Merge remote-tracking branch 'origin/main' into merge/main-into-dev

# Conflicts:
#	CLAUDE.md
#	nix/package.nix
#	packages/app/src/components/combined-model-selector.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/app/src/stores/session-store.test.ts
#	packages/server/src/client/daemon-client.ts
#	packages/server/src/server/agent/agent-manager.ts
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspace-git-watch.test.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/shared/messages.ts
This commit is contained in:
Mohamed Boudra
2026-04-08 11:38:17 +07:00
90 changed files with 6284 additions and 923 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.48",
"version": "0.1.49",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.48",
"@getpaseo/highlight": "0.1.48",
"@getpaseo/server": "0.1.48",
"@getpaseo/expo-two-way-audio": "0.1.49",
"@getpaseo/highlight": "0.1.49",
"@getpaseo/server": "0.1.49",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -30,6 +30,8 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: 520,
maxHeight: "85%",
flexShrink: 1,
minHeight: 0,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
@@ -61,11 +63,13 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
desktopScroll: {
flex: 1,
flexShrink: 1,
minHeight: 0,
},
desktopContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
flexGrow: 1,
},
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,
@@ -15,8 +15,8 @@ import {
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
@@ -51,7 +51,6 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = {
id: string;
@@ -75,6 +74,7 @@ type ControlledAgentStatusBarProps = {
modelOptions?: StatusOption[];
selectedModelId?: string;
onSelectModel?: (modelId: string) => void;
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
@@ -203,6 +203,7 @@ function ControlledStatusBar({
modelOptions,
selectedModelId,
onSelectModel,
onSelectProviderAndModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
@@ -264,7 +265,7 @@ function ControlledStatusBar({
return null;
}
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
const modelDisabled = disabled;
const SEARCH_THRESHOLD = 6;
@@ -648,10 +649,14 @@ function ControlledStatusBar({
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
if (onSelectProviderAndModel) {
onSelectProviderAndModel(selectedProviderId, modelId);
} else {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}
onSelectModel?.(modelId);
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
@@ -667,6 +672,7 @@ function ControlledStatusBar({
pointerEvents="none"
testID="agent-preferences-model"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
@@ -692,6 +698,7 @@ function ControlledStatusBar({
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
@@ -852,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
}
: null;
}),
@@ -863,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
} = useProvidersSnapshot(serverId);
const snapshotModels = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {
return null;
}
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
return entry?.models ?? null;
}, [snapshotEntries, agent?.provider]);
const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [agent?.provider]);
const agentProviderModelQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
if (agent?.provider && snapshotModels) {
map.set(agent.provider, snapshotModels);
}
return map;
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
}, [agent?.provider, snapshotModels]);
const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -1028,7 +1018,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
isModelLoading={snapshotIsLoading || snapshotIsFetching}
onDropdownClose={onDropdownClose}
disabled={!client}
/>
@@ -1135,6 +1125,7 @@ export function DraftAgentStatusBar({
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => onSelectModel(modelId)}
onSelectProviderAndModel={onSelectProviderAndModel}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
@@ -38,7 +38,10 @@ import {
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type {
AgentPermissionAction,
AgentPermissionResponse,
} from "@server/server/agent/agent-sdk-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -700,6 +703,29 @@ function PermissionRequestCard({
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const description = request.description ?? "";
const resolvedActions = useMemo((): AgentPermissionAction[] => {
if (request.kind === "question") {
return [];
}
if (Array.isArray(request.actions) && request.actions.length > 0) {
return request.actions;
}
return [
{
id: "reject",
label: "Deny",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "accept",
label: isPlanRequest ? "Implement" : "Accept",
behavior: "allow",
variant: "primary",
},
];
}, [isPlanRequest, request]);
const planMarkdown = useMemo(() => {
if (!request) {
@@ -740,11 +766,11 @@ function PermissionRequestCard({
isPending: isResponding,
} = permissionMutation;
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
useEffect(() => {
resetPermissionMutation();
setRespondingAction(null);
setRespondingActionId(null);
}, [permission.request.id, resetPermissionMutation]);
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
@@ -758,6 +784,24 @@ function PermissionRequestCard({
},
[permission.agentId, permission.request.id, respondToPermission],
);
const handleActionPress = useCallback(
(action: AgentPermissionAction) => {
setRespondingActionId(action.id);
if (action.behavior === "allow") {
handleResponse({
behavior: "allow",
selectedActionId: action.id,
});
return;
}
handleResponse({
behavior: "deny",
selectedActionId: action.id,
message: "Denied by user",
});
},
[handleResponse],
);
if (request.kind === "question") {
return (
@@ -784,64 +828,48 @@ function PermissionRequestCard({
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
<Pressable
testID="permission-request-deny"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
disabled={isResponding}
>
{respondingAction === "deny" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={permissionStyles.optionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
Deny
</Text>
</View>
)}
</Pressable>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
const testID =
action.behavior === "deny"
? "permission-request-deny"
: action.id === "accept" || action.id === "implement"
? "permission-request-accept"
: `permission-request-action-${action.id}`;
<Pressable
testID="permission-request-accept"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}}
disabled={isResponding}
>
{respondingAction === "accept" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={permissionStyles.optionContent}>
<Check size={14} color={theme.colors.foreground} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
Accept
</Text>
</View>
)}
</Pressable>
return (
<Pressable
key={action.id}
testID={testID}
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => handleActionPress(action)}
disabled={isResponding}
>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>
{action.label}
</Text>
</View>
)}
</Pressable>
);
})}
</View>
</>
);
+29
View File
@@ -12,6 +12,7 @@ import {
DraftAgentStatusBar,
type DraftAgentStatusBarProps,
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -140,6 +141,8 @@ export function Composer({
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
};
}),
);
@@ -649,6 +652,25 @@ export function Composer({
</View>
) : null;
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
typeof agentState.contextWindowUsedTokens === "number";
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter
? agentState.contextWindowUsedTokens
: null;
const beforeVoiceContent = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
@@ -729,6 +751,7 @@ export function Composer({
disabled={isSubmitLoading}
isInputActive={isInputActive}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
@@ -805,6 +828,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
@@ -0,0 +1,153 @@
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ContextWindowMeterProps = {
maxTokens: number;
usedTokens: number;
};
const SVG_SIZE = 20;
const CENTER = SVG_SIZE / 2;
const RADIUS = 7;
const STROKE_WIDTH = 2.25;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function isValidUsedTokens(value: number): boolean {
return Number.isFinite(value) && value >= 0;
}
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
return null;
}
return (usedTokens / maxTokens) * 100;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
}
return { progress: theme.colors.foregroundMuted, track };
}
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={styles.container}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
>
<Svg
width={SVG_SIZE}
height={SVG_SIZE}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.track}
strokeWidth={STROKE_WIDTH}
/>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.progress}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
</Svg>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
svg: {
transform: [{ rotate: "-90deg" }],
},
tooltipContent: {
gap: theme.spacing[1],
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -118,7 +118,6 @@ export function FileExplorerPane({
);
const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing,
requestFileDownloadToken,
selectExplorerEntry,
@@ -129,6 +128,16 @@ export function FileExplorerPane({
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setExpandedPathsForWorkspace = usePanelStore(
(state) => state.setExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
[expandedPathsArray],
);
const directories = explorerState?.directories ?? new Map();
const pendingRequest = explorerState?.pendingRequest ?? null;
@@ -144,7 +153,6 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null);
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
@@ -154,8 +162,7 @@ export function FileExplorerPane({
useEffect(() => {
hasInitializedRef.current = false;
setExpandedPaths(new Set(["."]));
}, [actionsWorkspaceStateKey]);
}, [workspaceStateKey]);
useEffect(() => {
if (!hasWorkspaceScope) {
@@ -169,23 +176,35 @@ export function FileExplorerPane({
recordHistory: false,
setCurrentPath: false,
});
}, [hasWorkspaceScope, requestDirectoryListing]);
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => {
if (!selectedEntryPath || !hasWorkspaceScope) {
if (!selectedEntryPath || !workspaceStateKey) {
return;
}
const parentDir = getParentDirectory(selectedEntryPath);
const ancestors = getAncestorDirectories(parentDir);
setExpandedPaths((prev) => {
const next = new Set(prev);
ancestors.forEach((path) => next.add(path));
return next;
});
ancestors.forEach((path) => {
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
if (newPaths.length === 0) {
return;
}
setExpandedPathsForWorkspace(
workspaceStateKey,
[...Array.from(expandedPaths), ...newPaths],
);
newPaths.forEach((path) => {
if (!directories.has(path)) {
void requestDirectoryListing(path, {
recordHistory: false,
@@ -193,34 +212,46 @@ export function FileExplorerPane({
});
}
});
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
}, [
directories,
workspaceStateKey,
expandedPaths,
requestDirectoryListing,
selectedEntryPath,
setExpandedPathsForWorkspace,
]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => {
if (!hasWorkspaceScope) {
if (!workspaceStateKey) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
const nextExpanded = !isExpanded;
setExpandedPaths((prev) => {
const next = new Set(prev);
if (isExpanded) {
next.delete(entry.path);
} else {
next.add(entry.path);
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
} else {
setExpandedPathsForWorkspace(
workspaceStateKey,
[...Array.from(expandedPaths), entry.path],
);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
}
return next;
});
if (nextExpanded && !directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
}
},
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
[
workspaceStateKey,
expandedPaths,
directories,
requestDirectoryListing,
setExpandedPathsForWorkspace,
],
);
const handleOpenFile = useCallback(
+457 -149
View File
@@ -18,17 +18,21 @@ import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
TextStyle,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
AlignJustify,
Archive,
ChevronDown,
Columns2,
GitBranch,
GitCommitHorizontal,
GitMerge,
ListChevronsDownUp,
ListChevronsUpDown,
Pilcrow,
RefreshCcw,
Upload,
WrapText,
@@ -42,6 +46,7 @@ import {
} from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "./diff-scroll";
import {
darkHighlightColors,
@@ -51,6 +56,7 @@ import {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout";
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,6 +72,8 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -77,13 +85,27 @@ type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps {
tokens: HighlightToken[];
baseStyle: HighlightStyle | null;
lineType: "add" | "remove" | "context" | "header";
wrapLines?: boolean;
}
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
type WrappedWebTextStyle = TextStyle & {
whiteSpace?: "pre" | "pre-wrap";
overflowWrap?: "normal" | "anywhere";
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") {
return undefined;
}
return wrapLines
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
: { whiteSpace: "pre", overflowWrap: "normal" };
}
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark";
const lineHeight = theme.lineHeight.diff;
const getTokenColor = (style: HighlightStyle | null): string => {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
@@ -93,9 +115,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
};
return (
<Text style={styles.diffLineText}>
<Text
style={[
styles.diffLineText,
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
]}
>
{tokens.map((token, index) => (
<Text key={index} style={{ color: getTokenColor(token.style) }}>
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
{token.text}
</Text>
))}
@@ -115,10 +142,12 @@ function DiffLineView({
line,
lineNumber,
gutterWidth,
wrapLines,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
wrapLines: boolean;
}) {
return (
<View
@@ -142,11 +171,12 @@ function DiffLineView({
</Text>
</View>
{line.tokens && line.type !== "header" ? (
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
@@ -160,6 +190,59 @@ function DiffLineView({
);
}
function SplitDiffCell({
line,
gutterWidth,
wrapLines,
showDivider = false,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
showDivider?: boolean;
}) {
return (
<View
style={[
styles.splitCell,
showDivider && styles.splitCellWithDivider,
!line && styles.emptySplitCell,
line?.type === "add" && styles.addLineContainer,
line?.type === "remove" && styles.removeLineContainer,
line?.type === "context" && styles.contextLineContainer,
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text
style={[
styles.lineNumberText,
line?.type === "add" && styles.addLineNumberText,
line?.type === "remove" && styles.removeLineNumberText,
]}
>
{line?.lineNumber != null ? String(line.lineNumber) : ""}
</Text>
</View>
{line?.tokens ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines) ,
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
]}
>
{line?.content ?? ""}
</Text>
)}
</View>
);
}
const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
@@ -245,21 +328,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
function DiffFileBody({
file,
layout,
wrapLines,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
setBodyWidth(event.nativeEvent.layout.width);
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
@@ -275,50 +362,80 @@ function DiffFileBody({
);
}
const linesContent = (() => {
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(
maxLineNo,
hunk.oldStart + hunk.oldCount,
hunk.newStart + hunk.newCount,
);
}
const gutterWidth = lineNumberGutterWidth(maxLineNo);
return file.hunks.map((hunk, hunkIndex) => {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
return hunk.lines.map((line, lineIndex) => {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo++;
newLineNo++;
}
return (
<DiffLineView
key={`${hunkIndex}-${lineIndex}`}
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
/>
);
});
});
})();
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
}
const gutterWidth = lineNumberGutterWidth(maxLineNo);
const linesContent =
layout === "split"
? buildSplitDiffRows(file).map((row, rowIndex) => {
if (row.kind === "header") {
return (
<View key={`header-${rowIndex}`} style={styles.splitHeaderRow}>
<Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
</View>
);
}
return (
<View key={`pair-${rowIndex}`} style={styles.splitRow}>
<SplitDiffCell line={row.left} gutterWidth={gutterWidth} wrapLines={wrapLines} />
<SplitDiffCell
line={row.right}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
showDivider
/>
</View>
);
})
: file.hunks.map((hunk, hunkIndex) => {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
return hunk.lines.map((line, lineIndex) => {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo++;
newLineNo++;
}
return (
<DiffLineView
key={`${hunkIndex}-${lineIndex}`}
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
/>
);
});
});
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const contentContainer = (
<View
style={[
layout === "split" ? styles.splitLinesContainer : styles.linesContainer,
availableWidth > 0 &&
(layout === "split"
? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth }
: { minWidth: availableWidth }),
]}
>
{linesContent}
</View>
);
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>{linesContent}</View>
</View>
);
return <View style={styles.diffContent}>{contentContainer}</View>;
}
return (
@@ -328,11 +445,7 @@ function DiffFileBody({
style={styles.diffContent}
contentContainerStyle={styles.diffContentInner}
>
<View
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
>
{linesContent}
</View>
{contentContainer}
</DiffScroll>
);
})()}
@@ -355,26 +468,31 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const [wrapLines, setWrapLines] = useState(false);
useEffect(() => {
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
if (value === "true") setWrapLines(true);
});
}, []);
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
setWrapLines((prev) => {
const next = !prev;
AsyncStorage.setItem("diff-wrap-lines", String(next));
return next;
});
}, []);
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
const handleLayoutChange = useCallback(
(nextLayout: "unified" | "split") => {
void updateChangesPreferences({ layout: nextLayout });
},
[updateChangesPreferences],
);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const {
status,
@@ -410,6 +528,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: isGit,
});
const {
@@ -424,7 +543,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
});
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId],
);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setDiffExpandedPathsForWorkspace = usePanelStore(
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray ?? []),
[expandedPathsArray],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
@@ -484,7 +621,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isExpanded = expandedByPath[file.path] ?? false;
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
@@ -494,7 +631,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [files, expandedByPath]);
}, [expandedPaths, files]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
@@ -540,18 +677,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedByPath[file.path]) {
if (expandedPaths.has(file.path)) {
offset += bodyHeightByPathRef.current[file.path] ?? 0;
}
}
return Math.max(0, offset);
},
[expandedByPath, files],
[expandedPaths, files],
);
const handleToggleExpanded = useCallback(
(path: string) => {
const isCurrentlyExpanded = expandedByPath[path] ?? false;
if (!workspaceStateKey) {
return;
}
const isCurrentlyExpanded = expandedPaths.has(path);
const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
@@ -573,32 +713,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
});
}
setExpandedByPath((prev) => ({
...prev,
// Use a deterministic target value (instead of toggling from prev) so duplicate
// onPress events from sticky headers on Android can't flip back immediately.
[path]: nextExpanded,
}));
const nextPaths = nextExpanded
? [...expandedPaths, path]
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
},
[computeHeaderOffset, expandedByPath],
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedByPath[file.path]);
}, [files, expandedByPath]);
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
const handleToggleExpandAll = useCallback(() => {
if (allExpanded) {
setExpandedByPath({});
} else {
const newExpanded: Record<string, boolean> = {};
for (const file of files) {
newExpanded[file.path] = true;
}
setExpandedByPath(newExpanded);
if (!workspaceStateKey) {
return;
}
}, [allExpanded, files]);
if (allExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
workspaceStateKey,
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Reset manual refresh flag when fetch completes
useEffect(() => {
@@ -726,13 +866,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return (
<DiffFileBody
file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines],
[
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
@@ -821,7 +968,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
bodyContent = (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`}
{changesPreferences.hideWhitespace
? "No visible changes after hiding whitespace"
: diffMode === "uncommitted"
? "No uncommitted changes"
: `No changes vs ${baseRefLabel}`}
</Text>
</View>
);
@@ -833,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor}
stickyHeaderIndices={stickyHeaderIndices}
extraData={expandedByPath}
extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
@@ -1019,56 +1170,149 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{files.length > 0 ? (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Unified diff"
testID="changes-layout-unified"
onPress={() => handleLayoutChange("unified")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupStart,
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
>
<AlignJustify
size={14}
color={
changesPreferences.layout === "unified"
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<ListChevronsUpDown
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Unified diff</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Side-by-side diff"
testID="changes-layout-split"
onPress={() => handleLayoutChange("split")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupEnd,
changesPreferences.layout === "split" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
>
<Columns2
size={14}
color={
changesPreferences.layout === "split"
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Side-by-side diff</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Hide whitespace"
testID="changes-toggle-whitespace"
style={({ hovered, pressed }) => [
styles.expandAllButton,
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleHideWhitespace}
>
<Pilcrow
size={isMobile ? 18 : 14}
color={
changesPreferences.hideWhitespace
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Hide whitespace</Text>
</TooltipContent>
</Tooltip>
{files.length > 0 ? (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
wrapLines && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<WrapText
size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
) : (
<ListChevronsUpDown
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
</View>
</View>
</View>
) : null}
@@ -1160,11 +1404,42 @@ const styles = StyleSheet.create((theme) => ({
diffStatusButtons: {
flexDirection: "row",
alignItems: "center",
gap: {
xs: theme.spacing[1],
sm: theme.spacing[1],
md: 0,
gap: theme.spacing[1],
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
},
toggleButtonGroupStart: {
borderTopLeftRadius: theme.borderRadius.base,
borderBottomLeftRadius: theme.borderRadius.base,
},
toggleButtonGroupEnd: {
borderTopRightRadius: theme.borderRadius.base,
borderBottomRightRadius: theme.borderRadius.base,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
expandAllButton: {
flexDirection: "row",
@@ -1343,6 +1618,36 @@ const styles = StyleSheet.create((theme) => ({
linesContainer: {
backgroundColor: theme.colors.surface1,
},
splitLinesContainer: {
backgroundColor: theme.colors.surface1,
minWidth: 760,
},
splitRow: {
flexDirection: "row",
alignItems: "stretch",
width: "100%",
minWidth: "100%",
},
splitHeaderRow: {
backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[3],
},
splitCell: {
flex: 1,
flexBasis: 0,
minWidth: 0,
flexDirection: "row",
alignItems: "stretch",
overflow: "hidden",
backgroundColor: theme.colors.surface2,
},
emptySplitCell: {
backgroundColor: theme.colors.surfaceDiffEmpty,
},
splitCellWithDivider: {
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.border,
},
diffLineContainer: {
flexDirection: "row",
alignItems: "stretch",
@@ -1352,13 +1657,13 @@ const styles = StyleSheet.create((theme) => ({
borderRightColor: theme.colors.border,
marginRight: theme.spacing[2],
alignSelf: "stretch",
justifyContent: "center",
justifyContent: "flex-start",
},
lineNumberText: {
textAlign: "right",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
@@ -1372,8 +1677,8 @@ const styles = StyleSheet.create((theme) => ({
diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
userSelect: "text",
@@ -1402,6 +1707,9 @@ const styles = StyleSheet.create((theme) => ({
contextLineText: {
color: theme.colors.foregroundMuted,
},
emptySplitCellText: {
color: "transparent",
},
statusMessageContainer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
@@ -0,0 +1,32 @@
import { Image, type ImageSourcePropType } from "react-native";
import type { EditorTargetId } from "@server/shared/messages";
interface EditorAppIconProps {
editorId: EditorTargetId;
size?: number;
color?: string;
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function EditorAppIcon({
editorId,
size = 16,
}: EditorAppIconProps) {
return (
<Image
source={EDITOR_APP_IMAGES[editorId]}
style={{ width: size, height: size }}
resizeMode="contain"
/>
);
}
@@ -80,6 +80,8 @@ export interface MessageInputProps {
isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
rightContent?: React.ReactNode;
voiceServerId?: string;
@@ -207,6 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
disabled = false,
isInputActive = true,
leftContent,
beforeVoiceContent,
rightContent,
voiceServerId,
voiceAgentId,
@@ -1024,6 +1027,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
+10 -2
View File
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
</View>
);
},
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},
+8 -3
View File
@@ -203,12 +203,12 @@ export function ComboboxItem({
]}
>
{leadingContent}
<View style={styles.comboboxItemContent}>
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
{description}
</Text>
) : null}
@@ -838,6 +838,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
flexShrink: 1,
},
comboboxItemContentInline: {
flexDirection: "row",
alignItems: "baseline",
gap: theme.spacing[2],
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: "center",
@@ -848,9 +853,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
comboboxItemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
emptyText: {
paddingHorizontal: theme.spacing[3],
+28 -10
View File
@@ -44,6 +44,7 @@ type TooltipContextValue = {
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
};
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
});
}
function isMobileTooltipEnvironment(): boolean {
if (Platform.OS !== "web") {
return true;
}
if (typeof navigator === "undefined") {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
}
function computePosition({
triggerRect,
contentSize,
@@ -214,12 +227,8 @@ export function Tooltip({
onOpenChange,
});
const isWeb = Platform.OS === "web";
const isMobileWeb =
isWeb &&
typeof navigator !== "undefined" &&
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
const isMobile = isMobileTooltipEnvironment();
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
@@ -227,9 +236,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isMobile,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration],
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -322,9 +332,17 @@ export function TooltipTrigger({
const handlePress = useCallback(
(e: any) => {
onPress?.(e);
if (!ctx.enabled || disabled) {
return;
}
if (ctx.openOnPress) {
clearOpenTimer();
ctx.setOpen(true);
return;
}
close();
},
[close, onPress],
[clearOpenTimer, close, ctx, disabled, onPress],
);
const triggerProps = {
@@ -491,7 +509,7 @@ export function TooltipContent({
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
@@ -512,7 +530,7 @@ export function TooltipContent({
>
{children}
</Animated.View>
</View>
</Pressable>
</Modal>
);
}
+63 -1
View File
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
processTimelineResponse,
@@ -32,6 +33,7 @@ import {
type Agent,
type SessionState,
type WorkspaceDescriptor,
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
@@ -69,6 +71,22 @@ export type {
const HISTORY_STALE_AFTER_MS = 60_000;
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
function hasAgentUsageChanged(
incomingUsage: Agent["lastUsage"] | undefined,
currentUsage: Agent["lastUsage"] | undefined,
): boolean {
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
"inputTokens",
"outputTokens",
"cachedInputTokens",
"totalCostUsd",
"contextWindowMaxTokens",
"contextWindowUsedTokens",
];
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
interface BufferedAudioChunk {
@@ -318,6 +336,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
const workspaces = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
let includeSubscribe = options?.subscribe ?? false;
@@ -333,7 +352,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set(workspace.id, workspace);
workspaces.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
@@ -358,6 +383,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
if (hasUsageUpdate) {
const next = new Map(prev);
next.set(agent.id, {
...current,
lastUsage: agent.lastUsage,
});
return next;
}
return prev;
}
const next = new Map(prev);
@@ -611,6 +645,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
updateSessionClient(serverId, client);
}, [serverId, client, updateSessionClient]);
useEffect(() => {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
return;
}
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
}, [client, serverId, updateSessionServerInfo]);
useEffect(() => {
if (!isConnected) {
return;
}
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo?.features?.providersSnapshot) {
return;
}
prefetchProvidersSnapshot(serverId, client);
}, [client, isConnected, serverId]);
useEffect(() => {
if (!voiceRuntime) {
return;
+21 -264
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
@@ -11,8 +10,7 @@ import type {
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
mergeProviderPreferences,
@@ -320,10 +318,6 @@ function combineInitialValues(
return initialValues;
}
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
return ["providersSnapshot", serverId, cwd ?? ""] as const;
}
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
const {
initialServerId = null,
@@ -341,7 +335,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useFormPreferences();
const daemons = useHosts();
const queryClient = useQueryClient();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
@@ -374,95 +367,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}
}, [isVisible]);
// Session state for provider model listing
const client = useHostRuntimeClient(formState.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
const supportsProvidersSnapshot = useSessionForServer(
formState.serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
);
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
error: snapshotError,
refresh: refreshSnapshot,
} = useProvidersSnapshot(formState.serverId);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const snapshotQueryKey = useMemo(
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
[debouncedCwd, formState.serverId],
);
const providersSnapshotQuery = useQuery({
queryKey: snapshotQueryKey,
enabled: Boolean(
supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot({ cwd: debouncedCwd });
},
});
useEffect(() => {
if (
!supportsProvidersSnapshot ||
!client ||
!isConnected ||
!isVisible ||
!isTargetDaemonReady ||
!formState.serverId
) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
return;
}
queryClient.setQueryData(snapshotQueryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [
client,
debouncedCwd,
formState.serverId,
isConnected,
isTargetDaemonReady,
isVisible,
queryClient,
snapshotQueryKey,
supportsProvidersSnapshot,
]);
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
const allProviderEntries = useMemo(
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
[snapshotEntries, supportsProvidersSnapshot],
);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => {
if (!supportsProvidersSnapshot) {
return [];
}
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
}, [snapshotEntries, supportsProvidersSnapshot]);
}, [snapshotEntries]);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
@@ -498,157 +415,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
[];
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listAvailableProviders();
if (payload.error) {
throw new Error(payload.error);
}
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
},
});
const legacyProviderDefinitions = useMemo(() => {
const availableProviders = availableProvidersQuery.data;
if (!availableProviders) {
return [];
}
const available = new Set(availableProviders);
return allProviderDefinitions.filter((definition) => available.has(definition.id));
}, [availableProvidersQuery.data]);
const legacyProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
),
[legacyProviderDefinitions],
);
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const legacySelectedProviderModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
legacyProviderDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null;
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const providerModesQuery = useQuery({
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
legacyProviderDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModes(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.modes ?? [];
},
});
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const allProviderModelQueries = useQueries({
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(def.id as AgentProvider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
})),
});
const legacyAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < legacyProviderDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(legacyProviderDefinitions[i]!.id, query.data);
}
}
return map;
}, [allProviderModelQueries, legacyProviderDefinitions]);
const legacySelectedProviderModes =
providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? [];
const providerDefinitions = supportsProvidersSnapshot
? snapshotProviderDefinitions
: legacyProviderDefinitions;
const providerDefinitionMap = supportsProvidersSnapshot
? snapshotProviderDefinitionMap
: legacyProviderDefinitionMap;
const selectableProviderDefinitionMap = supportsProvidersSnapshot
? snapshotSelectableProviderDefinitionMap
: legacyProviderDefinitionMap;
const allProviderModels = supportsProvidersSnapshot
? snapshotAllProviderModels
: legacyAllProviderModels;
const availableModels = supportsProvidersSnapshot
? snapshotSelectedProviderModels
: legacySelectedProviderModels;
const modeOptions = supportsProvidersSnapshot
? snapshotSelectedProviderModes
: legacySelectedProviderModes;
const isAllModelsLoading = supportsProvidersSnapshot
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
: allProviderModelQueries.some((q) => q.isLoading);
const providerDefinitions = snapshotProviderDefinitions;
const providerDefinitionMap = snapshotProviderDefinitionMap;
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
const allProviderModels = snapshotAllProviderModels;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -870,15 +643,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, []);
const refreshProviderModels = useCallback(() => {
if (supportsProvidersSnapshot) {
if (!client) {
return;
}
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
return;
}
void legacySelectedProviderModelsQuery.refetch();
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
refreshSnapshot();
}, [refreshSnapshot]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
@@ -914,17 +680,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = supportsProvidersSnapshot
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
const modelError =
supportsProvidersSnapshot
? providersSnapshotQuery.error instanceof Error
? providersSnapshotQuery.error.message
: null
: legacySelectedProviderModelsQuery.error instanceof Error
? legacySelectedProviderModelsQuery.error.message
: null;
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim();
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("use-changes-preferences", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("defaults to unified layout with visible whitespace", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:changes-preferences",
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
);
});
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "diff-wrap-lines") {
return "true";
}
return null;
});
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "unified",
wrapLines: true,
hideWhitespace: false,
});
});
it("loads persisted layout and whitespace preferences", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:changes-preferences") {
return JSON.stringify({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
}
return null;
});
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,89 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
wrapLines: false,
hideWhitespace: false,
};
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
if (legacyValue === "true") {
return true;
}
if (legacyValue === "false") {
return false;
}
return null;
}
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
if (stored) {
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
if (parsed.success) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
}
}
const legacyWrapLines = await loadLegacyWrapLinesPreference();
const next = {
...DEFAULT_CHANGES_PREFERENCES,
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
} satisfies ChangesPreferences;
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
return next;
}
export interface UseChangesPreferencesReturn {
preferences: ChangesPreferences;
isLoading: boolean;
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
}
export function useChangesPreferences(): UseChangesPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
queryFn: loadChangesPreferencesFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferences = useCallback(
async (updates: Partial<ChangesPreferences>) => {
const prev =
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
DEFAULT_CHANGES_PREFERENCES;
const next = { ...prev, ...updates };
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
return {
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
isLoading: isPending,
updatePreferences,
};
}
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
}
interface UseCheckoutDiffQueryOptions {
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
}
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } {
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
export function useCheckoutDiffQuery({
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
cwd,
mode,
baseRef,
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffQueryOptions) {
const queryClient = useQueryClient();
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
[mode, baseRef],
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
[serverId, cwd, mode, baseRef],
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
const payload = await client.getCheckoutDiff(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
...payload,
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolvePreferredEditorId } from "./use-preferred-editor";
describe("resolvePreferredEditorId", () => {
it("keeps the stored editor when it is still available", () => {
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
});
it("falls back to the first available editor when the stored one is missing", () => {
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
});
it("falls back when a platform-specific file manager target is unavailable", () => {
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
});
it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
}
export function resolvePreferredEditorId(
availableEditorIds: readonly EditorTargetId[],
storedEditorId: EditorTargetId | null | undefined,
): EditorTargetId | null {
if (
storedEditorId &&
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
) {
return storedEditorId;
}
return availableEditorIds[0] ?? null;
}
export function usePreferredEditor() {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: PREFERRED_EDITOR_QUERY_KEY,
queryFn: loadPreferredEditor,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferredEditor = useCallback(
async (editorId: EditorTargetId | null) => {
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
if (editorId) {
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
return;
}
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
},
[queryClient],
);
return {
preferredEditorId: data ?? null,
isLoading: isPending,
updatePreferredEditor,
};
}
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import { queryClient as singletonQueryClient } from "@/query/query-client";
export function providersSnapshotQueryKey(serverId: string | null) {
return ["providersSnapshot", serverId] as const;
}
interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
isFetching: boolean;
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
}
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const supportsSnapshot = useSessionForServer(
serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
);
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
const snapshotQuery = useQuery({
queryKey,
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
staleTime: 60_000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot();
},
});
useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
queryClient.setQueryData(queryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
const refresh = useCallback(() => {
if (!client) {
return;
}
void client.refreshProvidersSnapshot();
}, [client]);
return {
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
isFetching: snapshotQuery.isFetching,
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,
};
}
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
const queryKey = providersSnapshotQueryKey(serverId);
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot(),
});
}
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -372,6 +376,7 @@ export function useSidebarWorkspacesList(options?: {
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
try {
while (true) {
@@ -381,7 +386,13 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
next.set(workspace.id, workspace);
next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
+26 -32
View File
@@ -62,12 +62,12 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { isCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { StatusBadge } from "@/components/ui/status-badge";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
// ---------------------------------------------------------------------------
// Section definitions
@@ -431,55 +431,49 @@ interface ProvidersSectionProps {
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(routeServerId);
const isConnected = useHostRuntimeIsConnected(routeServerId);
const [entries, setEntries] = useState<ProviderSnapshotEntry[]>([]);
const [loading, setLoading] = useState(false);
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
useEffect(() => {
if (!client || !isConnected) {
setEntries([]);
return;
}
let cancelled = false;
setLoading(true);
client
.getProvidersSnapshot()
.then((result) => {
if (!cancelled) setEntries(result.entries);
})
.catch(() => {
if (!cancelled) setEntries([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [client, isConnected]);
const hasServer = routeServerId.length > 0;
return (
<>
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Providers</Text>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
{hasServer && isConnected ? (
<Pressable
onPress={refresh}
disabled={isFetching}
style={[
settingsStyles.sectionHeaderLink,
isFetching ? { opacity: 0.5 } : null,
]}
>
<Text
style={{
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
}}
>
Refresh
</Text>
</Pressable>
) : null}
</View>
{!hasServer || !isConnected ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
</View>
) : loading ? (
) : isLoading ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : (
<View style={[settingsStyles.card, styles.audioCard]}>
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
const entry = entries.find((e) => e.provider === def.id);
const entry = entries?.find((e) => e.provider === def.id);
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
} from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
resolvePreferredEditorId,
usePreferredEditor,
} from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
cwd: string;
}
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
}: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors =
Platform.OS === "web" &&
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadEditors,
staleTime: 60_000,
retry: false,
queryFn: async () => {
if (!client) {
return [];
}
try {
const payload = await client.listAvailableEditors();
return payload.error ? [] : payload.editors;
} catch {
return [];
}
},
});
const availableEditors = availableEditorsQuery.data ?? [];
const availableEditorIds = useMemo(
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
[availableEditors],
);
const effectivePreferredEditorId = useMemo(
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
[availableEditorIds, preferredEditorId],
);
const primaryOption =
availableEditors.find(
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
) ?? null;
useEffect(() => {
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
return;
}
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
const openMutation = useMutation({
mutationFn: async (editorId: EditorTargetId) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.openInEditor(cwd, editorId);
if (payload.error) {
throw new Error(payload.error);
}
return editorId;
},
onError: (error: unknown) => {
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
},
});
const handleOpenEditor = useCallback(
(editorId: EditorTargetId) => {
void updatePreferredEditor(editorId).catch(() => undefined);
openMutation.mutate(editorId);
},
[openMutation, updatePreferredEditor],
);
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
return null;
}
return (
<View style={styles.row}>
<View style={styles.splitButton}>
<Pressable
testID="workspace-open-in-editor-primary"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
openMutation.isPending && styles.splitButtonPrimaryDisabled,
]}
onPress={() => handleOpenEditor(primaryOption.id)}
disabled={openMutation.isPending}
accessibilityRole="button"
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
>
{openMutation.isPending ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
<EditorAppIcon
editorId={primaryOption.id}
size={16}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.splitButtonText}>Open</Text>
</View>
)}
</Pressable>
{availableEditors.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-open-in-editor-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
accessibilityRole="button"
accessibilityLabel="Choose editor"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
minWidth={148}
maxWidth={176}
testID="workspace-open-in-editor-menu"
>
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
<DropdownMenuItem
key={editor.id}
testID={`workspace-open-in-editor-item-${editor.id}`}
leading={
<EditorAppIcon
editorId={editor.id}
size={16}
color={theme.colors.foregroundMuted}
/>
}
trailing={
editor.id === effectivePreferredEditorId
? <Check size={16} color={theme.colors.foregroundMuted} />
: undefined
}
onSelect={() => handleOpenEditor(editor.id)}
>
{editor.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingLeft: theme.spacing[3],
paddingRight: 10,
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 28,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -49,6 +49,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -2098,6 +2099,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}
right={
<View style={styles.headerRight}>
{!isMobile ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
) : null}
{!isMobile && isGitCheckout ? (
<>
{workspaceDirectory ? (
@@ -39,10 +39,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Reload agent",
"Close to the left",
"Close to the right",
"Close other tabs",
"Reload agent",
"Close",
]);
});
@@ -66,10 +66,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Reload agent",
"Close tabs above",
"Close tabs below",
"Close other tabs",
"Reload agent",
"Close",
]);
});
@@ -138,17 +138,6 @@ export function buildWorkspaceTabMenuEntries(
void onCopyAgentId(agentId);
},
});
entries.push({
kind: "item",
key: "reload-agent",
label: "Reload agent",
icon: "rotate-cw",
tooltip: "Reload agent to update skills, MCPs or login status.",
testID: `${menuTestIDBase}-reload-agent`,
onSelect: () => {
void onReloadAgent(agentId);
},
});
entries.push({
kind: "separator",
key: "copy-separator",
@@ -188,6 +177,20 @@ export function buildWorkspaceTabMenuEntries(
void onCloseOtherTabs(tab.tabId);
},
});
if (tab.target.kind === "agent") {
const { agentId } = tab.target;
entries.push({
kind: "item",
key: "reload-agent",
label: "Reload agent",
icon: "rotate-cw",
tooltip: "Reload agent to update skills, MCPs or login status.",
testID: `${menuTestIDBase}-reload-agent`,
onSelect: () => {
void onReloadAgent(agentId);
},
});
}
entries.push({
kind: "item",
key: "close",
+36 -1
View File
@@ -66,6 +66,8 @@ interface PanelState {
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
activeExplorerCheckout: ExplorerCheckoutContext | null;
sidebarWidth: number;
explorerWidth: number;
@@ -85,6 +87,8 @@ interface PanelState {
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setSidebarWidth: (width: number) => void;
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
activeExplorerCheckout: null,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
}
return nextState;
}),
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
})),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {
...state.diffExpandedPathsByWorkspace,
[workspaceKey]: paths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
activeExplorerCheckout: checkout,
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 8,
version: 10,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
if (
version < 9 ||
typeof state.expandedPathsByWorkspace !== "object" ||
!state.expandedPathsByWorkspace
) {
state.expandedPathsByWorkspace = {};
}
if (
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
state.activeExplorerCheckout = null;
return state as PanelState;
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
+38 -4
View File
@@ -1,13 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
function workspace(
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
@@ -17,7 +20,7 @@ function workspace(
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "checkout",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
@@ -99,11 +102,11 @@ describe("mergeWorkspaces", () => {
store.initializeSession("test-server", null as unknown as DaemonClient);
store.setWorkspaces(
"test-server",
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]),
);
store.mergeWorkspaces("test-server", [
workspace({
createWorkspace({
id: "/repo/main",
services: [
{
@@ -130,3 +133,34 @@ describe("mergeWorkspaces", () => {
]);
});
});
describe("mergeWorkspaceSnapshotWithExisting", () => {
it("preserves the last known diff stat when a snapshot only has baseline null data", () => {
const existing = createWorkspace({
id: "/tmp/repo",
diffStat: { additions: 4, deletions: 2 },
});
const incoming = createWorkspace({
id: "/tmp/repo",
diffStat: null,
});
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({
...incoming,
diffStat: { additions: 4, deletions: 2 },
});
});
it("uses the incoming diff stat when the server provides a known value", () => {
const existing = createWorkspace({
id: "/tmp/repo",
diffStat: { additions: 4, deletions: 2 },
});
const incoming = createWorkspace({
id: "/tmp/repo",
diffStat: { additions: 0, deletions: 0 },
});
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming);
});
});
+15
View File
@@ -147,6 +147,21 @@ export function normalizeWorkspaceDescriptor(
};
}
export function mergeWorkspaceSnapshotWithExisting(input: {
incoming: WorkspaceDescriptor;
existing?: WorkspaceDescriptor | null;
}): WorkspaceDescriptor {
const { incoming, existing } = input;
if (!existing || existing.id !== incoming.id) {
return incoming;
}
return {
...incoming,
diffStat: incoming.diffStat ?? existing.diffStat,
};
}
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
+6
View File
@@ -111,6 +111,7 @@ const lightSemanticColors = {
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceWorkspace: "#ffffff", // Workspace main background
@@ -185,6 +186,7 @@ const darkSemanticColors = {
surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
@@ -279,6 +281,10 @@ const commonTheme = {
"4xl": 34,
},
lineHeight: {
diff: 22,
},
iconSize: {
xs: 12,
sm: 14,
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildSplitDiffRows } from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
return {
path: "example.ts",
isNew: false,
isDeleted: false,
additions: lines.filter((line) => line.type === "add").length,
deletions: lines.filter((line) => line.type === "remove").length,
status: "ok",
hunks: [
{
oldStart: 10,
oldCount: 4,
newStart: 10,
newCount: 5,
lines,
},
],
};
}
describe("buildSplitDiffRows", () => {
it("pairs replacement runs by index", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,2 +10,2 @@" },
{ type: "remove", content: "before one" },
{ type: "remove", content: "before two" },
{ type: "add", content: "after one" },
{ type: "add", content: "after two" },
]),
);
expect(rows).toHaveLength(3);
expect(rows[1]).toMatchObject({
kind: "pair",
left: { type: "remove", content: "before one", lineNumber: 10 },
right: { type: "add", content: "after one", lineNumber: 10 },
});
expect(rows[2]).toMatchObject({
kind: "pair",
left: { type: "remove", content: "before two", lineNumber: 11 },
right: { type: "add", content: "after two", lineNumber: 11 },
});
});
it("keeps unmatched additions on the right side only", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,1 +10,2 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after one" },
{ type: "add", content: "after two" },
]),
);
expect(rows[2]).toMatchObject({
kind: "pair",
left: null,
right: { type: "add", content: "after two", lineNumber: 11 },
});
});
it("duplicates context rows on both sides", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,1 +10,1 @@" },
{ type: "context", content: "same line" },
]),
);
expect(rows[1]).toMatchObject({
kind: "pair",
left: { type: "context", content: "same line", lineNumber: 10 },
right: { type: "context", content: "same line", lineNumber: 10 },
});
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
export interface SplitDiffDisplayLine {
type: DiffLine["type"];
content: string;
tokens?: DiffLine["tokens"];
lineNumber: number | null;
}
export type SplitDiffRow =
| {
kind: "header";
content: string;
}
| {
kind: "pair";
left: SplitDiffDisplayLine | null;
right: SplitDiffDisplayLine | null;
};
function toDisplayLine(input: {
line: DiffLine;
oldLineNumber: number | null;
newLineNumber: number | null;
side: "left" | "right";
}): SplitDiffDisplayLine | null {
const { line, oldLineNumber, newLineNumber, side } = input;
if (line.type === "header") {
return null;
}
if (line.type === "remove") {
if (side !== "left") {
return null;
}
return {
type: "remove",
content: line.content,
tokens: line.tokens,
lineNumber: oldLineNumber,
};
}
if (line.type === "add") {
if (side !== "right") {
return null;
}
return {
type: "add",
content: line.content,
tokens: line.tokens,
lineNumber: newLineNumber,
};
}
return {
type: "context",
content: line.content,
tokens: line.tokens,
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
};
}
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];
for (const hunk of file.hunks) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
rows.push({
kind: "header",
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
});
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
const flushPendingRows = () => {
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
for (let index = 0; index < pairCount; index += 1) {
const removal = pendingRemovals[index] ?? null;
const addition = pendingAdditions[index] ?? null;
rows.push({
kind: "pair",
left: removal
? toDisplayLine({
line: removal.line,
oldLineNumber: removal.oldLineNumber,
newLineNumber: null,
side: "left",
})
: null,
right: addition
? toDisplayLine({
line: addition.line,
oldLineNumber: null,
newLineNumber: addition.newLineNumber,
side: "right",
})
: null,
});
}
pendingRemovals = [];
pendingAdditions = [];
};
for (const line of hunk.lines.slice(1)) {
if (line.type === "remove") {
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
oldLineNo += 1;
continue;
}
if (line.type === "add") {
pendingAdditions.push({ line, newLineNumber: newLineNo });
newLineNo += 1;
continue;
}
flushPendingRows();
if (line.type === "context") {
rows.push({
kind: "pair",
left: toDisplayLine({
line,
oldLineNumber: oldLineNo,
newLineNumber: newLineNo,
side: "left",
}),
right: toDisplayLine({
line,
oldLineNumber: oldLineNo,
newLineNumber: newLineNo,
side: "right",
}),
});
oldLineNo += 1;
newLineNo += 1;
}
}
flushPendingRows();
}
return rows;
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.48",
"version": "0.1.49",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.48",
"@getpaseo/server": "0.1.48",
"@getpaseo/relay": "0.1.49",
"@getpaseo/server": "0.1.49",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.48",
"version": "0.1.49",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.48",
"@getpaseo/server": "0.1.48",
"@getpaseo/cli": "0.1.49",
"@getpaseo/server": "0.1.49",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.48",
"version": "0.1.49",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.48",
"version": "0.1.49",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.48",
"version": "0.1.49",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.48",
"version": "0.1.49",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -61,8 +61,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.48",
"@getpaseo/relay": "0.1.48",
"@getpaseo/highlight": "0.1.49",
"@getpaseo/relay": "0.1.49",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
+47 -8
View File
@@ -34,6 +34,8 @@ import type {
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListAvailableEditorsResponseMessage,
OpenInEditorResponseMessage,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
@@ -55,6 +57,7 @@ import type {
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
EditorTargetId,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -478,9 +481,12 @@ export type InspectScheduleOptions = {
id: string;
requestId?: string;
};
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
export type FetchAgentResult = {
agent: AgentSnapshotPayload;
@@ -618,7 +624,10 @@ export class DaemonClient {
private connectionState: ConnectionState = { status: "idle" };
private checkoutDiffSubscriptions = new Map<
string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private terminalDirectorySubscriptions = new Set<string>();
private terminalSlots = new Map<string, number>();
@@ -1339,8 +1348,36 @@ export class DaemonClient {
});
}
async listAvailableEditors(requestId?: string): Promise<ListAvailableEditorsPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "list_available_editors_request",
},
responseType: "list_available_editors_response",
timeout: 10000,
});
}
async openInEditor(
path: string,
editorId: EditorTargetId,
requestId?: string,
): Promise<OpenInEditorPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "open_in_editor_request",
path,
editorId,
},
responseType: "open_in_editor_response",
timeout: 10000,
});
}
async archiveWorkspace(
workspaceId: number,
workspaceId: string | number,
requestId?: string,
): Promise<ArchiveWorkspacePayload> {
return this.sendCorrelatedSessionRequest({
@@ -2185,20 +2222,22 @@ export class DaemonClient {
private normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } {
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
if (!trimmedBaseRef) {
return { mode: "base" };
return { mode: "base", ignoreWhitespace };
}
return { mode: "base", baseRef: trimmedBaseRef };
return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
}
async getCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string },
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
requestId?: string,
): Promise<CheckoutDiffPayload> {
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
@@ -2224,7 +2263,7 @@ export class DaemonClient {
async subscribeCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string },
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
options?: { subscriptionId?: string; requestId?: string },
): Promise<SubscribeCheckoutDiffPayload> {
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
@@ -174,6 +174,9 @@ function sanitizePermissionRequest(
if (sanitized.suggestions === undefined) {
delete sanitized.suggestions;
}
if (sanitized.actions === undefined) {
delete sanitized.actions;
}
if (sanitized.metadata === undefined) {
delete sanitized.metadata;
}
@@ -3459,6 +3459,213 @@ describe("AgentManager", () => {
expect(persisted?.lastModeId).toBe("acceptEdits");
});
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class RefreshingPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: true }),
];
private modeId = "auto";
private pending = [
{
id: "perm-plan-1",
provider: "codex" as const,
name: "CodexPlanApproval",
kind: "plan" as const,
input: { plan: "- Implement the feature" },
},
];
get features(): AgentFeature[] {
return this.featureState;
}
override async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: "gpt-5.4",
modeId: this.modeId,
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
};
}
override async getCurrentMode() {
return this.modeId;
}
override getPendingPermissions() {
return this.pending;
}
override async respondToPermission(): Promise<void> {
this.modeId = "auto";
this.pending = [];
this.featureState = [
createFeature({ id: "fast_mode", label: "Fast", value: false }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
];
}
}
class RefreshingPermissionClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new RefreshingPermissionSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new RefreshingPermissionClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000133",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const agent = manager.getAgent(snapshot.id);
if (!agent) {
throw new Error("Expected managed agent");
}
agent.pendingPermissions.set("perm-plan-1", {
id: "perm-plan-1",
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: { plan: "- Implement the feature" },
});
await manager.respondToPermission(snapshot.id, "perm-plan-1", {
behavior: "allow",
selectedActionId: "implement",
});
const updated = manager.getAgent(snapshot.id);
expect(updated?.pendingPermissions.size).toBe(0);
expect(updated?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: false }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
expect(updated?.runtimeInfo).toMatchObject({
model: "gpt-5.4",
extra: { collaborationMode: "Code" },
});
const persisted = await storage.get(snapshot.id);
expect(persisted?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: false }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
});
test("respondToPermission emits refreshed state before permission_resolved", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class OrderedPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
];
private modeId = "plan";
private pending = [
{
id: "perm-order-1",
provider: "codex" as const,
name: "ExitPlanMode",
kind: "plan" as const,
input: { plan: "- Do the work" },
},
];
get features(): AgentFeature[] {
return this.featureState;
}
override async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: "gpt-5.4",
modeId: this.modeId,
};
}
override async getCurrentMode() {
return this.modeId;
}
override getPendingPermissions() {
return this.pending;
}
override async respondToPermission(): Promise<void> {
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
requestId: "perm-order-1",
resolution: { behavior: "allow" },
});
this.modeId = "acceptEdits";
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
this.pending = [];
}
}
class OrderedPermissionClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new OrderedPermissionSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new OrderedPermissionClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000134",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const seen: string[] = [];
manager.subscribe((event) => {
if ("agentId" in event && event.agentId !== snapshot.id) {
return;
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
return;
}
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
seen.push(`resolved:${event.event.requestId}`);
}
});
await manager.respondToPermission(snapshot.id, "perm-order-1", {
behavior: "allow",
});
const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false");
const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1");
expect(refreshedStateIndex).toBeGreaterThanOrEqual(0);
expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex);
});
test("close during in-flight stream does not clear persistence sessionId", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -150,6 +150,11 @@ type ManagedAgentBase = {
features?: AgentFeature[];
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map<
string,
Extract<AgentStreamEvent, { type: "permission_resolved" }>
>;
inFlightPermissionResponses: Set<string>;
pendingReplacement: boolean;
persistence: AgentPersistenceHandle | null;
historyPrimed: boolean;
@@ -1378,25 +1383,32 @@ export class AgentManager {
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
const agent = this.requireSessionAgent(agentId);
await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
const agent = this.requireAgent(agentId);
agent.inFlightPermissionResponses.add(requestId);
// Update currentModeId - the session may have changed mode internally
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
try {
agent.currentModeId = await agent.session.getCurrentMode();
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
modeId: agent.currentModeId,
};
}
} catch {
// Ignore errors from getCurrentMode - mode tracking is best effort
}
await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
this.emitState(agent);
try {
await this.refreshSessionState(agent);
} catch {
// Ignore refresh errors - state sync after permission approval is best effort.
}
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent);
this.emitState(agent);
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
if (bufferedResolution) {
agent.bufferedPermissionResolutions.delete(requestId);
this.dispatchStream(agent.id, bufferedResolution);
}
} finally {
agent.inFlightPermissionResponses.delete(requestId);
agent.bufferedPermissionResolutions.delete(requestId);
}
}
async cancelAgentRun(agentId: string): Promise<boolean> {
@@ -1840,6 +1852,8 @@ export class AgentManager {
availableModes: [],
currentModeId: null,
pendingPermissions: new Map<string, AgentPermissionRequest>(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
activeForegroundTurnId: null,
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
@@ -2097,6 +2111,7 @@ export class AgentManager {
agent.pendingPermissions.clear();
}
this.syncFeaturesFromSession(agent);
await this.refreshRuntimeInfo(agent);
}
@@ -2174,6 +2189,7 @@ export class AgentManager {
}
let timelineRow: AgentTimelineRow | null = null;
let shouldDispatchEvent = true;
switch (event.type) {
case "thread_started":
@@ -2344,6 +2360,11 @@ export class AgentManager {
break;
case "permission_resolved":
agent.pendingPermissions.delete(event.requestId);
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
agent.bufferedPermissionResolutions.set(event.requestId, event);
shouldDispatchEvent = false;
break;
}
this.emitState(agent);
break;
default:
@@ -2355,7 +2376,7 @@ export class AgentManager {
}
// Skip dispatching individual stream events during history replay.
if (!options?.fromHistory) {
if (!options?.fromHistory && shouldDispatchEvent) {
this.dispatchStream(
agent.id,
event,
@@ -2436,9 +2457,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
if (agent.session?.features) {
agent.features = agent.session.features;
}
this.syncFeaturesFromSession(agent);
this.dispatch({
type: "agent_state",
@@ -2446,6 +2465,12 @@ export class AgentManager {
});
}
private syncFeaturesFromSession(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.features) {
agent.features = agent.session.features;
}
}
private checkAndSetAttention(agent: ManagedAgent): void {
const previousStatus = this.previousStatuses.get(agent.id);
const currentStatus = agent.lifecycle;
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
expect(permissionA.title).toBe("Run command");
});
it("omits usage when any numeric usage field is NaN", () => {
const fields = [
"inputTokens",
"cachedInputTokens",
"outputTokens",
"totalCostUsd",
"contextWindowMaxTokens",
"contextWindowUsedTokens",
] as const;
for (const field of fields) {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 20,
totalCostUsd: 0.5,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 100_000,
[field]: Number.NaN,
},
});
const payload = toAgentPayload(agent);
expect(payload.lastUsage).toBeUndefined();
}
});
it("produces null title and current mode even without overrides", () => {
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
const payload = toAgentPayload(agent);
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
expect(payload).not.toHaveProperty("lastUsage");
});
it("preserves context window usage fields when they are valid numbers", () => {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 42_000,
},
});
const payload = toAgentPayload(agent);
expect(payload.lastUsage).toEqual({
inputTokens: 10,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 42_000,
});
});
it("omits lastUsage when context window usage fields are invalid", () => {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
contextWindowMaxTokens: "200000" as unknown as number,
contextWindowUsedTokens: NaN,
},
});
const payload = toAgentPayload(agent);
expect(payload).not.toHaveProperty("lastUsage");
});
it("keeps existing lastUsage behavior when context window fields are absent", () => {
const agent = createManagedAgent({
lastUsage: {
inputTokens: 10,
outputTokens: 20,
totalCostUsd: 1.25,
},
});
const payload = toAgentPayload(agent);
expect(payload.lastUsage).toEqual({
inputTokens: 10,
outputTokens: 20,
totalCostUsd: 1.25,
});
});
it("includes features in the snapshot payload", () => {
const features = [createFeature()];
const agent = createManagedAgent({ features });
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
runtimeInfo,
features: agent.features,
persistence,
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
@@ -167,6 +168,7 @@ function sanitizePendingPermissions(
...request,
input: sanitizeMetadata(request.input),
suggestions: sanitizeMetadataArray(request.suggestions),
actions: request.actions?.map((action) => ({ ...action })),
metadata: sanitizeMetadata(request.metadata),
}));
}
@@ -260,29 +262,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
}
const result: AgentUsage = {};
const inputTokens = sanitized.inputTokens;
if (typeof inputTokens === "number") {
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
result.inputTokens = inputTokens;
} else if (inputTokens !== undefined && inputTokens !== null) {
return undefined;
}
const cachedInputTokens = sanitized.cachedInputTokens;
if (typeof cachedInputTokens === "number") {
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
result.cachedInputTokens = cachedInputTokens;
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
return undefined;
}
const outputTokens = sanitized.outputTokens;
if (typeof outputTokens === "number") {
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
result.outputTokens = outputTokens;
} else if (outputTokens !== undefined && outputTokens !== null) {
return undefined;
}
const totalCostUsd = sanitized.totalCostUsd;
if (typeof totalCostUsd === "number") {
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
result.totalCostUsd = totalCostUsd;
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
return undefined;
}
const contextWindowMaxTokens = sanitized.contextWindowMaxTokens;
if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) {
result.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) {
return undefined;
}
const contextWindowUsedTokens = sanitized.contextWindowUsedTokens;
if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) {
result.contextWindowUsedTokens = contextWindowUsedTokens;
} else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) {
return undefined;
}
return Object.keys(result).length ? result : undefined;
}
@@ -132,6 +132,8 @@ export type AgentUsage = {
cachedInputTokens?: number;
outputTokens?: number;
totalCostUsd?: number;
contextWindowMaxTokens?: number;
contextWindowUsedTokens?: number;
};
export const TOOL_CALL_ICON_NAMES = [
@@ -329,6 +331,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
export type AgentPermissionUpdate = AgentMetadata;
export type AgentPermissionAction = {
id: string;
label: string;
behavior: "allow" | "deny";
variant?: "primary" | "secondary" | "danger";
intent?: "implement" | "implement_resume" | "dismiss";
};
export type AgentPermissionRequest = {
id: string;
provider: AgentProvider;
@@ -339,17 +349,20 @@ export type AgentPermissionRequest = {
input?: AgentMetadata;
detail?: ToolCallDetail;
suggestions?: AgentPermissionUpdate[];
actions?: AgentPermissionAction[];
metadata?: AgentMetadata;
};
export type AgentPermissionResponse =
| {
behavior: "allow";
selectedActionId?: string;
updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[];
}
| {
behavior: "deny";
selectedActionId?: string;
message?: string;
interrupt?: boolean;
};
@@ -4,7 +4,7 @@ import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
import { AgentStatusSchema } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
@@ -57,6 +57,7 @@ const STORED_AGENT_SCHEMA = z.object({
extra: z.record(z.unknown()).optional(),
})
.optional(),
features: z.array(AgentFeatureSchema).optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(),
requiresAttention: z.boolean().optional(),
@@ -236,6 +236,9 @@ function sanitizePermissionRequest(
if (sanitized.suggestions === undefined) {
delete sanitized.suggestions;
}
if (sanitized.actions === undefined) {
delete sanitized.actions;
}
if (sanitized.metadata === undefined) {
delete sanitized.metadata;
}
@@ -747,4 +747,55 @@ describe("ACPAgentSession", () => {
});
expect((session as any).activeForegroundTurnId).toBeNull();
});
test("auto-approves Copilot ACP permissions in autopilot mode without emitting prompt events", async () => {
const session = new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
const events: Array<{ type: string }> = [];
session.subscribe((event) => {
events.push(event as { type: string });
});
const response = await session.requestPermission({
toolCall: {
toolCallId: "tool-1",
title: "Edit file",
kind: "edit",
status: "pending",
} as any,
options: [
{ optionId: "allow-once", name: "Allow Once", kind: "allow_once" },
{ optionId: "reject-once", name: "Reject Once", kind: "reject_once" },
],
} as any);
expect(response).toEqual({
outcome: {
outcome: "selected",
optionId: "allow-once",
},
});
expect(session.getPendingPermissions()).toEqual([]);
expect(events.find((event) => event.type === "permission_requested")).toBeUndefined();
});
});
@@ -107,6 +107,9 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
const COPILOT_AUTOPILOT_MODE =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
type ACPAgentClientOptions = {
provider: string;
logger: Logger;
@@ -1084,6 +1087,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
return selectedOption
? {
outcome: {
outcome: "selected",
optionId: selectedOption.optionId,
},
}
: { outcome: { outcome: "cancelled" } };
}
const requestId = randomUUID();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1939,6 +1954,10 @@ function mapPermissionRequest(
};
}
function shouldAutoApprovePermissionRequest(provider: string, currentMode: string | null): boolean {
return provider === "copilot" && currentMode === COPILOT_AUTOPILOT_MODE;
}
function selectPermissionOption(
options: PermissionOption[],
response: AgentPermissionResponse,
@@ -835,6 +835,82 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
sdkQueryFactory.mockImplementation(() => queryMock);
const session = await createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.setMode("bypassPermissions");
await session.setMode("plan");
const internal = session as unknown as {
handlePermissionRequest: (
toolName: string,
input: Record<string, unknown>,
options: Record<string, unknown>,
) => Promise<unknown>;
};
const pendingResolution = internal.handlePermissionRequest(
"ExitPlanMode",
{ plan: "- Implement the approved plan" },
{},
);
const requestEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(requestEvent).toBeDefined();
expect(requestEvent?.request.actions).toEqual([
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
{
id: "implement_resume",
label: "Implement with Bypass",
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
},
]);
if (!requestEvent) {
throw new Error("Expected plan permission request");
}
await session.respondToPermission(requestEvent.request.id, {
behavior: "allow",
selectedActionId: "implement_resume",
});
await expect(pendingResolution).resolves.toMatchObject({
behavior: "allow",
updatedInput: { plan: "- Implement the approved plan" },
});
expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions");
expect(await session.getCurrentMode()).toBe("bypassPermissions");
} finally {
await session.close();
}
});
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal = session as unknown as {
@@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => {
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", () => {
const entry = {
type: "user",
isMeta: true,
message: {
role: "user",
content: [
{
type: "text",
text: "Base directory for this skill: /tmp/skill\n\n# Orchestrate\n\nYou are an end-to-end implementation orchestrator.",
},
],
},
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", () => {
const interruptEntry = {
type: "user",
@@ -261,3 +283,356 @@ describe("ClaudeAgentClient.listModels", () => {
expect(defaultModel?.id).toBe("claude-opus-4-6");
});
});
describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
async function createSessionForTest(): Promise<any> {
const client = new ClaudeAgentClient({ logger });
return client.createSession({
provider: "claude",
cwd: process.cwd(),
});
}
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
let turnIndex = 0;
let closed = false;
function wakeNextWaiter() {
const waiter = waiters.shift();
waiter?.();
}
function enqueue(message: Record<string, unknown>) {
queuedMessages.push(message);
wakeNextWaiter();
}
void (async () => {
for await (const _prompt of prompt) {
const turnMessages = turns[turnIndex] ?? [];
turnIndex += 1;
for (const message of turnMessages) {
enqueue(message);
}
}
closed = true;
wakeNextWaiter();
})();
return {
next: vi.fn(async () => {
while (queuedMessages.length === 0 && !closed) {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}
if (queuedMessages.length === 0) {
return { done: true, value: undefined };
}
return { done: false, value: queuedMessages.shift() };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => {
closed = true;
wakeNextWaiter();
return undefined;
}),
close: vi.fn(() => {
closed = true;
wakeNextWaiter();
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
});
}
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage(
{
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
},
{
"claude-sonnet-4-6": { contextWindow: 200_000 },
"claude-opus-4-6": { contextWindow: 1_000_000 },
},
);
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowMaxTokens: 1_000_000,
contextWindowUsedTokens: 22,
});
});
test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 25,
});
});
test("contextWindowUsedTokens is populated from task_progress usage data", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
});
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
});
expect(events).toContainEqual({
type: "turn_completed",
provider: "claude",
usage: {
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
},
});
});
test("task_progress usage takes priority over derived result usage", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
});
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 999,
});
});
test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => {
const queryFactory = createQueryFactoryForTurns([
[
{
type: "system",
subtype: "init",
session_id: "session-1",
permissionMode: "default",
model: "claude-sonnet-4-6",
},
{
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
input_tokens: 345,
cache_read_input_tokens: 55,
},
uuid: "task-progress-1",
session_id: "session-1",
},
{
type: "result",
subtype: "success",
duration_ms: 100,
duration_api_ms: 75,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0.25,
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-1",
session_id: "session-1",
},
],
[
{
type: "result",
subtype: "success",
duration_ms: 110,
duration_api_ms: 80,
is_error: false,
num_turns: 1,
result: "still done",
stop_reason: null,
total_cost_usd: 0.1,
usage: {
input_tokens: 11,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 6,
output_tokens: 8,
},
modelUsage: {
"claude-sonnet-4-6": { contextWindow: 200_000 },
},
permission_denials: [],
uuid: "result-2",
session_id: "session-1",
},
],
]);
const client = new ClaudeAgentClient({ logger, queryFactory });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const firstTurn = await session.run("turn 1");
const secondTurn = await session.run("turn 2");
expect(firstTurn.usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
});
expect(secondTurn.usage).toEqual({
inputTokens: 11,
cachedInputTokens: 6,
outputTokens: 8,
totalCostUsd: 0.1,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 28,
});
} finally {
await session.close();
}
});
test("convertUsage derives used tokens even when modelUsage is missing", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 22,
});
});
});
@@ -18,6 +18,7 @@ import {
type SpawnOptions,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKTaskProgressMessage,
type SDKResultMessage,
type SDKSystemMessage,
type SDKUserMessage,
@@ -47,6 +48,7 @@ import {
} from "./diagnostic-utils.js";
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
@@ -669,6 +671,43 @@ function resolvePermissionKind(
return "tool";
}
function getClaudeModeLabel(modeId: PermissionMode): string {
return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId;
}
function buildClaudePlanPermissionActions(
resumeMode: PermissionMode | null,
): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
];
if (resumeMode === "bypassPermissions") {
actions.push({
id: "implement_resume",
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
type TimelineFragment = {
kind: "assistant" | "reasoning";
text: string;
@@ -967,7 +1006,8 @@ function isSyntheticUserEntry(entry: unknown): boolean {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { isSynthetic?: unknown }).isSynthetic === true;
const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown };
return candidate.isSynthetic === true || candidate.isMeta === true;
}
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
@@ -1160,6 +1200,40 @@ function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined {
if (!modelUsage || typeof modelUsage !== "object") {
return undefined;
}
let maxContextWindow: number | undefined;
for (const value of Object.values(modelUsage as Record<string, unknown>)) {
if (!value || typeof value !== "object") {
continue;
}
const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
if (
typeof contextWindow !== "number" ||
!Number.isFinite(contextWindow) ||
contextWindow <= 0
) {
continue;
}
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
}
return maxContextWindow;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
): number | undefined {
const totalTokens = message.usage?.total_tokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
return totalTokens;
}
class ClaudeAgentSession implements AgentSession {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1175,6 +1249,7 @@ class ClaudeAgentSession implements AgentSession {
private claudeSessionId: string | null;
private persistence: AgentPersistenceHandle | null;
private currentMode: PermissionMode;
private planResumeMode: PermissionMode | null = null;
private availableModes: AgentMode[] = DEFAULT_MODES;
private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
@@ -1201,6 +1276,7 @@ class ClaudeAgentSession implements AgentSession {
private pendingInterruptAbort = false;
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined;
private userMessageIds: string[] = [];
private recentStderr = "";
private closed = false;
@@ -1235,6 +1311,9 @@ class ClaudeAgentSession implements AgentSession {
}
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
}
get id(): string | null {
@@ -1380,6 +1459,7 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
this.lastForegroundPromptText = this.extractPromptText(prompt);
this.lastContextWindowUsedTokens = undefined;
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
@@ -1476,8 +1556,16 @@ class ClaudeAgentSession implements AgentSession {
}
const normalized = isPermissionMode(modeId) ? modeId : "default";
const previousMode = this.currentMode;
const query = await this.ensureQuery();
await query.setPermissionMode(normalized);
if (normalized === "plan") {
if (previousMode !== "plan") {
this.planResumeMode = previousMode;
}
} else {
this.planResumeMode = normalized;
}
this.currentMode = normalized;
}
@@ -1524,13 +1612,22 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
await this.setMode("acceptEdits");
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: { approved: true },
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
@@ -1847,6 +1944,9 @@ class ClaudeAgentSession implements AgentSession {
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Reset session identity for explicit restarts so the new query starts
// a fresh session rather than resuming the previous one.
this.claudeSessionId = null;
oldInput?.end();
oldQuery.close?.();
try {
@@ -1856,6 +1956,12 @@ class ClaudeAgentSession implements AgentSession {
}
}
// When the pump died unexpectedly (query became null, e.g. after a session
// ID overwrite error), preserve claudeSessionId so buildOptions() passes
// resume: sessionId and the new query auto-resumes the previous session.
// For explicit restarts above, claudeSessionId was already cleared.
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
@@ -2197,6 +2303,7 @@ class ClaudeAgentSession implements AgentSession {
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
}
@@ -2212,10 +2319,12 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn terminal");
}
}
@@ -2228,6 +2337,7 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.lastContextWindowUsedTokens = undefined;
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
@@ -2238,6 +2348,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn completed");
}
@@ -2566,6 +2677,10 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude",
});
}
} else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens;
}
break;
case "user": {
@@ -2643,7 +2758,7 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "result": {
const usage = this.convertUsage(message);
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
events.push({ type: "turn_completed", provider: "claude", usage });
} else {
@@ -2688,11 +2803,16 @@ class ClaudeAgentSession implements AgentSession {
if (this.claudeSessionId === sessionId) {
return null;
}
throw new Error(
`CRITICAL: Claude session ID overwrite detected! ` +
`Existing: ${this.claudeSessionId}, New: ${sessionId}. ` +
`This indicates a session identity corruption bug.`,
// Session ID changed mid-stream (e.g. a hook caused Claude to restart
// with a new session). Accept the new ID and continue — the turn should
// not be failed just because the underlying subprocess cycled.
this.logger.warn(
{ existingSessionId: this.claudeSessionId, newSessionId: sessionId },
"Claude session ID changed in message; accepting new session",
);
this.claudeSessionId = sessionId;
this.persistence = null;
return sessionId;
}
private handleSystemMessage(message: SDKSystemMessage): string | null {
@@ -2727,14 +2847,20 @@ class ClaudeAgentSession implements AgentSession {
} else if (existingSessionId === newSessionId) {
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
} else {
throw new Error(
`CRITICAL: Claude session ID overwrite detected! ` +
`Existing: ${existingSessionId}, New: ${newSessionId}. ` +
`This indicates a session identity corruption bug.`,
// Session ID changed in an init message (e.g. a hook restarted Claude
// with a new session mid-turn). Accept the new ID and continue.
this.logger.warn(
{ existingSessionId, newSessionId },
"Claude session ID changed in init message; accepting new session",
);
this.claudeSessionId = newSessionId;
threadStartedSessionId = newSessionId;
}
this.availableModes = DEFAULT_MODES;
this.currentMode = message.permissionMode;
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
this.persistence = null;
if (message.model) {
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
@@ -2776,16 +2902,38 @@ class ClaudeAgentSession implements AgentSession {
return null;
}
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
if (!message.usage) {
return undefined;
}
return {
const usage: AgentUsage = {
inputTokens: message.usage.input_tokens,
cachedInputTokens: message.usage.cache_read_input_tokens,
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const contextWindowMaxTokens = extractContextWindowSize(
modelUsage ?? message.modelUsage,
);
if (contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = contextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (message.usage) {
const usageWithCacheCreation = message.usage as typeof message.usage & {
cache_creation_input_tokens?: number;
};
const derived =
(message.usage.input_tokens ?? 0) +
(usageWithCacheCreation.cache_creation_input_tokens ?? 0) +
(message.usage.cache_read_input_tokens ?? 0) +
(message.usage.output_tokens ?? 0);
if (Number.isFinite(derived) && derived > 0) {
usage.contextWindowUsedTokens = derived;
}
}
return usage;
}
private handlePermissionRequest: CanUseTool = async (
@@ -2820,6 +2968,8 @@ class ClaudeAgentSession implements AgentSession {
input,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
actions:
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
@@ -40,6 +40,183 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
describe("Codex app-server provider", () => {
const logger = createTestLogger();
test("extracts context window usage from snake_case token payloads", () => {
expect(
__codexAppServerInternals.toAgentUsage({
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
});
});
test("extracts context window usage from camelCase token payloads", () => {
expect(
__codexAppServerInternals.toAgentUsage({
modelContextWindow: 200000,
last: {
totalTokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
});
});
test("keeps existing usage behavior when context window fields are missing", () => {
expect(
__codexAppServerInternals.toAgentUsage({
last: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("excludes invalid context window values", () => {
expect(
__codexAppServerInternals.toAgentUsage({
model_context_window: Number.NaN,
modelContextWindow: "200000",
last: {
total_tokens: Number.NaN,
totalTokens: "50000",
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
}),
).toEqual({
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
});
});
test("normalizes raw output schemas for Codex structured outputs", () => {
const input = {
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity"],
},
},
overall: { type: "string" },
},
required: ["overall"],
};
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
expect(normalized).toEqual({
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity", "summary"],
additionalProperties: false,
},
},
overall: { type: "string" },
},
required: ["overall", "findings"],
additionalProperties: false,
});
expect(input).toEqual({
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
severity: { type: "string" },
summary: { type: "string" },
},
required: ["severity"],
},
},
overall: { type: "string" },
},
required: ["overall"],
});
});
test("passes a normalized output schema to turn/start", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = { request } as any;
await session.startTurn("Return JSON", {
outputSchema: {
type: "object",
properties: {
summary: { type: "string" },
},
},
});
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
outputSchema: {
type: "object",
properties: {
summary: { type: "string" },
},
required: ["summary"],
additionalProperties: false,
},
}),
);
});
test("maps image prompt blocks to Codex localImage input", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
@@ -345,4 +522,165 @@ describe("Codex app-server provider", () => {
},
});
});
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-1" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [
{ step: "Inspect the existing auth flow", status: "completed" },
{ step: "Implement the button behavior", status: "pending" },
],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
actions: [
expect.objectContaining({
id: "reject",
label: "Reject",
behavior: "deny",
}),
expect.objectContaining({
id: "implement",
label: "Implement",
behavior: "allow",
}),
],
}),
});
expect(events.at(-1)).toEqual({
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: undefined,
});
});
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockResolvedValue({ turnId: "follow-up-turn" });
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-2" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the new flow", status: "pending" }],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect((session as any).serviceTier).toBeNull();
expect((session as any).planModeEnabled).toBe(false);
expect((session as any).config.featureValues).toEqual({
plan_mode: false,
fast_mode: false,
});
expect(startTurnSpy).toHaveBeenCalledWith(
expect.stringContaining("The user approved the plan. Implement it now."),
);
expect(events.at(-1)).toEqual({
type: "permission_resolved",
provider: "codex",
requestId: request.request.id,
resolution: {
behavior: "allow",
selectedActionId: "implement",
},
});
});
test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockRejectedValueOnce(new Error("follow-up failed"));
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-retry" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the retriable flow", status: "pending" }],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
await expect(
session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
}),
).rejects.toThrow("follow-up failed");
expect(startTurnSpy).toHaveBeenCalledTimes(1);
expect((session as any).planModeEnabled).toBe(true);
expect((session as any).config.featureValues).toEqual({
plan_mode: true,
fast_mode: true,
});
expect(session.getPendingPermissions()).toEqual([request.request]);
expect(
events.some(
(event) =>
event.type === "permission_resolved" && event.requestId === request.request.id,
),
).toBe(false);
});
});
@@ -1,4 +1,5 @@
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentFeature,
@@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
const CODEX_PROVIDER = "codex" as const;
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, "GPT");
}
function isSchemaRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
const type = schema.type;
return (
isSchemaRecord(schema.properties) ||
type === "object" ||
(Array.isArray(type) && type.includes("object"))
);
}
function normalizeCodexOutputSchemaNode(
schema: unknown,
path: string,
): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
}
if (!isSchemaRecord(schema)) {
return schema;
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
normalized[key] = normalizeCodexOutputSchemaNode(value, `${path}.${key}`);
}
if (!isObjectSchemaNode(normalized)) {
return normalized;
}
if (normalized.additionalProperties === undefined) {
normalized.additionalProperties = false;
} else if (normalized.additionalProperties !== false) {
throw new Error(
`Codex structured outputs require ${path} to set additionalProperties to false for object schemas.`,
);
}
const properties = isSchemaRecord(normalized.properties) ? normalized.properties : null;
if (!properties) {
return normalized;
}
const propertyKeys = Object.keys(properties);
const existingRequired = Array.isArray(normalized.required)
? normalized.required.filter((entry): entry is string => typeof entry === "string")
: [];
normalized.required = Array.from(new Set([...existingRequired, ...propertyKeys]));
return normalized;
}
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
if (!isSchemaRecord(schema)) {
throw new Error("Codex structured outputs require a JSON object schema.");
}
const normalized = normalizeCodexOutputSchemaNode(schema, "$");
if (!isSchemaRecord(normalized) || !isObjectSchemaNode(normalized)) {
throw new Error("Codex structured outputs require a root object schema.");
}
return normalized;
}
type CodexConfiguredDefaults = {
model?: string;
thinkingOptionId?: string;
@@ -677,12 +747,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
const usage = tokenUsage as {
last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number };
model_context_window?: number;
modelContextWindow?: number;
last?: {
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
total_tokens?: number;
totalTokens?: number;
};
};
const contextWindowMaxTokens =
typeof usage.model_context_window === "number" &&
Number.isFinite(usage.model_context_window) &&
usage.model_context_window > 0
? usage.model_context_window
: typeof usage.modelContextWindow === "number" &&
Number.isFinite(usage.modelContextWindow) &&
usage.modelContextWindow > 0
? usage.modelContextWindow
: undefined;
const contextWindowUsedTokens =
typeof usage.last?.total_tokens === "number" &&
Number.isFinite(usage.last.total_tokens) &&
usage.last.total_tokens > 0
? usage.last.total_tokens
: typeof usage.last?.totalTokens === "number" &&
Number.isFinite(usage.last.totalTokens) &&
usage.last.totalTokens > 0
? usage.last.totalTokens
: undefined;
return {
inputTokens: usage.last?.inputTokens,
cachedInputTokens: usage.last?.cachedInputTokens,
outputTokens: usage.last?.outputTokens,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
};
}
@@ -739,6 +839,53 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
};
}
function buildPlanPermissionActions(
options?: { includeResumeAction?: boolean; resumeLabel?: string },
): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
];
if (options?.includeResumeAction && options.resumeLabel) {
actions.push({
id: "implement_resume",
label: options.resumeLabel,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) {
return `${CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX} Make the required code changes and verify them.`;
}
return [
CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX,
"Approved plan:",
normalizedPlan,
"Carry out the work, make the necessary code changes, and verify the result.",
].join("\n\n");
}
type CodexQuestionOption = {
label: string;
description?: string;
@@ -2269,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession {
string,
{
resolve: (value: unknown) => void;
kind: "command" | "file" | "question";
kind: "command" | "file" | "question" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
}
>();
private resolvedPermissionRequests = new Set<string>();
@@ -2289,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private connected = false;
private collaborationModes: Array<{
name: string;
@@ -2472,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
this.config.featureValues = {
...(this.config.featureValues ?? {}),
[featureId]: value,
};
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
return;
}
this.planModeEnabled = value;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
private rememberPlanResult(item: ToolCallTimelineItem): void {
if (item.detail.type !== "plan") {
return;
}
this.latestPlanResult = {
callId: item.callId,
text: item.detail.text,
turnId: this.currentTurnId,
};
}
private emitSyntheticPlanApprovalRequest(planText: string): void {
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
description: "Review the proposed plan before implementation starts.",
input: { plan: planText },
actions: buildPlanPermissionActions(),
metadata: {
planText,
source: "codex_plan_approval",
},
};
this.pendingPermissions.set(requestId, request);
this.pendingPermissionHandlers.set(requestId, {
resolve: () => undefined,
kind: "plan",
planText,
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise<void> {
const planText =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
const previousPlanMode = this.planModeEnabled;
const previousFastMode = this.serviceTier === "fast";
this.applyFeatureValue("plan_mode", false);
this.applyFeatureValue("fast_mode", false);
try {
await this.startTurn(buildCodexPlanImplementationPrompt(planText));
} catch (error) {
this.applyFeatureValue("plan_mode", previousPlanMode);
this.applyFeatureValue("fast_mode", previousFastMode);
throw error;
}
}
private registerRequestHandlers(): void {
if (!this.client) return;
@@ -2774,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession {
params.cwd = this.config.cwd;
}
if (options?.outputSchema) {
params.outputSchema = options.outputSchema;
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
}
if (this.config.systemPrompt?.trim()) {
params.developerInstructions = this.config.systemPrompt.trim();
@@ -2869,14 +3091,11 @@ class CodexAppServerAgentSession implements AgentSession {
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
this.applyFeatureValue("fast_mode", Boolean(value));
return;
}
if (featureId === "plan_mode") {
this.planModeEnabled = Boolean(value);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
this.applyFeatureValue("plan_mode", Boolean(value));
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
@@ -2892,6 +3111,26 @@ class CodexAppServerAgentSession implements AgentSession {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
}
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
if (pending.kind === "plan") {
if (response.behavior === "allow") {
await this.handleApprovedPlanPermission({
planText: pending.planText ?? pendingRequest?.metadata?.planText,
});
}
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
});
return;
}
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
@@ -3193,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedExecCommandStartedCallIds.clear();
@@ -3214,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession {
} else if (parsed.status === "interrupted") {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
}
this.emitEvent({
type: "turn_completed",
provider: CODEX_PROVIDER,
@@ -3221,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession {
});
}
this.activeForegroundTurnId = null;
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedExecCommandStartedCallIds.clear();
@@ -3242,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession {
),
});
if (timelineItem) {
this.rememberPlanResult(timelineItem);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
@@ -3435,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
if (timelineItem.type === "tool_call") {
if (timelineItem.detail.type === "plan") {
this.rememberPlanResult(timelineItem);
}
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
@@ -4008,6 +4256,8 @@ export const __codexAppServerInternals = {
mapCodexPatchNotificationToToolCall,
planStepsToMarkdown,
mapCodexPlanToToolCall,
normalizeCodexOutputSchema,
normalizeCodexQuestionPrompts,
toAgentUsage,
threadItemToTimeline,
};
@@ -12,7 +12,11 @@ import path from "node:path";
import { execFileSync } from "node:child_process";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
__openCodeInternals,
OpenCodeAgentClient,
translateOpenCodeEvent,
} from "./opencode-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type {
AgentSessionConfig,
@@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode");
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
expect(model.id).toContain("/");
expect(model.metadata).toMatchObject({
providerId: expect.any(String),
modelId: expect.any(String),
contextWindowMaxTokens: expect.any(Number),
});
}
}, 60_000);
@@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode");
rmSync(cwd, { recursive: true, force: true });
}, 180_000);
});
describe("OpenCode adapter context-window normalization", () => {
test("preserves provider catalog context limit in model metadata", () => {
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
{ id: "openai", name: "OpenAI" },
"gpt-5",
{
name: "GPT-5",
family: "gpt",
limit: {
context: 400_000,
input: 200_000,
output: 16_384,
},
},
);
expect(definition.metadata).toMatchObject({
providerId: "openai",
modelId: "gpt-5",
contextWindowMaxTokens: 400_000,
limit: {
context: 400_000,
input: 200_000,
output: 16_384,
},
});
});
test("resolves selected model context window from connected provider catalog data", () => {
expect(
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
{
connected: ["openai"],
all: [
{
id: "openai",
models: {
"gpt-5": {
limit: {
context: 400_000,
output: 16_384,
},
},
},
},
{
id: "anthropic",
models: {
"claude-opus": {
limit: {
context: 1_000_000,
output: 8_192,
},
},
},
},
],
},
"openai/gpt-5",
),
).toBe(400_000);
expect(
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
{
connected: ["openai"],
all: [
{
id: "anthropic",
models: {
"claude-opus": {
limit: {
context: 1_000_000,
output: 8_192,
},
},
},
},
],
},
"anthropic/claude-opus",
),
).toBeUndefined();
});
test("normalizes step-finish usage into AgentUsage context window fields", () => {
const usage = { contextWindowMaxTokens: 400_000 };
__openCodeInternals.mergeOpenCodeStepFinishUsage(usage, {
cost: 0.25,
tokens: {
total: 999_999,
input: 30_000,
output: 12_000,
reasoning: 10_000,
cache: {
read: 2_000,
write: 1_000,
},
},
});
expect(usage).toEqual({
contextWindowMaxTokens: 400_000,
contextWindowUsedTokens: 55_000,
cachedInputTokens: 2_000,
inputTokens: 30_000,
outputTokens: 12_000,
totalCostUsd: 0.25,
});
expect(__openCodeInternals.hasNormalizedOpenCodeUsage(usage)).toBe(true);
});
test("resolves context window max tokens from assistant message metadata", () => {
const usage = {};
const onAssistantModelContextWindowResolved = vi.fn();
translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-1",
sessionID: "session-1",
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
},
},
},
{
sessionId: "session-1",
messageRoles: new Map(),
accumulatedUsage: usage,
streamedPartKeys: new Set(),
emittedStructuredMessageIds: new Set(),
partTypes: new Map(),
modelContextWindowsByModelKey: new Map([["openai/gpt-5", 400_000]]),
onAssistantModelContextWindowResolved,
},
);
expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000);
});
});
@@ -1,6 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import {
createOpencodeClient,
type OpencodeClient,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
import { z } from "zod";
@@ -268,6 +271,224 @@ function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
});
}
function readPositiveFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string {
return `${providerId}/${modelId}`;
}
function parseOpenCodeModelLookupKey(modelId: string | null | undefined): string | undefined {
if (typeof modelId !== "string" || modelId.trim().length === 0) {
return undefined;
}
const slashIndex = modelId.indexOf("/");
if (slashIndex <= 0 || slashIndex === modelId.length - 1) {
return undefined;
}
const providerId = modelId.slice(0, slashIndex).trim();
const providerModelId = modelId.slice(slashIndex + 1).trim();
if (!providerId || !providerModelId) {
return undefined;
}
return buildOpenCodeModelLookupKey(providerId, providerModelId);
}
function extractOpenCodeModelContextWindow(model: unknown): number | undefined {
if (!model || typeof model !== "object") {
return undefined;
}
const limit = (model as { limit?: { context?: unknown } }).limit;
return readPositiveFiniteNumber(limit?.context);
}
function buildOpenCodeModelDefinition(
provider: {
id: string;
name: string;
},
modelId: string,
model: {
name: string;
family?: string;
release_date?: string;
attachment?: boolean;
reasoning?: boolean;
tool_call?: boolean;
cost?: unknown;
limit?: { context?: number; input?: number; output?: number };
variants?: Record<string, unknown>;
},
): AgentModelDefinition {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
return {
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: thinkingOptions[0]?.id,
metadata: {
providerId: provider.id,
providerName: provider.name,
modelId,
family: model.family,
releaseDate: model.release_date,
supportsAttachments: model.attachment,
supportsReasoning: model.reasoning,
supportsToolCall: model.tool_call,
cost: model.cost,
contextWindowMaxTokens: extractOpenCodeModelContextWindow(model),
...(model.limit ? { limit: model.limit } : {}),
},
};
}
function resolveOpenCodeSelectedModelContextWindow(
providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined,
modelId: string | null | undefined,
): number | undefined {
if (!providers) {
return undefined;
}
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
if (!modelLookupKey) {
return undefined;
}
const lookup = buildOpenCodeModelContextWindowLookup(providers);
return lookup.get(modelLookupKey);
}
function buildOpenCodeModelContextWindowLookup(providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined): Map<string, number> {
const lookup = new Map<string, number>();
if (!providers) {
return lookup;
}
const connectedProviderIds = new Set(providers.connected ?? []);
for (const provider of providers.all ?? []) {
if (!connectedProviderIds.has(provider.id)) {
continue;
}
for (const [modelId, modelDefinition] of Object.entries(provider.models ?? {})) {
const contextWindow = extractOpenCodeModelContextWindow(modelDefinition);
if (contextWindow === undefined) {
continue;
}
lookup.set(buildOpenCodeModelLookupKey(provider.id, modelId), contextWindow);
}
}
return lookup;
}
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(
info: AgentMetadata | undefined,
): string | undefined {
if (!info || info.role !== "assistant") {
return undefined;
}
const providerId = readNonEmptyString(info.providerID);
const modelId = readNonEmptyString(info.modelID);
if (!providerId || !modelId) {
return undefined;
}
return buildOpenCodeModelLookupKey(providerId, modelId);
}
function mergeOpenCodeStepFinishUsage(
usage: AgentUsage,
part: {
cost?: unknown;
tokens?: {
input?: unknown;
output?: unknown;
reasoning?: unknown;
total?: unknown;
cache?: {
read?: unknown;
write?: unknown;
};
};
},
): void {
const inputTokens = readPositiveFiniteNumber(part.tokens?.input);
const outputTokens = readPositiveFiniteNumber(part.tokens?.output);
const reasoningTokens = readPositiveFiniteNumber(part.tokens?.reasoning);
const cacheReadTokens = readPositiveFiniteNumber(part.tokens?.cache?.read);
const cacheWriteTokens = readPositiveFiniteNumber(part.tokens?.cache?.write);
const totalTokens =
(inputTokens ?? 0) +
(outputTokens ?? 0) +
(reasoningTokens ?? 0) +
(cacheReadTokens ?? 0) +
(cacheWriteTokens ?? 0);
const cost = readPositiveFiniteNumber(part.cost);
if (inputTokens !== undefined) {
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens;
}
if (cacheReadTokens !== undefined) {
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens;
}
if (outputTokens !== undefined) {
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens;
}
if (totalTokens > 0) {
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens;
}
if (cost !== undefined) {
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
}
}
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
return [
usage.inputTokens,
usage.cachedInputTokens,
usage.outputTokens,
usage.totalCostUsd,
usage.contextWindowMaxTokens,
usage.contextWindowUsedTokens,
].some((value) => typeof value === "number" && Number.isFinite(value));
}
export const __openCodeInternals = {
buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey,
extractOpenCodeModelContextWindow,
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
resolveOpenCodeSelectedModelContextWindow,
};
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
@@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly serverManager: OpenCodeServerManager;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
@@ -460,7 +682,15 @@ export class OpenCodeAgentClient implements AgentClient {
throw new Error("OpenCode session creation returned no data");
}
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession(
openCodeConfig,
client,
session.id,
this.logger,
new Map(this.modelContextWindows),
);
}
async resumeSession(
@@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient {
directory: openCodeConfig.cwd,
});
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
return new OpenCodeAgentSession(
openCodeConfig,
client,
handle.sessionId,
this.logger,
new Map(this.modelContextWindows),
);
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
@@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient {
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
// Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) {
@@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient {
}
for (const [modelId, model] of Object.entries(provider.models)) {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
models.push({
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId: thinkingOptions[0]?.id,
metadata: {
providerId: provider.id,
providerName: provider.name,
modelId,
family: model.family,
releaseDate: model.release_date,
supportsAttachments: model.attachment,
supportsReasoning: model.reasoning,
supportsToolCall: model.tool_call,
cost: model.cost,
},
});
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens,
);
}
models.push(definition);
}
}
@@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient {
}
return { ...config, provider: "opencode" };
}
private async populateModelContextWindowCache(
client: OpencodeClient,
cwd: string,
): Promise<void> {
const response = await client.provider.list({ directory: cwd });
if (response.error || !response.data) {
return;
}
const lookup = buildOpenCodeModelContextWindowLookup(response.data);
this.modelContextWindows.clear();
for (const [modelLookupKey, contextWindowMaxTokens] of lookup.entries()) {
this.modelContextWindows.set(modelLookupKey, contextWindowMaxTokens);
}
}
}
export type OpenCodeEventTranslationState = {
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
emittedStructuredMessageIds: Set<string>;
/** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>;
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
};
function stringifyStructuredAssistantMessage(value: unknown): string | null {
@@ -787,6 +1027,15 @@ export function translateOpenCodeEvent(
if (messageId && messageSessionId === state.sessionId && role) {
state.messageRoles.set(messageId, role);
if (role === "assistant") {
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
if (contextWindowMaxTokens !== undefined) {
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
}
}
if (
role === "assistant" &&
!state.emittedStructuredMessageIds.has(messageId) &&
@@ -894,20 +1143,7 @@ export function translateOpenCodeEvent(
});
}
} else if (partType === "step-finish") {
const tokens = part.tokens as
| { input?: number; output?: number; reasoning?: number }
| undefined;
const cost = part.cost as number | undefined;
if (tokens) {
state.accumulatedUsage.inputTokens =
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
state.accumulatedUsage.outputTokens =
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
}
if (cost !== undefined) {
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
}
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
}
break;
}
@@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly client: OpencodeClient;
private readonly sessionId: string;
private readonly logger: Logger;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
@@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
}
get id(): string | null {
@@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession {
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model);
@@ -1781,28 +2028,35 @@ class OpenCodeAgentSession implements AgentSession {
streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
partTypes: this.partTypes,
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
if (!this.config.model) {
this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
}
},
});
for (const translatedEvent of translated) {
if (translatedEvent.type === "permission_requested") {
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
}
if (translatedEvent.type === "turn_completed") {
translatedEvent.usage = this.extractAndResetUsage();
}
}
return translated;
}
private extractAndResetUsage(): AgentUsage | undefined {
const usage = this.accumulatedUsage;
this.accumulatedUsage = {};
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
return this.selectedModelContextWindowMaxTokens;
}
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
private resolveConfiguredModelContextWindowMaxTokens(
modelId: string | undefined,
): number | undefined {
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
if (!modelLookupKey) {
return undefined;
}
return usage;
return this.modelContextWindowsByModelKey.get(modelLookupKey);
}
}
@@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
}
describe("translateOpenCodeEvent", () => {
it("resolves context window max tokens from assistant message.updated model metadata", () => {
const resolvedContextWindowMaxTokens: number[] = [];
const state = createState();
state.modelContextWindowsByModelKey = new Map([["anthropic/claude-sonnet-4", 200_000]]);
state.onAssistantModelContextWindowResolved = (contextWindowMaxTokens) => {
resolvedContextWindowMaxTokens.push(contextWindowMaxTokens);
};
translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-model-1",
sessionID: "session-1",
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet-4",
},
},
},
state,
);
expect(resolvedContextWindowMaxTokens).toEqual([200_000]);
});
it("does not duplicate assistant output when completed part echoes streamed delta", () => {
const state = createState();
@@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const execFileMock = vi.hoisted(() =>
vi.fn(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
),
);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: execFileMock,
};
});
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
warn: vi.fn(),
};
return logger;
}
describe("BackgroundGitFetchManager", () => {
beforeEach(() => {
vi.useFakeTimers();
execFileMock.mockReset();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
);
});
afterEach(() => {
vi.useRealTimers();
});
test("creates a fetch timer for a repo with an origin remote", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(target).toBeDefined();
expect(target.intervalId).toBeTruthy();
expect(execFileMock).toHaveBeenNthCalledWith(
1,
"git",
["remote", "get-url", "origin"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
expect(execFileMock).toHaveBeenNthCalledWith(
2,
"git",
["fetch", "origin", "--prune"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
subscription.unsubscribe();
manager.dispose();
});
test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listenerOne = vi.fn();
const listenerTwo = vi.fn();
const subscriptionOne = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
listenerOne,
);
const subscriptionTwo = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
listenerTwo,
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(managerAny.targets.size).toBe(1);
expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
subscriptionOne.unsubscribe();
subscriptionTwo.unsubscribe();
manager.dispose();
});
test("cleans up the timer when the last subscriber unsubscribes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
const intervalId = target.intervalId;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
subscription.unsubscribe();
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
manager.dispose();
});
test("logs fetch errors without crashing", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
if (args[0] === "remote") {
callback(null, "", "");
return;
}
callback(new Error("fetch failed"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
"Running background git fetch",
);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
},
"Background git fetch failed",
);
manager.dispose();
});
test("calls listeners when a fetch completes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(180_000);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(2);
manager.dispose();
});
test("does not create a timer when the repo has no origin remote", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(new Error("missing origin"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
expect((manager as any).targets.size).toBe(0);
subscription.unsubscribe();
manager.dispose();
});
test("dispose clears timers and listeners", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
await flushPromises();
const managerAny = manager as any;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
manager.dispose();
expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
});
});
@@ -0,0 +1,150 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type pino from "pino";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
const execFileAsync = promisify(execFile);
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
type BackgroundGitFetchTarget = {
repoGitRoot: string;
cwd: string;
listeners: Set<() => void>;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
};
export class BackgroundGitFetchManager {
private readonly logger: pino.Logger;
private readonly targets = new Map<string, BackgroundGitFetchTarget>();
constructor(options: { logger: pino.Logger }) {
this.logger = options.logger.child({ module: "background-git-fetch-manager" });
}
async subscribe(
params: { repoGitRoot: string; cwd: string },
listener: () => void,
): Promise<{ unsubscribe: () => void }> {
const existingTarget = this.targets.get(params.repoGitRoot);
if (existingTarget) {
existingTarget.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const hasOrigin = await this.hasOriginRemote(params.cwd);
if (!hasOrigin) {
return { unsubscribe: () => {} };
}
const targetAfterProbe = this.targets.get(params.repoGitRoot);
if (targetAfterProbe) {
targetAfterProbe.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const target: BackgroundGitFetchTarget = {
repoGitRoot: params.repoGitRoot,
cwd: params.cwd,
listeners: new Set([listener]),
intervalId: setInterval(() => {
void this.runFetch(target);
}, BACKGROUND_GIT_FETCH_INTERVAL_MS),
fetchInFlight: false,
};
this.targets.set(params.repoGitRoot, target);
void this.runFetch(target);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
dispose(): void {
for (const target of this.targets.values()) {
this.closeTarget(target);
}
this.targets.clear();
}
private closeTarget(target: BackgroundGitFetchTarget): void {
if (target.intervalId) {
clearInterval(target.intervalId);
target.intervalId = null;
}
target.listeners.clear();
}
private removeListener(targetKey: string, listener: () => void): void {
const target = this.targets.get(targetKey);
if (!target) {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private async hasOriginRemote(cwd: string): Promise<boolean> {
try {
await execFileAsync("git", ["remote", "get-url", "origin"], {
cwd,
env: {
...READ_ONLY_GIT_ENV,
GIT_TERMINAL_PROMPT: "0",
},
});
return true;
} catch {
return false;
}
}
private async runFetch(target: BackgroundGitFetchTarget): Promise<void> {
if (target.fetchInFlight) {
return;
}
target.fetchInFlight = true;
this.logger.debug(
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Running background git fetch",
);
try {
await execFileAsync("git", ["fetch", "origin", "--prune"], {
cwd: target.cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
},
});
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
} finally {
target.fetchInFlight = false;
for (const listener of target.listeners) {
listener();
}
}
}
}
@@ -123,11 +123,14 @@ export class CheckoutDiffManager {
}
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
@@ -135,6 +138,7 @@ export class CheckoutDiffManager {
cwd,
compare.mode,
compare.mode === "base" ? (compare.baseRef ?? "") : "",
compare.ignoreWhitespace === true,
]);
}
@@ -206,6 +210,7 @@ export class CheckoutDiffManager {
{
mode: compare.mode,
baseRef: compare.baseRef,
ignoreWhitespace: compare.ignoreWhitespace,
includeStructured: true,
},
{ paseoHome: this.paseoHome },
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", () => {
const available = new Set(["code", "cursor", "explorer"]);
const editors = listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", () => {
const editors = listAvailableEditorTargets({
platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
});
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
});
it("returns the generic file manager target on Linux", () => {
const editors = listAvailableEditorTargets({
platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
});
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
});
it("launches editors as detached processes", async () => {
const unref = vi.fn();
const once = vi.fn((event: string, handler: () => void) => {
if (event === "spawn") {
queueMicrotask(handler);
}
return child;
});
const child = { once, unref };
const spawn = vi.fn(() => child as any);
await openInEditorTarget(
{
editorId: "vscode",
path: "/tmp/repo",
},
{
platform: "darwin",
existsSync: () => true,
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
spawn,
},
);
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
detached: true,
shell: false,
stdio: "ignore",
});
expect(unref).toHaveBeenCalled();
});
it("rejects relative paths", async () => {
await expect(
openInEditorTarget(
{
editorId: "cursor",
path: "repo",
},
{
existsSync: () => true,
findExecutable: () => "/usr/local/bin/cursor",
},
),
).rejects.toThrow("Editor target path must be an absolute local path");
});
it("rejects platform-specific targets that are unavailable on this OS", async () => {
await expect(
openInEditorTarget(
{
editorId: "finder",
path: "/tmp/repo",
},
{
platform: "linux",
existsSync: () => true,
findExecutable: () => "/usr/bin/open",
},
),
).rejects.toThrow("Editor target unavailable: Finder");
});
});
@@ -0,0 +1,168 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
type EditorTargetDefinition = {
id: EditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
};
type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null;
};
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
existsSync?: typeof existsSync;
spawn?: typeof spawn;
};
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
{
id: "file-manager",
label: "File Manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
function isAbsolutePath(value: string): boolean {
return posix.isAbsolute(value) || win32.isAbsolute(value);
}
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
if (!target) {
throw new Error(`Unknown editor target: ${editorId}`);
}
return target;
}
export function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => {
if (!isTargetSupportedOnPlatform(target, platform)) {
return [];
}
const executable = findExecutableFn(target.command);
if (!executable) {
return [];
}
return [
{
id: target.id,
label: target.label,
},
];
});
}
type Launch = {
command: string;
args: string[];
};
function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable;
}): Launch {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
return {
command: executable,
args: [input.path],
};
}
export async function openInEditorTarget(
input: {
editorId: EditorTargetId;
path: string;
},
dependencies: OpenInEditorTargetDependencies = {},
): Promise<void> {
const platform = dependencies.platform ?? process.platform;
const pathToOpen = input.path.trim();
const existsSyncFn = dependencies.existsSync ?? existsSync;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
const spawnFn = dependencies.spawn ?? spawn;
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSyncFn(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
findExecutableFn,
});
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnFn(command, args, {
detached: true,
shell: platform === "win32",
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
+295 -71
View File
@@ -29,6 +29,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
@@ -49,6 +50,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
@@ -66,6 +68,7 @@ import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
import { readGitCommand } from "./workspace-git-metadata.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -251,6 +254,17 @@ export function resolveCreateAgentTitles(options: {
};
}
export function resolveWaitForFinishError(options: {
status: "permission" | "error" | "idle";
final: AgentSnapshotPayload | null;
}): string | null {
if (options.status !== "error") {
return null;
}
const message = options.final?.lastError;
return typeof message === "string" && message.trim().length > 0 ? message : "Agent failed";
}
type ProcessingPhase = "idle" | "transcribing";
type WorkspaceGitWatchTarget = {
@@ -395,6 +409,7 @@ export type SessionOptions = {
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
agentLoadingService?: AgentLoadingService;
backgroundGitFetchManager: BackgroundGitFetchManager;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -565,6 +580,7 @@ export class Session {
private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly agentLoadingService: AgentLoadingService;
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -604,6 +620,7 @@ export class Session {
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
private readonly registerVoiceSpeakHandler?: (
agentId: string,
@@ -643,6 +660,7 @@ export class Session {
loopService,
checkoutDiffManager,
agentLoadingService,
backgroundGitFetchManager,
createAgentMcpTransport,
stt,
tts,
@@ -687,6 +705,8 @@ export class Session {
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.backgroundGitFetchManager = backgroundGitFetchManager;
this.backgroundGitFetchManager = backgroundGitFetchManager;
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null;
@@ -1650,6 +1670,14 @@ export class Session {
await this.handleWorkspaceSetupStatusRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
@@ -4077,6 +4105,9 @@ export class Session {
if (!target) {
return;
}
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId);
unsubscribeFetch?.();
this.workspaceGitFetchSubscriptions.delete(workspaceId);
this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(workspaceId);
}
@@ -4123,7 +4154,7 @@ export class Session {
workspaces: Iterable<WorkspaceDescriptorPayload>,
): Promise<void> {
for (const workspace of workspaces) {
const persistedWorkspace = await this.workspaceRegistry.get(Number(workspace.id));
const persistedWorkspace = await this.findWorkspaceByDirectory(workspace.id);
if (!persistedWorkspace) {
continue;
}
@@ -4216,6 +4247,16 @@ export class Session {
}
this.workspaceGitWatchTargets.set(workspaceId, target);
const subscription = await this.backgroundGitFetchManager.subscribe(
{ repoGitRoot: refsRoot, cwd: workspaceId },
() => {
const activeTarget = this.workspaceGitWatchTargets.get(workspaceId);
if (activeTarget) {
this.scheduleWorkspaceGitWatchRefresh(activeTarget);
}
},
);
this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe);
}
private async syncWorkspaceGitWatchTarget(
@@ -5240,7 +5281,28 @@ export class Session {
};
}
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
private async describeWorkspaceRecordWithGitData(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
return this.describeWorkspaceRecord(workspace, projectRecord);
}
private async buildWorkspaceDescriptor(input: {
workspace: PersistedWorkspaceRecord;
projectRecord?: PersistedProjectRecord | null;
includeGitData: boolean;
}): Promise<WorkspaceDescriptorPayload> {
if (input.includeGitData && input.projectRecord?.kind === "git") {
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
}
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
}
private async buildWorkspaceDescriptorMap(options: {
includeGitData: boolean;
workspaceIds?: Iterable<string>;
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
this.listAgentPayloads(),
this.workspaceRegistry.list(),
@@ -5253,16 +5315,30 @@ export class Session {
.filter((project) => !project.archivedAt)
.map((project) => [project.id, project] as const),
);
const descriptorsByWorkspaceId = new Map<number, WorkspaceDescriptorPayload>();
const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id]));
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds
? new Set(
Array.from(options.workspaceIds, (workspaceId) =>
normalizePersistedWorkspaceId(workspaceId),
),
)
: null;
const workspaceIdsByDirectory = new Map(
activeRecords.map((workspace) => [workspace.directory, workspace.directory] as const),
);
for (const workspace of activeRecords) {
if (workspaceIds && !workspaceIds.has(workspace.directory)) {
continue;
}
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
descriptorsByWorkspaceId.set(
workspace.id,
await this.describeWorkspaceRecord(
workspace.directory,
await this.buildWorkspaceDescriptor({
workspace,
activeProjects.get(workspace.projectId) ?? null,
),
projectRecord,
includeGitData: options.includeGitData,
}),
);
}
@@ -5290,7 +5366,41 @@ export class Session {
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent);
}
return Array.from(descriptorsByWorkspaceId.values());
return descriptorsByWorkspaceId;
}
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
return Array.from(
(await this.buildWorkspaceDescriptorMap({
includeGitData: false,
})).values(),
);
}
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.directory === normalizedCwd);
if (exact) {
return exact.directory;
}
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
const prefix = workspace.directory.endsWith(sep)
? workspace.directory
: `${workspace.directory}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.directory.length > bestMatch.directory.length) {
bestMatch = workspace;
}
}
return bestMatch?.directory ?? normalizedCwd;
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
@@ -5328,7 +5438,7 @@ export class Session {
case "name":
return workspace.name.toLocaleLowerCase();
case "project_id":
return workspace.projectId;
return workspace.projectId.toLocaleLowerCase();
}
}
@@ -5346,7 +5456,7 @@ export class Session {
}
return spec.direction === "asc" ? base : -base;
}
return Number(left.id) - Number(right.id);
return left.id.localeCompare(right.id);
}
private encodeFetchWorkspacesCursor(
@@ -5388,7 +5498,7 @@ export class Session {
id?: unknown;
};
if (!Array.isArray(payload.sort) || (typeof payload.id !== "number" && typeof payload.id !== "string")) {
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
if (!payload.values || typeof payload.values !== "object") {
@@ -5455,7 +5565,7 @@ export class Session {
}
return spec.direction === "asc" ? base : -base;
}
return Number(workspace.id) - Number(cursor.id);
return workspace.id.localeCompare(cursor.id);
}
private matchesWorkspaceFilter(input: {
@@ -5467,8 +5577,8 @@ export class Session {
return true;
}
if (typeof filter.projectId === "number") {
if (workspace.projectId !== filter.projectId) {
if (filter.projectId && filter.projectId.trim().length > 0) {
if (workspace.projectId !== filter.projectId.trim()) {
return false;
}
}
@@ -5707,46 +5817,73 @@ export class Session {
}
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: { dedupeGitState?: boolean },
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
if (!this.workspaceUpdatesSubscription) {
return;
}
try {
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
if (changedWorkspaceIds.size === 0) {
return;
}
await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, {
skipReconcile: true,
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed");
}
}
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
return new Set();
}
private async emitWorkspaceUpdatesForWorkspaceIds(
workspaceIds: Iterable<string>,
options?: { dedupeGitState?: boolean; skipReconcile?: boolean },
): Promise<void> {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription) {
return;
}
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.directory] : [];
const uniqueWorkspaceIds = new Set(
Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)),
);
if (uniqueWorkspaceIds.size === 0) {
return;
}
for (const nextWorkspaceId of workspaceIdsToEmit) {
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
workspaceIds: uniqueWorkspaceIds,
includeGitData: true,
});
for (const workspaceId of uniqueWorkspaceIds) {
const workspace = descriptorsByWorkspaceId.get(workspaceId);
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
: null;
if (
options?.dedupeGitState &&
this.shouldSkipWorkspaceGitWatchUpdate(normalizedCwd, nextWorkspace)
this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)
) {
continue;
}
const watchTarget = this.workspaceGitWatchTargets.get(normalizedCwd);
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) {
this.onBranchChanged(normalizedCwd, watchTarget.lastBranchName, newBranchName);
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
}
}
this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace);
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace);
if (!nextWorkspace) {
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: nextWorkspaceId,
id: workspaceId,
});
continue;
}
@@ -5756,50 +5893,53 @@ export class Session {
workspace: nextWorkspace,
});
}
if (!options?.skipReconcile) {
void this.reconcileAndEmitWorkspaceUpdates();
}
}
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
if (!this.workspaceUpdatesSubscription) {
private scheduleWorkspaceGitBootstrapUpdates(options: {
subscriptionId: string;
workspaces: Iterable<WorkspaceDescriptorPayload>;
}): void {
const gitWorkspaceIds = Array.from(options.workspaces, (workspace) => workspace)
.filter((workspace) => workspace.projectKind === "git")
.map((workspace) => workspace.id);
if (gitWorkspaceIds.length === 0) {
return;
}
const uniqueWorkspaceCwds = new Set<string>();
for (const cwd of cwds) {
const normalized = await this.resolveWorkspaceDirectory(cwd);
if (!normalized) {
continue;
queueMicrotask(() => {
if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) {
return;
}
uniqueWorkspaceCwds.add(normalized);
}
const subscription = this.workspaceUpdatesSubscription;
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
for (const workspaceCwd of uniqueWorkspaceCwds) {
const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd);
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.directory) : null;
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
: null;
this.rememberWorkspaceGitWatchFingerprint(workspaceCwd, nextWorkspace);
if (!nextWorkspace) {
if (persistedWorkspace) {
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: persistedWorkspace.directory,
});
}
continue;
}
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "upsert",
workspace: nextWorkspace,
void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, {
skipReconcile: true,
});
});
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: { dedupeGitState?: boolean },
): Promise<void> {
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
}
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const uniqueWorkspaceIds = new Set<string>();
for (const cwd of cwds) {
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
}
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
}
private async handleFetchAgents(
@@ -5905,6 +6045,11 @@ export class Session {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
void this.reconcileAndEmitWorkspaceUpdates();
this.scheduleWorkspaceGitBootstrapUpdates({
subscriptionId,
workspaces: payload.entries,
});
}
} catch (error) {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
@@ -5931,7 +6076,7 @@ export class Session {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
await this.emitWorkspaceUpdateForCwd(workspace.directory);
const descriptor = await this.describeWorkspaceRecord(workspace);
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.emit({
type: "open_project_response",
payload: {
@@ -5954,7 +6099,9 @@ export class Session {
}
}
private buildWorkspaceServicePayloadSnapshot(workspaceDirectory: string): WorkspaceDescriptorPayload["services"] {
private buildWorkspaceServicePayloadSnapshot(
workspaceDirectory: string,
): WorkspaceDescriptorPayload["services"] {
if (!this.serviceRouteStore) {
return [];
}
@@ -5976,6 +6123,14 @@ export class Session {
});
}
async getAvailableEditorTargets() {
return listAvailableEditorTargets();
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleStartWorkspaceServiceRequest(
request: StartWorkspaceServiceRequest,
): Promise<void> {
@@ -6035,13 +6190,76 @@ export class Session {
}
}
private async handleListAvailableEditorsRequest(
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
): Promise<void> {
try {
const editors = await this.getAvailableEditorTargets();
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list available editors";
this.sessionLogger.error(
{ err: error, requestType: request.type },
"Failed to list available editors",
);
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: message,
},
});
}
}
private async handleOpenInEditorRequest(
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
): Promise<void> {
try {
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open in editor";
this.sessionLogger.error(
{
err: error,
editorId: request.editorId,
path: request.path,
requestType: request.type,
},
"Failed to open in editor",
);
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: message,
},
});
}
}
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
@@ -6438,9 +6656,10 @@ export class Session {
: record.lastStatus === "error"
? "error"
: "idle";
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null, lastMessage: null },
payload: { requestId, status, final, error, lastMessage: null },
});
return;
}
@@ -6467,10 +6686,11 @@ export class Session {
: result.status === "error"
? "error"
: "idle";
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
payload: { requestId, status, final, error, lastMessage: result.lastMessage },
});
} catch (error) {
const isAbort =
@@ -7212,6 +7432,10 @@ export class Session {
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) {
unsubscribe();
}
this.workspaceGitFetchSubscriptions.clear();
for (const target of this.workspaceGitWatchTargets.values()) {
this.closeWorkspaceGitWatchTarget(target);
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { resolveWaitForFinishError } from "./session.js";
describe("resolveWaitForFinishError", () => {
test("returns the agent error when the wait result is an error", () => {
expect(
resolveWaitForFinishError({
status: "error",
final: { lastError: "invalid_json_schema" } as any,
}),
).toBe("invalid_json_schema");
});
test("returns a generic fallback when the agent ended in error without a message", () => {
expect(
resolveWaitForFinishError({
status: "error",
final: {} as any,
}),
).toBe("Agent failed");
});
test("returns null for non-error wait results", () => {
expect(
resolveWaitForFinishError({
status: "idle",
final: { lastError: "should not surface" } as any,
}),
).toBeNull();
});
});
@@ -48,7 +48,14 @@ vi.mock("node:fs", async () => {
});
vi.mock("./checkout-git-utils.js", () => ({
READ_ONLY_GIT_ENV: {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
},
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
toCheckoutError: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
vi.mock("@getpaseo/highlight", () => ({
@@ -61,10 +68,33 @@ function createSessionForWorkspaceGitWatchTests(): {
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
backgroundGitFetchManager: {
subscribe: ReturnType<typeof vi.fn>;
subscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}>;
};
logger: {
child: () => unknown;
trace: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
let nextProjectId = 1;
let nextWorkspaceId = 1;
const backgroundGitFetchSubscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
@@ -73,6 +103,19 @@ function createSessionForWorkspaceGitWatchTests(): {
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
const unsubscribe = vi.fn();
backgroundGitFetchSubscriptions.push({
params,
listener,
unsubscribe,
});
return { unsubscribe };
},
),
};
const session = new Session({
clientId: "test-client",
@@ -95,7 +138,11 @@ function createSessionForWorkspaceGitWatchTests(): {
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: number) => projects.get(id) ?? null,
insert: async () => 0,
insert: async (record: Omit<ReturnType<typeof createPersistedProjectRecord>, "id">) => {
const id = nextProjectId++;
projects.set(id, createPersistedProjectRecord({ id, ...record }));
return id;
},
upsert: async (record: any) => {
projects.set(record.id, record);
},
@@ -119,7 +166,11 @@ function createSessionForWorkspaceGitWatchTests(): {
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: number) => workspaces.get(id) ?? null,
insert: async () => 0,
insert: async (record: Omit<ReturnType<typeof createPersistedWorkspaceRecord>, "id">) => {
const id = nextWorkspaceId++;
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
return id;
},
upsert: async (record: any) => {
workspaces.set(record.id, record);
},
@@ -152,6 +203,7 @@ function createSessionForWorkspaceGitWatchTests(): {
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
@@ -162,7 +214,17 @@ function createSessionForWorkspaceGitWatchTests(): {
(session as any).listAgentPayloads = async () => [];
return { session, emitted, projects, workspaces };
return {
session,
emitted,
projects,
workspaces,
backgroundGitFetchManager: {
subscribe: backgroundGitFetchManager.subscribe,
subscriptions: backgroundGitFetchSubscriptions,
},
logger,
};
}
function seedGitWorkspace(input: {
@@ -233,19 +295,20 @@ describe("workspace git watch targets", () => {
};
let descriptor = {
id: "10",
projectId: "1",
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
workspaceDirectory: "/tmp/repo",
};
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
@@ -274,7 +337,7 @@ describe("workspace git watch targets", () => {
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "10",
id: "/tmp/repo",
name: "renamed-branch",
diffStat: { additions: 1, deletions: 0 },
},
@@ -317,15 +380,16 @@ describe("workspace git watch targets", () => {
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: 20,
projectId: 2,
id: "/tmp/repo-one",
projectId: "/tmp/repo-one",
projectDisplayName: "repo-one",
projectRootPath: "/tmp/repo-one",
projectKind: "git",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
workspaceDirectory: "/tmp/repo-one",
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
@@ -341,15 +405,16 @@ describe("workspace git watch targets", () => {
await sessionAny.primeWorkspaceGitWatchFingerprints([
{
id: 30,
projectId: 3,
id: "/tmp/repo-two",
projectId: "/tmp/repo-two",
projectDisplayName: "repo-two",
projectRootPath: "/tmp/repo-two",
projectKind: "git",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
workspaceDirectory: "/tmp/repo-two",
},
]);
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
@@ -379,4 +444,143 @@ describe("workspace git watch targets", () => {
await session.cleanup();
}
});
test("subscribes to the background fetch manager when a git watch target is created", async () => {
const { session, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 4,
workspaceId: 40,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
expect.any(Function),
);
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => {
const { session, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 5,
workspaceId: 50,
cwd: "/tmp/repo",
name: "main",
});
seedGitWorkspace({
projects,
workspaces,
projectId: 6,
workspaceId: 60,
cwd: "/tmp/repo-feature",
name: "feature",
});
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) =>
cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature",
);
sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git");
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo-feature", { isGit: true });
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2);
expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
});
expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo-feature",
});
sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo");
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled();
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
const { session, emitted, projects, workspaces, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
seedGitWorkspace({
projects,
workspaces,
projectId: 7,
workspaceId: 70,
cwd: "/tmp/repo",
name: "main",
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-1",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
let descriptor = {
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
descriptor = {
...descriptor,
name: "updated-after-fetch",
};
backgroundGitFetchManager.subscriptions[0]?.listener();
await vi.advanceTimersByTimeAsync(500);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as any[];
expect(workspaceUpdates).toHaveLength(1);
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "/tmp/repo",
name: "updated-after-fetch",
},
});
await session.cleanup();
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
});
});
@@ -85,6 +85,9 @@ function createSessionForWorkspaceTests(): {
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn() })),
};
const session = new Session({
clientId: "test-client",
@@ -172,6 +175,7 @@ function createSessionForWorkspaceTests(): {
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
@@ -988,38 +992,47 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
];
(session as any).reconcileActiveWorkspaceRecords = async () => new Set();
(session as any).buildWorkspaceDescriptorMap = async () =>
new Map([
[
"/tmp/repo",
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
],
]);
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
];
(session as any).buildWorkspaceDescriptorMap = async () =>
new Map([
[
"/tmp/repo",
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
],
]);
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update");
@@ -1109,6 +1122,195 @@ describe("workspace aggregation", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("workspace update fanout for multiple cwd values is deduplicated", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const { session, projects, workspaces } = createSessionForWorkspaceTests();
const sessionAny = session as any;
seedProject({
projects,
id: 6,
directory: "/tmp/repo",
displayName: "repo",
kind: "git",
});
seedWorkspace({
workspaces,
id: 60,
projectId: 6,
directory: "/tmp/repo",
displayName: "main",
kind: "checkout",
});
seedWorkspace({
workspaces,
id: 61,
projectId: 6,
directory: "/tmp/repo/worktree",
displayName: "feature",
kind: "worktree",
});
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-dedup",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([
[
"/tmp/repo",
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
services: [],
},
],
[
"/tmp/repo/worktree",
{
id: "/tmp/repo/worktree",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
workspaceDirectory: "/tmp/repo/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
],
]);
sessionAny.emit = (message: any) => emitted.push(message);
await sessionAny.emitWorkspaceUpdatesForCwds(["/tmp/repo/worktree", "/tmp/repo"]);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as any[];
expect(workspaceUpdates).toHaveLength(2);
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]);
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/worktree",
]);
});
test("open_project_request registers a workspace before any agent exists", async () => {
const { session, emitted, workspaces } = createSessionForWorkspaceTests();
const sessionAny = session as any;
sessionAny.resolveWorkspaceDirectory = async (cwd: string) => cwd;
await sessionAny.handleMessage({
type: "open_project_request",
cwd: "/tmp/repo",
requestId: "req-open",
});
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo")).toBe(
true,
);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
});
test("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const { session, emitted, workspaces } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const repoRoot = "/tmp/repo";
const subdir = "/tmp/repo/packages/app";
sessionAny.resolveWorkspaceDirectory = async () => repoRoot;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: repoRoot,
projectName: "repo",
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: repoRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
await sessionAny.handleMessage({
type: "open_project_request",
cwd: subdir,
requestId: "req-open-subdir",
});
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot)).toBe(
true,
);
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir)).toBe(
false,
);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("list_available_editors_request returns available targets", async () => {
const { session, emitted } = createSessionForWorkspaceTests();
const sessionAny = session as any;
sessionAny.getAvailableEditorTargets = async () => [
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
];
await sessionAny.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors",
});
const response = emitted.find(
(message) => message.type === "list_available_editors_response",
) as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
]);
});
test("open_in_editor_request launches the selected target", async () => {
const { session, emitted } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const calls: Array<{ editorId: string; path: string }> = [];
sessionAny.openEditorTarget = async (input: { editorId: string; path: string }) => {
calls.push(input);
};
await sessionAny.handleMessage({
type: "open_in_editor_request",
requestId: "req-open-editor",
editorId: "vscode",
path: "/tmp/repo",
});
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
const response = emitted.find(
(message) => message.type === "open_in_editor_response",
) as any;
expect(response?.payload.error).toBeNull();
});
test("archive_workspace_request archives the persisted workspace row", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
@@ -1550,4 +1752,186 @@ describe("backward compatibility", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => {
const { session } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const project = createPersistedProjectRecord({
id: 70,
directory: "/tmp/repo",
kind: "git",
displayName: "repo",
gitRemote: "https://github.com/acme/repo.git",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
id: 71,
projectId: project.id,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
sessionAny.listAgentPayloads = async () => [];
sessionAny.projectRegistry.list = async () => [project];
sessionAny.workspaceRegistry.list = async () => [workspace];
const baselineDescriptor = {
id: workspace.directory,
projectId: project.directory,
projectDisplayName: project.displayName,
projectRootPath: project.directory,
workspaceDirectory: workspace.directory,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
services: [],
} as const;
const gitDescriptor = {
...baselineDescriptor,
diffStat: { additions: 3, deletions: 1 },
} as const;
sessionAny.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor);
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor);
const descriptors = await sessionAny.listWorkspaceDescriptorsSnapshot();
expect(sessionAny.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project);
expect(sessionAny.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled();
expect(descriptors).toEqual([baselineDescriptor]);
});
test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const { session } = createSessionForWorkspaceTests();
const sessionAny = session as any;
const gitProject = createPersistedProjectRecord({
id: 80,
directory: "/tmp/repo",
kind: "git",
displayName: "repo",
gitRemote: "https://github.com/acme/repo.git",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const directoryProject = createPersistedProjectRecord({
id: 81,
directory: "/tmp/docs",
kind: "directory",
displayName: "docs",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const gitWorkspace = createPersistedWorkspaceRecord({
id: 82,
projectId: gitProject.id,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const directoryWorkspace = createPersistedWorkspaceRecord({
id: 83,
projectId: directoryProject.id,
directory: "/tmp/docs",
kind: "checkout",
displayName: "docs",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const baselineGitDescriptor = {
id: gitWorkspace.directory,
projectId: gitProject.directory,
projectDisplayName: gitProject.displayName,
projectRootPath: gitProject.directory,
workspaceDirectory: gitWorkspace.directory,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
services: [],
} as const;
const enrichedGitDescriptor = {
...baselineGitDescriptor,
diffStat: { additions: 3, deletions: 1 },
} as const;
const directoryDescriptor = {
id: directoryWorkspace.directory,
projectId: directoryProject.directory,
projectDisplayName: directoryProject.displayName,
projectRootPath: directoryProject.directory,
workspaceDirectory: directoryWorkspace.directory,
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "docs",
status: "done",
activityAt: null,
diffStat: null,
services: [],
} as const;
sessionAny.emit = (message: any) => emitted.push(message);
sessionAny.listAgentPayloads = async () => [];
sessionAny.projectRegistry.list = async () => [gitProject, directoryProject];
sessionAny.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace];
sessionAny.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {});
sessionAny.describeWorkspaceRecord = vi.fn(
async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => {
if (workspace.id === gitWorkspace.id) {
expect(project).toEqual(gitProject);
return baselineGitDescriptor;
}
expect(project).toEqual(directoryProject);
return directoryDescriptor;
},
);
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor);
await sessionAny.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-fetch-workspaces",
subscribe: {},
});
await new Promise((resolve) => setTimeout(resolve, 0));
const response = emitted.find(
(message) => message.type === "fetch_workspaces_response",
) as { type: "fetch_workspaces_response"; payload: any } | undefined;
expect(
response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
entry.id,
entry.diffStat,
]),
).toEqual([
[directoryDescriptor.id, directoryDescriptor.diffStat],
[baselineGitDescriptor.id, baselineGitDescriptor.diffStat],
]);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as Array<{ type: "workspace_update"; payload: any }>;
expect(workspaceUpdates).toEqual([
{
type: "workspace_update",
payload: {
kind: "upsert",
workspace: enrichedGitDescriptor,
},
},
]);
expect(sessionAny.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith(
gitWorkspace,
gitProject,
);
});
});
@@ -13,6 +13,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
import {
type ServerInfoStatusPayload,
type WSHelloMessage,
@@ -239,6 +240,7 @@ export class VoiceAssistantWebSocketServer {
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly pushTokenStore: PushTokenStore;
@@ -356,6 +358,9 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
this.backgroundGitFetchManager = new BackgroundGitFetchManager({
logger: this.logger,
});
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport;
@@ -541,6 +546,7 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.backgroundGitFetchManager.dispose();
this.checkoutDiffManager.dispose();
this.pendingConnections.clear();
this.sessions.clear();
@@ -687,6 +693,7 @@ export class VoiceAssistantWebSocketServer {
loopService: this.loopService,
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
backgroundGitFetchManager: this.backgroundGitFetchManager,
createAgentMcpTransport: this.createAgentMcpTransport,
stt: () => this.speech?.resolveStt() ?? null,
tts: () => this.speech?.resolveTts() ?? null,
@@ -203,6 +203,74 @@ describe("shared messages stream parsing", () => {
}
});
it("parses optional permission actions and selectedActionId compatibly", () => {
const requestParsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "permission_requested",
provider: "codex",
request: {
id: "perm-1",
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: { plan: "- step 1" },
actions: [
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
],
},
},
},
});
expect(requestParsed.payload.event.type).toBe("permission_requested");
if (requestParsed.payload.event.type === "permission_requested") {
expect(requestParsed.payload.event.request.actions).toHaveLength(2);
expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement");
}
const resolutionParsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:01.000Z",
event: {
type: "permission_resolved",
provider: "claude",
requestId: "perm-1",
resolution: {
behavior: "allow",
selectedActionId: "implement_resume",
},
},
},
});
expect(resolutionParsed.payload.event.type).toBe("permission_resolved");
if (resolutionParsed.payload.event.type === "permission_resolved") {
expect(resolutionParsed.payload.event.resolution).toEqual({
behavior: "allow",
selectedActionId: "implement_resume",
});
}
});
it("rejects removed initialize_agent_request inbound payload", () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request",
+68
View File
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
cachedInputTokens: z.number().optional(),
outputTokens: z.number().optional(),
totalCostUsd: z.number().optional(),
contextWindowMaxTokens: z.number().optional(),
contextWindowUsedTokens: z.number().optional(),
});
const McpStdioServerConfigSchema = z.object({
@@ -197,15 +199,24 @@ const AgentSessionConfigSchema = z.object({
});
const AgentPermissionUpdateSchema = z.record(z.unknown());
const AgentPermissionActionSchema = z.object({
id: z.string(),
label: z.string(),
behavior: z.enum(["allow", "deny"]),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
@@ -220,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
actions: z.array(AgentPermissionActionSchema).optional(),
metadata: z.record(z.unknown()).optional(),
});
@@ -978,6 +990,7 @@ const CheckoutErrorSchema = z.object({
const CheckoutDiffCompareSchema = z.object({
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(),
ignoreWhitespace: z.boolean().optional(),
});
export const CheckoutStatusRequestSchema = z.object({
@@ -1098,6 +1111,32 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
requestId: z.string(),
});
export const EditorTargetIdSchema = z.enum([
"cursor",
"vscode",
"zed",
"finder",
"explorer",
"file-manager",
]);
export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
label: z.string(),
});
export const ListAvailableEditorsRequestSchema = z.object({
type: z.literal("list_available_editors_request"),
requestId: z.string(),
});
export const OpenInEditorRequestSchema = z.object({
type: z.literal("open_in_editor_request"),
path: z.string(),
editorId: EditorTargetIdSchema,
requestId: z.string(),
});
export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"),
cwd: z.string(),
@@ -1366,6 +1405,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
WorkspaceSetupStatusRequestSchema,
ListAvailableEditorsRequestSchema,
OpenInEditorRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
@@ -1915,6 +1956,23 @@ export const StartWorkspaceServiceResponseMessageSchema = z.object({
}),
});
export const ListAvailableEditorsResponseMessageSchema = z.object({
type: z.literal("list_available_editors_response"),
payload: z.object({
requestId: z.string(),
editors: z.array(EditorTargetDescriptorPayloadSchema),
error: z.string().nullable(),
}),
});
export const OpenInEditorResponseMessageSchema = z.object({
type: z.literal("open_in_editor_response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal("archive_workspace_response"),
payload: z.object({
@@ -2608,6 +2666,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
StartWorkspaceServiceResponseMessageSchema,
ListAvailableEditorsResponseMessageSchema,
OpenInEditorResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
@@ -2708,6 +2768,8 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
@@ -2715,6 +2777,10 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
export type StartWorkspaceServiceResponseMessage = z.infer<
typeof StartWorkspaceServiceResponseMessageSchema
>;
export type ListAvailableEditorsResponseMessage = z.infer<
typeof ListAvailableEditorsResponseMessageSchema
>;
export type OpenInEditorResponseMessage = z.infer<typeof OpenInEditorResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
export type FetchAgentTimelineResponseMessage = z.infer<
@@ -2863,6 +2929,8 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request");
});
test("parses list_available_editors_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",
requestId: "req-editors",
});
expect(parsed.type).toBe("list_available_editors_request");
});
test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: null,
},
});
expect(parsed.type).toBe("open_in_editor_response");
});
test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",
@@ -110,6 +110,21 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file");
});
it("hides whitespace-only changes when requested", async () => {
writeFileSync(join(repoDir, "file.txt"), "hello \n");
const visibleDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted" });
expect(visibleDiff.diff).toContain("file.txt");
const hiddenDiff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
ignoreWhitespace: true,
includeStructured: true,
});
expect(hiddenDiff.diff).toBe("");
expect(hiddenDiff.structured).toEqual([]);
});
it("preserves removed-line syntax highlighting with structured diffs", async () => {
const originalContent = `/*
comment line 1
+48 -12
View File
@@ -309,13 +309,25 @@ export async function listBranchSuggestions(
return ordered.slice(0, limit);
}
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
async function listCheckoutFileChanges(
cwd: string,
ref: string,
ignoreWhitespace = false,
): Promise<CheckoutFileChange[]> {
const changes: CheckoutFileChange[] = [];
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
const { stdout: nameStatusOut } = await execFileAsync(
"git",
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--name-status", ref],
}),
{
cwd,
env: READ_ONLY_GIT_ENV,
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
},
);
for (const line of nameStatusOut
.split("\n")
.map((l) => l.trim())
@@ -430,13 +442,24 @@ function normalizeNumstatPath(pathField: string): string {
return pathField;
}
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
}
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath(cwd: string, ref: string): Promise<Map<string, FileStat>> {
async function getTrackedNumstatByPath(
cwd: string,
ref: string,
ignoreWhitespace = false,
): Promise<Map<string, FileStat>> {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--numstat", ref],
args: buildGitDiffArgs({
ignoreWhitespace,
extra: ["--numstat", ref],
}),
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
@@ -612,6 +635,7 @@ export interface CheckoutDiffResult {
export interface CheckoutDiffCompare {
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
includeStructured?: boolean;
}
@@ -1106,6 +1130,7 @@ function buildPlaceholderParsedDiffFile(
async function getUntrackedDiffText(
cwd: string,
change: CheckoutFileChange,
ignoreWhitespace = false,
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
@@ -1118,7 +1143,10 @@ async function getUntrackedDiffText(
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path],
args: buildGitDiffArgs({
ignoreWhitespace,
extra: ["--no-index", "/dev/null", "--", change.path],
}),
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
@@ -1391,7 +1419,8 @@ export async function getCheckoutDiff(
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
}
const changes = await listCheckoutFileChanges(cwd, refForDiff);
const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
changes.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
@@ -1422,7 +1451,7 @@ export async function getCheckoutDiff(
const trackedNumstatByPath =
trackedChanges.length > 0
? await getTrackedNumstatByPath(cwd, refForDiff)
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
: new Map<string, FileStat>();
const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map<
@@ -1448,7 +1477,10 @@ export async function getCheckoutDiff(
if (trackedDiffPaths.length > 0) {
const trackedDiffResult = await spawnLimitedText({
cmd: "git",
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
args: buildGitDiffArgs({
ignoreWhitespace,
extra: [refForDiff, "--", ...trackedDiffPaths],
}),
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TOTAL_DIFF_MAX_BYTES,
@@ -1537,7 +1569,11 @@ export async function getCheckoutDiff(
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
const { text, truncated, stat } = await getUntrackedDiffText(
cwd,
change,
ignoreWhitespace,
);
if (!compare.includeStructured) {
if (stat?.isBinary) {
@@ -149,6 +149,71 @@ describe("createWorktree", () => {
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
it("prefers origin/{branch} over local {branch} when both exist", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
execSync(`git init --bare ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir });
execSync("git config user.name 'Test'", { cwd: remoteCloneDir });
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
execSync("git add file.txt", { cwd: remoteCloneDir });
execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", {
cwd: remoteCloneDir,
});
execSync("git push origin main", { cwd: remoteCloneDir });
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
execSync("git add file.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'advance local main'", { cwd: repoDir });
execSync("git fetch origin", { cwd: repoDir });
const result = await createWorktree({
branchName: "prefer-origin-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "prefer-origin-feature",
runSetup: false,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n");
});
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
execSync("git add file.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'advance local main only'", { cwd: repoDir });
const result = await createWorktree({
branchName: "prefer-local-fallback-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "prefer-local-fallback-feature",
runSetup: false,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n");
});
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
await expect(
createWorktree({
branchName: "missing-base-feature",
cwd: repoDir,
baseBranch: "does-not-exist",
worktreeSlug: "missing-base-feature",
runSetup: false,
paseoHome,
}),
).rejects.toThrow("Base branch not found: does-not-exist");
});
it("fails with invalid branch name", async () => {
await expect(
createWorktree({
+4 -5
View File
@@ -1046,15 +1046,14 @@ export async function createWorktree({
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
}
// Resolve the base branch - try local first, then remote
// Resolve the base branch - prefer origin/{branch}, then fall back to local
let resolvedBaseBranch = normalizedBaseBranch;
try {
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
} catch {
// Local branch doesn't exist, try remote (origin/{branch})
try {
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.48",
"version": "0.1.49",
"private": true,
"type": "module",
"scripts": {
@@ -826,6 +826,8 @@ function GetStarted() {
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
<AgentBadge name="Codex" icon={<CodexIcon className="h-6 w-6" />} />
<AgentBadge name="OpenCode" icon={<OpenCodeIcon className="h-6 w-6" />} />
<AgentBadge name="Copilot" icon={<CopilotIcon className="h-6 w-6" />} />
<AgentBadge name="Pi" icon={<PiIcon className="h-6 w-6" />} />
</div>
</div>
</motion.div>
+13 -13
View File
@@ -23,9 +23,9 @@ import { Route as BlogIndexRouteImport } from './routes/blog/index'
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
import { Route as DocsCliRouteImport } from './routes/docs/cli'
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
import { Route as BlogSplatRouteImport } from './routes/blog/$'
@@ -100,6 +100,11 @@ const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
path: '/updates',
getParentRoute: () => DocsRoute,
} as any)
const DocsSkillsRoute = DocsSkillsRouteImport.update({
id: '/skills',
path: '/skills',
getParentRoute: () => DocsRoute,
} as any)
const DocsSecurityRoute = DocsSecurityRouteImport.update({
id: '/security',
path: '/security',
@@ -110,11 +115,6 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
path: '/configuration',
getParentRoute: () => DocsRoute,
} as any)
const DocsSkillsRoute = DocsSkillsRouteImport.update({
id: '/skills',
path: '/skills',
getParentRoute: () => DocsRoute,
} as any)
const DocsCliRoute = DocsCliRouteImport.update({
id: '/cli',
path: '/cli',
@@ -375,6 +375,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsUpdatesRouteImport
parentRoute: typeof DocsRoute
}
'/docs/skills': {
id: '/docs/skills'
path: '/skills'
fullPath: '/docs/skills'
preLoaderRoute: typeof DocsSkillsRouteImport
parentRoute: typeof DocsRoute
}
'/docs/security': {
id: '/docs/security'
path: '/security'
@@ -389,13 +396,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsConfigurationRouteImport
parentRoute: typeof DocsRoute
}
'/docs/skills': {
id: '/docs/skills'
path: '/skills'
fullPath: '/docs/skills'
preLoaderRoute: typeof DocsSkillsRouteImport
parentRoute: typeof DocsRoute
}
'/docs/cli': {
id: '/docs/cli'
path: '/cli'