Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -90,7 +90,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-unistyles": "^3.0.15",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
const { redirectMock, state } = vi.hoisted(() => {
|
||||
const hoistedState = {
|
||||
pathname: "/",
|
||||
bootstrapState: {
|
||||
@@ -23,7 +23,6 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
return {
|
||||
navigateToWorkspaceMock: vi.fn(),
|
||||
redirectMock: vi.fn(),
|
||||
state: hoistedState,
|
||||
};
|
||||
@@ -51,7 +50,6 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
@@ -72,7 +70,6 @@ describe("Index route startup navigation", () => {
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
redirectMock.mockReset();
|
||||
|
||||
container = document.createElement("div");
|
||||
@@ -108,7 +105,6 @@ describe("Index route startup navigation", () => {
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -118,11 +114,8 @@ describe("Index route startup navigation", () => {
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
@@ -131,10 +124,7 @@ describe("Index route startup navigation", () => {
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
resolveStartupWorkspaceSelection,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
|
||||
@@ -37,17 +37,15 @@ export default function Index() {
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!startupWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}, [pathname, startupWorkspaceSelection]);
|
||||
|
||||
if (startupWorkspaceSelection) {
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
return (
|
||||
<Redirect
|
||||
href={buildHostWorkspaceRoute(
|
||||
startupWorkspaceSelection.serverId,
|
||||
startupWorkspaceSelection.workspaceId,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectRoute) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -95,20 +96,27 @@ export function DiffViewer({
|
||||
const outerScrollStyle = React.useMemo(
|
||||
() => [
|
||||
styles.verticalScroll,
|
||||
maxHeight !== undefined && { maxHeight },
|
||||
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
],
|
||||
[maxHeight, fillAvailableHeight, webScrollbarStyle],
|
||||
);
|
||||
const linesContainerStyle = React.useMemo(
|
||||
() => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }],
|
||||
() => [
|
||||
styles.linesContainer,
|
||||
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
|
||||
],
|
||||
[scrollViewWidth],
|
||||
);
|
||||
const keyedDiffLines = React.useMemo(
|
||||
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
|
||||
[diffLines],
|
||||
);
|
||||
const webVerticalContentStyle = React.useMemo(
|
||||
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
|
||||
[fillAvailableHeight],
|
||||
);
|
||||
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
@@ -118,29 +126,39 @@ export function DiffViewer({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const lines = (
|
||||
<View style={linesContainerStyle}>
|
||||
{keyedDiffLines.map(({ key, line }) => (
|
||||
<DiffLineRow key={key} line={line} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
const horizontalScroll = (
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
onLayout={handleInnerLayout}
|
||||
>
|
||||
{lines}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<ScrollView
|
||||
style={outerScrollStyle}
|
||||
contentContainerStyle={styles.verticalContent}
|
||||
contentContainerStyle={webVerticalContentStyle}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
onLayout={handleInnerLayout}
|
||||
>
|
||||
<View style={linesContainerStyle}>
|
||||
{keyedDiffLines.map(({ key, line }) => (
|
||||
<DiffLineRow key={key} line={line} />
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
{horizontalScroll}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -120,7 +121,10 @@ const CodeLine = React.memo(function CodeLine({
|
||||
gutterWidth,
|
||||
highlighted,
|
||||
}: CodeLineProps) {
|
||||
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
||||
const gutterStyle = useMemo(
|
||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
||||
[gutterWidth],
|
||||
);
|
||||
const lineStyle = useMemo(
|
||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
||||
[highlighted],
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -79,7 +80,7 @@ function useDetailStyles(
|
||||
const codeVerticalScrollStyle = useMemo(
|
||||
() => [
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
],
|
||||
@@ -88,7 +89,7 @@ function useDetailStyles(
|
||||
const scrollAreaFillStyle = useMemo(
|
||||
() => [
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
],
|
||||
@@ -97,7 +98,7 @@ function useDetailStyles(
|
||||
const scrollAreaStyle = useMemo(
|
||||
() => [
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
|
||||
webScrollbarStyle,
|
||||
],
|
||||
[resolvedMaxHeight, webScrollbarStyle],
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
useWindowDimensions,
|
||||
type LayoutChangeEvent,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
@@ -858,7 +861,7 @@ interface DesktopContainerStyleInput {
|
||||
availableHeight: number | undefined;
|
||||
}
|
||||
|
||||
function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
|
||||
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
|
||||
const {
|
||||
desktopMinWidth,
|
||||
referenceWidth,
|
||||
@@ -877,7 +880,6 @@ function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
|
||||
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
|
||||
: null;
|
||||
return [
|
||||
styles.desktopContainer,
|
||||
{
|
||||
position: "absolute" as const,
|
||||
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
|
||||
@@ -1070,7 +1072,7 @@ interface DesktopBodyProps {
|
||||
handleClose: () => void;
|
||||
refs: ReturnType<typeof useFloating>["refs"];
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopContainerStyle: unknown;
|
||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
|
||||
header: SheetHeader | undefined;
|
||||
stickyHeader: ReactNode;
|
||||
@@ -1191,11 +1193,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
>
|
||||
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
|
||||
<Animated.View
|
||||
<FloatingSurface
|
||||
testID="combobox-desktop-container"
|
||||
entering={props.shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
|
||||
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||
style={props.desktopContainerStyle as never}
|
||||
style={styles.desktopContainer}
|
||||
frameStyle={props.desktopFrameStyle}
|
||||
ref={props.refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={props.handleDesktopContentLayout}
|
||||
@@ -1227,7 +1230,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
renderOption={props.renderOption}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
@@ -1492,9 +1495,9 @@ export function Combobox({
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
|
||||
const desktopContainerStyle = useMemo(
|
||||
const desktopFrameStyle = useMemo(
|
||||
() =>
|
||||
buildDesktopContainerStyle({
|
||||
buildDesktopFrameStyle({
|
||||
desktopMinWidth,
|
||||
referenceWidth,
|
||||
desktopFixedHeight,
|
||||
@@ -1561,7 +1564,7 @@ export function Combobox({
|
||||
handleClose={handleClose}
|
||||
refs={refs}
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopContainerStyle={desktopContainerStyle}
|
||||
desktopFrameStyle={desktopFrameStyle}
|
||||
handleDesktopContentLayout={handleDesktopContentLayout}
|
||||
header={header}
|
||||
stickyHeader={stickyHeader}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Text,
|
||||
View,
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
@@ -498,7 +498,7 @@ export function ContextMenuContent({
|
||||
[],
|
||||
);
|
||||
|
||||
const animatedContentStyle = useMemo(() => {
|
||||
const frameStyle = useMemo(() => {
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
? { width: screenWidth - horizontalPadding * 2 }
|
||||
@@ -508,7 +508,6 @@ export function ContextMenuContent({
|
||||
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||
};
|
||||
return [
|
||||
styles.content,
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
position: "absolute" as const,
|
||||
@@ -566,23 +565,24 @@ export function ContextMenuContent({
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
<Animated.View
|
||||
<FloatingSurface
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleContentLayout}
|
||||
style={animatedContentStyle}
|
||||
style={styles.content}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
<ScrollView
|
||||
<FloatingScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</FloatingScrollView>
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
Dimensions,
|
||||
@@ -25,10 +24,10 @@ import {
|
||||
type ViewStyle,
|
||||
type StyleProp,
|
||||
} from "react-native";
|
||||
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
@@ -172,42 +171,37 @@ function computePosition({
|
||||
return { x, y, actualPlacement };
|
||||
}
|
||||
|
||||
interface SharedDropdownContentProps {
|
||||
collapsable: false;
|
||||
testID?: string;
|
||||
style: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
function renderDropdownSurface(input: {
|
||||
isWebSurface: boolean;
|
||||
sharedProps: SharedDropdownContentProps;
|
||||
frameStyle: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
surfaceStyle: StyleProp<ViewStyle>;
|
||||
scrollable: boolean;
|
||||
scrollViewportStyle: StyleProp<ViewStyle>;
|
||||
content: ReactElement;
|
||||
onExited: () => void;
|
||||
}): ReactElement {
|
||||
const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
|
||||
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
|
||||
input;
|
||||
|
||||
const body = scrollable ? (
|
||||
<ScrollView
|
||||
<FloatingScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={scrollViewportStyle}
|
||||
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
</FloatingScrollView>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
|
||||
if (isWebSurface) {
|
||||
return <View {...sharedProps}>{body}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
{...sharedProps}
|
||||
<FloatingSurface
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
entering={contentEntering}
|
||||
exiting={contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
@@ -217,7 +211,7 @@ function renderDropdownSurface(input: {
|
||||
})}
|
||||
>
|
||||
{body}
|
||||
</Animated.View>
|
||||
</FloatingSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -513,7 +507,8 @@ export function DropdownMenuContent({
|
||||
[],
|
||||
);
|
||||
|
||||
const contentStyle = useMemo(() => {
|
||||
const surfaceStyle = styles.content;
|
||||
const frameStyle = useMemo(() => {
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
? { width: screenWidth - horizontalPadding * 2 }
|
||||
@@ -523,7 +518,6 @@ export function DropdownMenuContent({
|
||||
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||
};
|
||||
return [
|
||||
styles.content,
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
position: "absolute" as const,
|
||||
@@ -543,14 +537,6 @@ export function DropdownMenuContent({
|
||||
actualPlacement,
|
||||
align,
|
||||
]);
|
||||
const sharedContentProps = useMemo(
|
||||
() => ({
|
||||
collapsable: false as const,
|
||||
testID,
|
||||
style: contentStyle,
|
||||
}),
|
||||
[testID, contentStyle],
|
||||
);
|
||||
const scrollViewportStyle = useMemo(
|
||||
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
|
||||
[visibleContentSize, webScrollbarStyle],
|
||||
@@ -583,8 +569,9 @@ export function DropdownMenuContent({
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
isWebSurface: isWeb,
|
||||
sharedProps: sharedContentProps,
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { forwardRef, useMemo, type ComponentProps, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
type ScrollViewProps,
|
||||
type StyleProp,
|
||||
type View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FloatingSurfaceProps extends Omit<ComponentProps<typeof Animated.View>, "style"> {
|
||||
frameStyle?: StyleProp<ViewStyle>;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export const FloatingSurface = forwardRef<View, FloatingSurfaceProps>(function FloatingSurface(
|
||||
{ frameStyle, style, ...props },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const inlineFrameStyle = useMemo(() => {
|
||||
const flattened = StyleSheet.flatten(frameStyle);
|
||||
return flattened ? inlineUnistylesStyle(flattened) : undefined;
|
||||
}, [frameStyle]);
|
||||
const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
|
||||
return <Animated.View {...props} ref={ref} style={surfaceStyle} />;
|
||||
});
|
||||
|
||||
export interface FloatingScrollViewProps {
|
||||
bounces?: boolean;
|
||||
children: ReactNode;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"];
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function FloatingScrollView({
|
||||
bounces,
|
||||
children,
|
||||
contentContainerStyle,
|
||||
keyboardShouldPersistTaps,
|
||||
showsVerticalScrollIndicator,
|
||||
style,
|
||||
}: FloatingScrollViewProps): ReactElement {
|
||||
const inlineStyle = useMemo(() => {
|
||||
const flattened = StyleSheet.flatten(style);
|
||||
return flattened ? inlineUnistylesStyle(flattened) : undefined;
|
||||
}, [style]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
bounces={bounces}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
style={inlineStyle}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -25,9 +25,10 @@ import {
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
@@ -494,19 +495,18 @@ export function TooltipContent({
|
||||
[],
|
||||
);
|
||||
|
||||
const contentStyle = useMemo(
|
||||
const frameStyle = useMemo(
|
||||
() => [
|
||||
styles.content,
|
||||
{ maxWidth },
|
||||
style,
|
||||
{
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
maxWidth,
|
||||
},
|
||||
],
|
||||
[maxWidth, style, position?.x, position?.y],
|
||||
[maxWidth, position?.x, position?.y],
|
||||
);
|
||||
const contentStyle = useMemo(() => [styles.content, style], [style]);
|
||||
|
||||
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
|
||||
|
||||
@@ -519,7 +519,7 @@ export function TooltipContent({
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
<FloatingSurface
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
@@ -527,9 +527,10 @@ export function TooltipContent({
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
@@ -544,7 +545,7 @@ export function TooltipContent({
|
||||
onRequestClose={handleDismiss}
|
||||
>
|
||||
<Pressable style={styles.overlay} onPress={handleDismiss}>
|
||||
<Animated.View
|
||||
<FloatingSurface
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
@@ -552,9 +553,10 @@ export function TooltipContent({
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</FloatingSurface>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PanResponder,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ViewStyle,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HANDLE_WIDTH_IDLE = 6;
|
||||
@@ -27,6 +30,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
|
||||
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
|
||||
const HANDLE_SCROLL_ACTIVE_MS = 110;
|
||||
|
||||
interface WebPointerStyle {
|
||||
cursor?: "grab" | "grabbing";
|
||||
touchAction?: "none";
|
||||
userSelect?: "none";
|
||||
transitionProperty?: string;
|
||||
transitionDuration?: string;
|
||||
transitionTimingFunction?: string;
|
||||
}
|
||||
|
||||
interface PointerLikeEvent {
|
||||
clientY?: number;
|
||||
pageY?: number;
|
||||
@@ -59,6 +71,13 @@ function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
interface WebDesktopScrollbarOverlayProps {
|
||||
enabled: boolean;
|
||||
metrics: ScrollbarMetrics;
|
||||
onScrollToOffset: (offset: number) => void;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export function useWebDesktopScrollbarMetrics() {
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
@@ -115,13 +134,6 @@ export function useWebDesktopScrollbarMetrics() {
|
||||
};
|
||||
}
|
||||
|
||||
interface WebDesktopScrollbarOverlayProps {
|
||||
enabled: boolean;
|
||||
metrics: ScrollbarMetrics;
|
||||
onScrollToOffset: (offset: number) => void;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export function WebDesktopScrollbarOverlay({
|
||||
enabled,
|
||||
metrics,
|
||||
@@ -273,8 +285,12 @@ export function WebDesktopScrollbarOverlay({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onPanResponderGrant: () => {
|
||||
onPanResponderGrant: (event: GestureResponderEvent) => {
|
||||
const clientY = readClientY(event);
|
||||
dragStartOffsetRef.current = normalizedOffsetRef.current;
|
||||
if (clientY !== null) {
|
||||
dragStartClientYRef.current = clientY;
|
||||
}
|
||||
setIsDragging(true);
|
||||
},
|
||||
onPanResponderMove: (_event, gestureState) => {
|
||||
@@ -362,20 +378,19 @@ export function WebDesktopScrollbarOverlay({
|
||||
const thumbRegionStyle = useMemo(
|
||||
() => [
|
||||
styles.thumbRegion,
|
||||
{
|
||||
top: 0,
|
||||
inlineUnistylesStyle({
|
||||
height: thumbRegionHeight,
|
||||
transform: [{ translateY: thumbRegionOffset }],
|
||||
},
|
||||
}),
|
||||
platformIsWeb &&
|
||||
({
|
||||
inlineUnistylesStyle({
|
||||
cursor: handleCursor,
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
transitionProperty: "transform",
|
||||
transitionDuration: `${handleTravelDurationMs}ms`,
|
||||
transitionTimingFunction: "linear",
|
||||
} as object),
|
||||
} satisfies WebPointerStyle as unknown as ViewStyle),
|
||||
],
|
||||
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
|
||||
);
|
||||
@@ -383,19 +398,19 @@ export function WebDesktopScrollbarOverlay({
|
||||
const handleStyle = useMemo(
|
||||
() => [
|
||||
styles.handle,
|
||||
{
|
||||
inlineUnistylesStyle({
|
||||
marginTop: handleInsetTop,
|
||||
height: geometry.handleSize,
|
||||
width: handleWidth,
|
||||
backgroundColor: handleColor,
|
||||
opacity: handleOpacity,
|
||||
},
|
||||
}),
|
||||
platformIsWeb &&
|
||||
({
|
||||
inlineUnistylesStyle({
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
|
||||
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
||||
} as object),
|
||||
} satisfies WebPointerStyle as unknown as ViewStyle),
|
||||
],
|
||||
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
|
||||
);
|
||||
@@ -413,8 +428,6 @@ export function WebDesktopScrollbarOverlay({
|
||||
{...(platformIsWeb
|
||||
? ({
|
||||
onPointerDown: startWebDrag,
|
||||
onPointerEnter: handleGrabHoverIn,
|
||||
onPointerLeave: handleGrabHoverOut,
|
||||
onMouseEnter: handleGrabHoverIn,
|
||||
onMouseLeave: handleGrabHoverOut,
|
||||
} as object)
|
||||
@@ -446,5 +459,6 @@ const styles = StyleSheet.create(() => ({
|
||||
position: "absolute",
|
||||
right: -3,
|
||||
width: HANDLE_GRAB_WIDTH,
|
||||
top: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dimensions, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
@@ -24,6 +24,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface Rect {
|
||||
@@ -254,23 +255,19 @@ function WorkspaceHoverCardContent({
|
||||
[],
|
||||
);
|
||||
|
||||
const cardStyle = useMemo(
|
||||
() => [
|
||||
styles.card,
|
||||
{
|
||||
width: HOVER_CARD_WIDTH,
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
],
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
}),
|
||||
[position?.x, position?.y],
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
@@ -279,7 +276,8 @@ function WorkspaceHoverCardContent({
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel="Workspace scripts"
|
||||
testID="workspace-hover-card"
|
||||
style={cardStyle}
|
||||
style={styles.card}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
|
||||
@@ -303,7 +301,7 @@ function WorkspaceHoverCardContent({
|
||||
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
|
||||
</>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
@@ -441,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingTop: theme.spacing[2],
|
||||
width: HOVER_CARD_WIDTH,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
@@ -110,18 +111,20 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
const primaryPressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
|
||||
(Boolean(hovered) || pressed) &&
|
||||
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
|
||||
primaryDisabled && styles.splitButtonPrimaryDisabled,
|
||||
],
|
||||
[primaryDisabled],
|
||||
[primaryDisabled, theme.colors.surface2],
|
||||
);
|
||||
|
||||
const caretTriggerStyle = useCallback(
|
||||
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
|
||||
styles.splitButtonCaret,
|
||||
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||
(hovered || pressed || open) &&
|
||||
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
|
||||
],
|
||||
[],
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -230,9 +233,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
@@ -258,9 +258,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
splitButtonCaretHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
||||
@@ -78,6 +78,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import {
|
||||
@@ -113,25 +114,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
|
||||
}
|
||||
|
||||
function diffModeTriggerStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||
return [
|
||||
styles.diffModeTrigger,
|
||||
Boolean(hovered) && styles.diffModeTriggerHovered,
|
||||
(pressed || Boolean(open)) && styles.diffModeTriggerPressed,
|
||||
];
|
||||
}
|
||||
|
||||
function expandAllButtonStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
|
||||
}
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
wrapLines?: boolean;
|
||||
@@ -274,7 +256,12 @@ function DiffGutterCell({
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
|
||||
() => [
|
||||
styles.gutterCell,
|
||||
lineTypeBackground(type),
|
||||
inlineUnistylesStyle({ width: gutterWidth }),
|
||||
style,
|
||||
],
|
||||
[type, gutterWidth, style],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
@@ -544,7 +531,10 @@ function InlineReviewThreadContent({
|
||||
}) {
|
||||
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
||||
const height = reservedHeight ?? threadState?.height ?? 0;
|
||||
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
|
||||
const placeholderStyle = useMemo<ViewStyle>(
|
||||
() => inlineUnistylesStyle({ minHeight: height }),
|
||||
[height],
|
||||
);
|
||||
if (height === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -580,7 +570,11 @@ function InlineReviewGutterSpacer({
|
||||
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
||||
const height = reservedHeight ?? threadState?.height ?? 0;
|
||||
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style],
|
||||
() => [
|
||||
styles.inlineReviewGutterSpacer,
|
||||
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
|
||||
style,
|
||||
],
|
||||
[gutterWidth, height, style],
|
||||
);
|
||||
if (height === 0) {
|
||||
@@ -604,10 +598,13 @@ function InlineReviewRow({
|
||||
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
||||
const height = reservedHeight ?? threadState?.height ?? 0;
|
||||
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.inlineReviewGutterSpacer, { width: gutterWidth }],
|
||||
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
|
||||
[gutterWidth],
|
||||
);
|
||||
const placeholderStyle = useMemo<ViewStyle>(() => ({ minHeight: height }), [height]);
|
||||
const placeholderStyle = useMemo<ViewStyle>(
|
||||
() => inlineUnistylesStyle({ minHeight: height }),
|
||||
[height],
|
||||
);
|
||||
if (height === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -656,7 +653,10 @@ function SplitDiffColumn({
|
||||
[showDivider],
|
||||
);
|
||||
const linesContainerRowStyle = useMemo(
|
||||
() => [styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }],
|
||||
() => [
|
||||
styles.linesContainer,
|
||||
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
|
||||
],
|
||||
[scrollWidth],
|
||||
);
|
||||
|
||||
@@ -916,7 +916,10 @@ function DiffFileBody({
|
||||
|
||||
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
|
||||
const linesContainerRowStyle = useMemo(
|
||||
() => [styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }],
|
||||
() => [
|
||||
styles.linesContainer,
|
||||
availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
|
||||
],
|
||||
[availableWidth],
|
||||
);
|
||||
|
||||
@@ -1062,7 +1065,7 @@ interface GitDiffPaneProps {
|
||||
}
|
||||
|
||||
type PressableStyleFn = (
|
||||
state: PressableStateCallbackType & { hovered?: boolean },
|
||||
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
|
||||
) => StyleProp<ViewStyle>;
|
||||
|
||||
interface DiffLayoutToggleGroupProps {
|
||||
@@ -1436,14 +1439,30 @@ function computePrErrorMessage(
|
||||
return prPayloadError?.message ?? null;
|
||||
}
|
||||
|
||||
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
|
||||
return ({ hovered, pressed, open }) => [
|
||||
styles.diffModeTrigger,
|
||||
(Boolean(hovered) || pressed || Boolean(open)) &&
|
||||
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
|
||||
];
|
||||
}
|
||||
|
||||
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
|
||||
return ({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
|
||||
];
|
||||
}
|
||||
|
||||
function buildToggleButtonStyle(
|
||||
selected: boolean,
|
||||
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
|
||||
surfaceColor: string,
|
||||
): PressableStyleFn {
|
||||
return ({ hovered, pressed }) => [
|
||||
baseStyles,
|
||||
selected && styles.toggleButtonSelected,
|
||||
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
|
||||
(selected || Boolean(hovered) || pressed) &&
|
||||
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1494,32 +1513,50 @@ export function GitDiffPane({
|
||||
handleLayoutChange("split");
|
||||
}, [handleLayoutChange]);
|
||||
|
||||
const controlSurfaceColor = theme.colors.surface2;
|
||||
const diffModeTriggerStyle = useMemo(
|
||||
() => buildDiffModeTriggerStyle(controlSurfaceColor),
|
||||
[controlSurfaceColor],
|
||||
);
|
||||
|
||||
const unifiedToggleStyle = useMemo(
|
||||
() =>
|
||||
buildToggleButtonStyle(changesPreferences.layout === "unified", [
|
||||
styles.toggleButton,
|
||||
styles.toggleButtonGroupStart,
|
||||
]),
|
||||
[changesPreferences.layout],
|
||||
buildToggleButtonStyle(
|
||||
changesPreferences.layout === "unified",
|
||||
[styles.toggleButton, styles.toggleButtonGroupStart],
|
||||
controlSurfaceColor,
|
||||
),
|
||||
[changesPreferences.layout, controlSurfaceColor],
|
||||
);
|
||||
|
||||
const splitToggleStyle = useMemo(
|
||||
() =>
|
||||
buildToggleButtonStyle(changesPreferences.layout === "split", [
|
||||
styles.toggleButton,
|
||||
styles.toggleButtonGroupEnd,
|
||||
]),
|
||||
[changesPreferences.layout],
|
||||
buildToggleButtonStyle(
|
||||
changesPreferences.layout === "split",
|
||||
[styles.toggleButton, styles.toggleButtonGroupEnd],
|
||||
controlSurfaceColor,
|
||||
),
|
||||
[changesPreferences.layout, controlSurfaceColor],
|
||||
);
|
||||
|
||||
const hideWhitespaceToggleStyle = useMemo(
|
||||
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
|
||||
[changesPreferences.hideWhitespace],
|
||||
() =>
|
||||
buildToggleButtonStyle(
|
||||
changesPreferences.hideWhitespace,
|
||||
styles.expandAllButton,
|
||||
controlSurfaceColor,
|
||||
),
|
||||
[changesPreferences.hideWhitespace, controlSurfaceColor],
|
||||
);
|
||||
|
||||
const wrapLinesToggleStyle = useMemo(
|
||||
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
|
||||
[wrapLines],
|
||||
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
|
||||
[wrapLines, controlSurfaceColor],
|
||||
);
|
||||
|
||||
const expandAllToggleStyle = useMemo(
|
||||
() => buildExpandAllButtonStyle(controlSurfaceColor),
|
||||
[controlSurfaceColor],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -2046,7 +2083,7 @@ export function GitDiffPane({
|
||||
allExpanded={allExpanded}
|
||||
isMobile={isMobile}
|
||||
wrapLinesToggleStyle={wrapLinesToggleStyle}
|
||||
expandAllToggleStyle={expandAllButtonStyle}
|
||||
expandAllToggleStyle={expandAllToggleStyle}
|
||||
onToggleWrapLines={handleToggleWrapLines}
|
||||
onToggleExpandAll={handleToggleExpandAll}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import "@/test/window-local-storage";
|
||||
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import {
|
||||
@@ -275,7 +276,7 @@ describe("git diff inline review helpers", () => {
|
||||
viewportWidth: 320,
|
||||
pinToViewport: true,
|
||||
}),
|
||||
).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
|
||||
).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]);
|
||||
});
|
||||
|
||||
it("keeps the gutter add-comment target accessible and clicking opens the editor", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
|
||||
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
@@ -391,7 +392,7 @@ export function InlineReviewThread({
|
||||
() => [
|
||||
styles.threadContainer,
|
||||
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
|
||||
{ minHeight: height },
|
||||
inlineUnistylesStyle({ minHeight: height }),
|
||||
],
|
||||
[viewportWidth, pinToViewport, height],
|
||||
);
|
||||
@@ -478,7 +479,8 @@ export function getInlineReviewThreadViewportStyle({
|
||||
viewportWidth?: number;
|
||||
pinToViewport: boolean;
|
||||
}): StyleProp<ViewStyle> {
|
||||
const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
|
||||
const widthStyle =
|
||||
viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null;
|
||||
if (!pinToViewport || !isWeb) {
|
||||
return widthStyle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style";
|
||||
|
||||
/**
|
||||
* Forces a style object through Unistyles' inline/animated-style lane.
|
||||
*
|
||||
* Unistyles web sends ordinary style objects to the CSS registry. Styles that
|
||||
* look like animated styles stay in React Native's style array instead. Use
|
||||
* this only for high-churn values, such as measured dimensions, drag
|
||||
* transforms, and pressed/hovered state.
|
||||
*/
|
||||
export function inlineUnistylesStyle<TStyle extends object>(style: TStyle): TStyle {
|
||||
if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) {
|
||||
return style;
|
||||
}
|
||||
|
||||
Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, {
|
||||
value: {},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return style;
|
||||
}
|
||||
@@ -46,6 +46,12 @@ export function addRunOptions(cmd: Command): Command {
|
||||
[],
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current)")
|
||||
.option(
|
||||
"--env <key=value>",
|
||||
"Set environment variable(s) for the agent process (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
@@ -95,6 +101,7 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
base?: string;
|
||||
image?: string[];
|
||||
cwd?: string;
|
||||
env?: string[];
|
||||
label?: string[];
|
||||
waitTimeout?: string;
|
||||
outputSchema?: string;
|
||||
@@ -320,15 +327,41 @@ function loadRunImages(
|
||||
}
|
||||
|
||||
function parseRunLabels(labelFlags: string[] | undefined): Record<string, string> {
|
||||
return parseKeyValueFlags(labelFlags, {
|
||||
flagName: "--label",
|
||||
code: "INVALID_LABEL",
|
||||
noun: "label",
|
||||
pluralNoun: "Labels",
|
||||
});
|
||||
}
|
||||
|
||||
function parseRunEnv(envFlags: string[] | undefined): Record<string, string> {
|
||||
return parseKeyValueFlags(envFlags, {
|
||||
flagName: "--env",
|
||||
code: "INVALID_ENV",
|
||||
noun: "environment variable",
|
||||
pluralNoun: "Environment variables",
|
||||
});
|
||||
}
|
||||
|
||||
function parseKeyValueFlags(
|
||||
flags: string[] | undefined,
|
||||
options: {
|
||||
flagName: string;
|
||||
code: CommandError["code"];
|
||||
noun: string;
|
||||
pluralNoun: string;
|
||||
},
|
||||
): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
if (!labelFlags) return labels;
|
||||
for (const labelStr of labelFlags) {
|
||||
if (!flags) return labels;
|
||||
for (const labelStr of flags) {
|
||||
const eqIndex = labelStr.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
throw {
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${labelStr}`,
|
||||
details: "Labels must be in key=value format",
|
||||
code: options.code,
|
||||
message: `Invalid ${options.noun} format: ${labelStr}`,
|
||||
details: `${options.pluralNoun} must be in key=value format`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const key = labelStr.slice(0, eqIndex);
|
||||
@@ -394,6 +427,8 @@ export async function runRunCommand(
|
||||
: undefined;
|
||||
|
||||
const labels = parseRunLabels(options.label);
|
||||
const env = parseRunEnv(options.env);
|
||||
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
|
||||
|
||||
if (outputSchema) {
|
||||
let structuredAgent: AgentSnapshotPayload | null = null;
|
||||
@@ -410,6 +445,7 @@ export async function runRunCommand(
|
||||
initialPrompt: structuredPrompt,
|
||||
outputSchema,
|
||||
images,
|
||||
env: requestEnv,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
@@ -479,6 +515,7 @@ export async function runRunCommand(
|
||||
thinkingOptionId,
|
||||
initialPrompt: prompt,
|
||||
images,
|
||||
env: requestEnv,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
|
||||
@@ -249,6 +249,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
config?: AgentSessionConfig;
|
||||
provider?: AgentProvider;
|
||||
cwd?: string;
|
||||
env?: CreateAgentRequestMessage["env"];
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
clientMessageId?: string;
|
||||
@@ -1837,6 +1838,7 @@ export class DaemonClient {
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
config,
|
||||
...(options.env ? { env: options.env } : {}),
|
||||
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||
@@ -4667,6 +4669,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
config,
|
||||
provider,
|
||||
cwd,
|
||||
env: _env,
|
||||
workspaceId: _workspaceId,
|
||||
initialPrompt: _initialPrompt,
|
||||
images: _images,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -147,6 +148,46 @@ class TestAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
class EnvProbeAgentClient extends TestAgentClient {
|
||||
probe: Promise<{ probe: string | null; agentId: string | null }> | null = null;
|
||||
|
||||
override async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const script = `
|
||||
process.stdout.write(JSON.stringify({
|
||||
probe: process.env.CHUNK14_PROBE ?? null,
|
||||
agentId: process.env.PASEO_AGENT_ID ?? null
|
||||
}));
|
||||
`;
|
||||
const child = spawn(process.execPath, ["-e", script], {
|
||||
cwd: config.cwd,
|
||||
env: { ...process.env, ...launchContext?.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
this.probe = new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`env probe exited ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(stdout) as { probe: string | null; agentId: string | null });
|
||||
});
|
||||
});
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
class TestAgentSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
@@ -373,6 +414,40 @@ test("normalizeConfig injects the provider default model when omitted", async ()
|
||||
expect(snapshot.config.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
test("createAgent forwards request env into the spawned provider process", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-"));
|
||||
const client = new EnvProbeAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000e001",
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(client.probe).resolves.toEqual({
|
||||
probe: "expected",
|
||||
agentId: "00000000-0000-4000-8000-00000000e001",
|
||||
});
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeConfig strips legacy 'default' model id", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -805,6 +805,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -826,7 +827,7 @@ export class AgentManager {
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(injectedConfig),
|
||||
);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env);
|
||||
const client = await this.requireAvailableClient({
|
||||
provider: normalizedConfig.provider,
|
||||
});
|
||||
@@ -3469,10 +3470,11 @@ export class AgentManager {
|
||||
: next;
|
||||
}
|
||||
|
||||
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
|
||||
return {
|
||||
agentId,
|
||||
env: {
|
||||
...env,
|
||||
PASEO_AGENT_ID: agentId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1416,7 +1416,27 @@ describe("transformPiModels", () => {
|
||||
|
||||
describe("ACPAgentSession slash commands", () => {
|
||||
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
|
||||
const session = createSession();
|
||||
const session = new ACPAgentSession(
|
||||
{
|
||||
provider: "claude-acp",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "claude-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["claude", "--acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
waitForInitialCommands: false,
|
||||
},
|
||||
);
|
||||
|
||||
await expect(session.listCommands()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ interface CursorACPAgentClientOptions {
|
||||
}
|
||||
|
||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
|
||||
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
|
||||
|
||||
export class CursorACPAgentClient extends GenericACPAgentClient {
|
||||
@@ -21,7 +22,16 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
|
||||
private readonly env?: Record<string, string>;
|
||||
|
||||
constructor(options: CursorACPAgentClientOptions) {
|
||||
super(options);
|
||||
super({
|
||||
logger: options.logger,
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
// cursor-agent publishes slash commands asynchronously via available_commands_update.
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
this.cursorCommand = options.command;
|
||||
this.env = options.env;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ interface GenericACPAgentClientOptions {
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class GenericACPAgentClient extends ACPAgentClient {
|
||||
@@ -42,6 +44,8 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
env: options.env,
|
||||
},
|
||||
defaultCommand: options.command,
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
|
||||
this.command = options.command;
|
||||
|
||||
@@ -1138,6 +1138,40 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCodeAgentClient env", () => {
|
||||
test("passes launch-context env to env-specific server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
|
||||
try {
|
||||
const session = await client.createSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
},
|
||||
{
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
},
|
||||
);
|
||||
await session.close();
|
||||
|
||||
expect(runtime.acquisitions[0]).toMatchObject({
|
||||
force: false,
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode persisted sessions", () => {
|
||||
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
|
||||
@@ -961,7 +961,10 @@ interface OpenCodeAgentClientDeps {
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
constructor(private readonly serverManager: OpenCodeServerManager) {}
|
||||
|
||||
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
return this.serverManager.acquire(options);
|
||||
}
|
||||
|
||||
@@ -1007,7 +1010,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: launchContext?.env,
|
||||
});
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
|
||||
@@ -10,7 +10,10 @@ export interface OpenCodeServerAcquisition {
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntime {
|
||||
acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
|
||||
acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition>;
|
||||
ensureServerRunning(): Promise<{ port: number; url: string }>;
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
|
||||
shutdown(): Promise<void>;
|
||||
|
||||
@@ -21,7 +21,10 @@ export interface OpenCodeServerAcquisition {
|
||||
|
||||
export interface OpenCodeServerManagerLike {
|
||||
ensureRunning(): Promise<{ port: number; url: string }>;
|
||||
acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
|
||||
acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition>;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerGeneration {
|
||||
@@ -91,10 +94,22 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
return acquisition.server;
|
||||
}
|
||||
|
||||
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
async acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
if (options.env) {
|
||||
const server = await this.startDedicatedServer(options.env);
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
const server = options.force
|
||||
? await this.getForcedRefreshServer()
|
||||
: await this.getCurrentServer();
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
|
||||
server.refCount += 1;
|
||||
let released = false;
|
||||
return {
|
||||
@@ -164,7 +179,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
}
|
||||
|
||||
private async startServer(): Promise<OpenCodeServerGeneration> {
|
||||
private async startDedicatedServer(
|
||||
env: Record<string, string>,
|
||||
): Promise<OpenCodeServerGeneration> {
|
||||
const server = await this.startServer(env);
|
||||
server.retired = true;
|
||||
this.retiredServers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
|
||||
const port = await findAvailablePort();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const launchPrefix = await resolveProviderCommandPrefix(
|
||||
@@ -179,7 +203,10 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
{
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./ser
|
||||
|
||||
export interface TestOpenCodeServerAcquisition {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
released: boolean;
|
||||
}
|
||||
|
||||
@@ -15,9 +16,13 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
async acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition: TestOpenCodeServerAcquisition = {
|
||||
force: options.force,
|
||||
env: options.env,
|
||||
released: false,
|
||||
};
|
||||
this.acquisitions.push(acquisition);
|
||||
|
||||
+10
-3
@@ -8,7 +8,11 @@ interface OpenCodeResponse {
|
||||
}
|
||||
|
||||
export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
|
||||
readonly acquisitions: Array<{
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
releaseCount: number;
|
||||
}> = [];
|
||||
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
|
||||
private readonly clients: TestOpenCodeClient[] = [];
|
||||
|
||||
@@ -18,8 +22,11 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition = { force: options.force, releaseCount: 0 };
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition = { force: options.force, env: options.env, releaseCount: 0 };
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
|
||||
@@ -42,6 +42,22 @@ async function createSession(pi = new FakePi()): Promise<{
|
||||
return { pi, session, events };
|
||||
}
|
||||
|
||||
test("forwards launch-context env to the Pi process launch", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
const session = await client.createSession(createConfig(), {
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
});
|
||||
|
||||
expect(pi.recordedLaunches[0]?.env).toEqual({
|
||||
CHUNK14_PROBE: "expected",
|
||||
});
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
class SessionEvents {
|
||||
private readonly events: AgentStreamEvent[] = [];
|
||||
private readonly waiters: Array<{
|
||||
|
||||
@@ -843,7 +843,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
@@ -857,6 +857,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
config.systemPrompt,
|
||||
config.daemonAppendSystemPrompt,
|
||||
),
|
||||
env: launchContext?.env,
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PiRuntimeLaunch {
|
||||
|
||||
export interface PiStartSessionInput {
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
session?: string;
|
||||
@@ -84,7 +85,13 @@ export function buildPiLaunch(input: {
|
||||
return {
|
||||
cwd: input.session.cwd,
|
||||
argv,
|
||||
env: input.runtimeSettings?.env,
|
||||
env:
|
||||
input.runtimeSettings?.env || input.session.env
|
||||
? {
|
||||
...input.runtimeSettings?.env,
|
||||
...input.session.env,
|
||||
}
|
||||
: undefined,
|
||||
model: input.session.model,
|
||||
thinkingOptionId: input.session.thinkingOptionId,
|
||||
session: input.session.session,
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
|
||||
import { serializeAgentStreamEvent } from "./messages.js";
|
||||
import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js";
|
||||
|
||||
describe("serializeAgentStreamEvent", () => {
|
||||
test("accepts create_agent_request env records", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-env",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects non-string create_agent_request env values", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-env",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
env: {
|
||||
CHUNK14_PROBE: 14,
|
||||
},
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves user_message text as-is", () => {
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
|
||||
@@ -3084,6 +3084,7 @@ export class Session {
|
||||
images,
|
||||
attachments,
|
||||
labels,
|
||||
env,
|
||||
} = msg;
|
||||
this.sessionLogger.info(
|
||||
{ cwd: config.cwd, provider: config.provider, worktreeName },
|
||||
@@ -3124,6 +3125,7 @@ export class Session {
|
||||
labels,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
initialPrompt: trimmedPrompt,
|
||||
env,
|
||||
});
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
|
||||
|
||||
@@ -1061,6 +1061,7 @@ export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
|
||||
export const CreateAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("create_agent_request"),
|
||||
config: AgentSessionConfigSchema,
|
||||
env: z.record(z.string()).optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
worktreeName: z.string().optional(),
|
||||
initialPrompt: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user