From 24a8db5763a909b7ed7ef6dcf4917b8ce4419682 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 19 Feb 2026 09:53:44 +0700 Subject: [PATCH] refactor: extract explorer open gesture and standardize icon sizes --- packages/app/src/app/_layout.tsx | 23 +++ .../agent-form/agent-form-dropdowns.tsx | 22 +-- .../app/src/components/agent-input-area.tsx | 8 +- .../app/src/components/agent-status-bar.tsx | 20 ++- .../app/src/components/dictation-controls.tsx | 18 +-- .../app/src/components/explorer-sidebar.tsx | 40 ++++- .../src/components/headers/back-header.tsx | 2 +- .../src/components/headers/menu-header.tsx | 2 +- packages/app/src/components/message-input.tsx | 14 +- .../src/components/realtime-voice-overlay.tsx | 6 +- .../app/src/components/sidebar-agent-list.tsx | 34 ++-- .../app/src/components/sliding-sidebar.tsx | 21 ++- .../src/components/ui/segmented-control.tsx | 2 +- .../explorer-sidebar-animation-context.tsx | 36 ++++- .../app/src/hooks/use-archive-agent.test.ts | 15 ++ packages/app/src/hooks/use-archive-agent.ts | 85 +++++++++- .../src/hooks/use-explorer-open-gesture.ts | 138 ++++++++++++++++ .../src/screens/agent/agent-ready-screen.tsx | 107 ++++--------- .../src/screens/agent/draft-agent-screen.tsx | 52 +++--- packages/app/src/stores/panel-store.ts | 150 ++++++++++++++---- packages/app/src/styles/theme.ts | 7 + .../src/server/daemon-client.e2e.test.ts | 28 ++++ packages/server/src/server/session.ts | 46 +++--- packages/server/src/utils/worktree.test.ts | 10 ++ packages/server/src/utils/worktree.ts | 10 +- 25 files changed, 659 insertions(+), 237 deletions(-) create mode 100644 packages/app/src/hooks/use-explorer-open-gesture.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index eee47d6aa..c0a378fb0 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -52,6 +52,17 @@ import { import { getTauri } from "@/utils/tauri"; polyfillCrypto(); +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logLeftSidebarOpenGesture( + event: string, + details: Record +): void { + if (!IS_DEV) { + return; + } + console.log(`[LeftSidebarOpenGesture] ${event}`, details); +} function PushNotificationRouter() { const router = useRouter(); @@ -212,6 +223,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { }) .onStart(() => { isGesturing.value = true; + runOnJS(logLeftSidebarOpenGesture)("start", { + mobileView, + openGestureEnabled, + }); }) .onUpdate((event) => { // Start from closed position (-windowWidth) and move towards 0 @@ -228,6 +243,13 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { isGesturing.value = false; // Open if dragged more than 1/3 of sidebar or fast swipe const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + runOnJS(logLeftSidebarOpenGesture)("end", { + translationX: event.translationX, + velocityX: event.velocityX, + shouldOpen, + mobileView, + openGestureEnabled, + }); if (shouldOpen) { animateToOpen(); runOnJS(openAgentList)(); @@ -246,6 +268,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { animateToOpen, animateToClose, openAgentList, + mobileView, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX, diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index efb949666..f43c9aa58 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -97,7 +97,7 @@ export function DropdownField({ > {value || placeholder} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} @@ -196,7 +196,7 @@ export function SelectField({ {value || placeholder || "Select..."} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} @@ -289,7 +289,7 @@ export function DropdownSheet({ hitSlop={10} testID="dropdown-sheet-close" > - + )} - + ); } @@ -571,7 +571,7 @@ export function AgentConfigRow({ placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"} disabled={disabled || providerOptions.length === 0} onSelect={onSelectProvider} - icon={} + icon={} showLabel={false} /> @@ -585,7 +585,7 @@ export function AgentConfigRow({ disabled={disabled} isLoading={isModelLoading} onSelect={onSelectModel} - icon={} + icon={} showLabel={false} /> @@ -598,7 +598,7 @@ export function AgentConfigRow({ placeholder="Default" disabled={disabled || modeOptions.length === 0} onSelect={onSelectMode} - icon={} + icon={} showLabel={false} /> @@ -612,7 +612,7 @@ export function AgentConfigRow({ placeholder="Select..." disabled={disabled} onSelect={onSelectThinkingOption} - icon={} + icon={} showLabel={false} /> @@ -1120,16 +1120,16 @@ export function GitOptionsSection({ onSubmitEditing={handleConfirmEdit} /> - + - + ) : ( {displayBranch} - + )} diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 4d0e8857c..4b07940b9 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -562,7 +562,7 @@ export function AgentInputArea({ {isCancellingAgent ? ( ) : ( - + )} @@ -591,7 +591,7 @@ export function AgentInputArea({ {voice?.isVoiceSwitching ? ( ) : ( - + )} @@ -628,13 +628,13 @@ export function AgentInputArea({ onPress={() => handleEditQueuedMessage(item.id)} style={styles.queueActionButton} > - + handleSendQueuedNow(item.id)} style={[styles.queueActionButton, styles.queueSendButton]} > - + diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 9b7ac1a2f..fb836c689 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -118,7 +118,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { agent.currentModeId || "default"} - + {displayModel} - + - + {displayThinking} - + - + {displayMode} - + {agent.availableModes.map((mode) => { @@ -303,7 +307,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { testID="agent-preferences-model" > {displayModel} - + {models?.map((model) => { @@ -342,7 +346,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { testID="agent-preferences-thinking" > {displayThinking} - + {thinkingOptions.map((opt) => { diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index eeb60f512..d865d031a 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -58,7 +58,7 @@ export function DictationControls({ accessibilityLabel="Start voice dictation" style={[styles.micButton, disabled && styles.buttonDisabled]} > - + ); } @@ -88,7 +88,7 @@ export function DictationControls({ actionsDisabled && !isFailed ? styles.buttonDisabled : undefined, ]} > - + {actionsDisabled ? ( @@ -100,7 +100,7 @@ export function DictationControls({ accessibilityLabel="Retry dictation" style={[styles.actionButton, styles.actionButtonConfirm]} > - + ) : ( <> @@ -109,14 +109,14 @@ export function DictationControls({ accessibilityLabel="Insert transcription" style={[styles.actionButton, styles.actionButtonSecondary]} > - + - + )} @@ -167,7 +167,7 @@ export function DictationOverlay({ actionsDisabled && !isFailed && overlayStyles.buttonDisabled, ]} > - + @@ -219,7 +219,7 @@ export function DictationOverlay({ ]} > @@ -234,7 +234,7 @@ export function DictationOverlay({ ]} > @@ -247,7 +247,7 @@ export function DictationOverlay({ ]} > diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 4b8c44914..437b9bfa7 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -24,6 +24,14 @@ import { TerminalPane } from "./terminal-pane"; const MIN_CHAT_WIDTH = 400; const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logExplorerSidebar(event: string, details: Record): void { + if (!IS_DEV) { + return; + } + console.log(`[ExplorerSidebar] ${event}`, details); +} function resolveKeyboardShift(rawHeight: number, inset: number): number { "worklet"; @@ -94,9 +102,18 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb const startWidthRef = useRef(explorerWidth); const resizeWidth = useSharedValue(explorerWidth); - const handleClose = useCallback(() => { - closeToAgent(); - }, [closeToAgent]); + const handleClose = useCallback( + (reason: string) => { + logExplorerSidebar("handleClose", { + reason, + isOpen, + mobileView, + desktopFileExplorerOpen, + }); + closeToAgent(); + }, + [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView] + ); const enableSidebarCloseGesture = isMobile && isOpen; @@ -165,9 +182,15 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb isGesturing.value = false; const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500; + runOnJS(logExplorerSidebar)("closeGestureEnd", { + translationX: event.translationX, + velocityX: event.velocityX, + shouldClose, + windowWidth, + }); if (shouldClose) { animateToClose(); - runOnJS(handleClose)(); + runOnJS(handleClose)("swipe-close-gesture"); } else { animateToOpen(); } @@ -249,7 +272,10 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb {/* Backdrop */} - + handleClose("backdrop-press")} + /> @@ -265,7 +291,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb handleClose("header-close-button")} serverId={serverId} agentId={agentId} cwd={cwd} @@ -298,7 +324,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb handleClose("desktop-close-button")} serverId={serverId} agentId={agentId} cwd={cwd} diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index adec268d6..cdeb4717a 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -26,7 +26,7 @@ export function BackHeader({ onPress={onBack ?? (() => router.back())} style={styles.backButton} > - + {title && ( diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 9e21fa37a..a31dfbf28 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -75,7 +75,7 @@ export function SidebarMenuToggle({ {isMobile ? ( ) : ( - + )} ); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 697520e16..bcf0624f5 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -645,7 +645,7 @@ export const MessageInput = forwardRef(funct (hovered || !IS_WEB) && styles.removeImageButtonVisible, ]} > - + )} @@ -703,7 +703,7 @@ export const MessageInput = forwardRef(funct accessibilityRole="button" style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]} > - + Attach images @@ -736,11 +736,11 @@ export const MessageInput = forwardRef(funct ]} > {isDictating ? ( - + ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( - + ) : ( - + )} @@ -769,7 +769,7 @@ export const MessageInput = forwardRef(funct accessibilityRole="button" style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]} > - + @@ -791,7 +791,7 @@ export const MessageInput = forwardRef(funct {isSubmitLoading ? ( ) : ( - + )} diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 829720ac3..2f0b34b3d 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -54,9 +54,9 @@ export function RealtimeVoiceOverlay({ ]} > {isMuted ? ( - + ) : ( - + )} @@ -75,7 +75,7 @@ export function RealtimeVoiceOverlay({ ) : ( 0 ? ( {option.activeCount} ) : null} - {selected ? : null} + {selected ? : null} ); @@ -247,7 +247,7 @@ function SidebarAgentRow({ ]} > {isBatchSelected ? ( - + ) : null} ) : ( @@ -301,10 +301,10 @@ function SidebarAgentRow({ isArchiving ? ( ) : isArchiveConfirmVisible ? ( - + ) : ( ({ color: theme.colors.foregroundMuted, }, selectedProjectIcon: { - width: 14, - height: 14, + width: theme.iconSize.sm, + height: theme.iconSize.sm, borderRadius: theme.borderRadius.sm, }, projectCountBadge: { - minWidth: 16, - height: 16, + minWidth: theme.iconSize.md, + height: theme.iconSize.md, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", @@ -987,13 +987,13 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, }, projectIcon: { - width: 14, - height: 14, + width: theme.iconSize.sm, + height: theme.iconSize.sm, borderRadius: theme.borderRadius.sm, }, projectIconFallback: { - width: 14, - height: 14, + width: theme.iconSize.sm, + height: theme.iconSize.sm, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, @@ -1039,8 +1039,8 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 20, }, checkbox: { - width: 16, - height: 16, + width: theme.iconSize.md, + height: theme.iconSize.md, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, @@ -1100,8 +1100,8 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, agentMetaProjectIcon: { - width: 14, - height: 14, + width: theme.iconSize.sm, + height: theme.iconSize.sm, borderRadius: theme.borderRadius.sm, }, agentMetaProjectIconInactive: { diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index b12213ab8..3cb002c2f 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -356,7 +356,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { > {({ hovered }) => ( <> - + New agent )} @@ -418,7 +421,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { > {({ hovered }) => ( )} @@ -435,7 +438,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { > {({ hovered }) => ( )} @@ -479,7 +482,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { > {({ hovered }) => ( <> - + New agent )} @@ -539,7 +545,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { > {({ hovered }) => ( )} @@ -555,7 +561,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { onPress={handleSettingsDesktop} > {({ hovered }) => ( - + )} diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 019bc4e3d..e44c4b5fd 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -38,7 +38,7 @@ export function SegmentedControl({ const { theme } = useUnistyles(); const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd; const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; - const iconSize = size === "sm" ? 14 : 16; + const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md; return ( diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 9b9e04192..81e843343 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -12,6 +12,17 @@ import { usePanelStore } from "@/stores/panel-store"; const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logExplorerAnimation( + event: string, + details: Record +): void { + if (!IS_DEV) { + return; + } + console.log(`[ExplorerAnimation] ${event}`, details); +} interface ExplorerSidebarAnimationContextValue { translateX: SharedValue; @@ -50,13 +61,28 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React if (prevIsOpen.current === isOpen) { return; } + const previousIsOpen = prevIsOpen.current; prevIsOpen.current = isOpen; // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { + logExplorerAnimation("sync-skipped-during-gesture", { + previousIsOpen, + nextIsOpen: isOpen, + mobileView, + desktopFileExplorerOpen, + }); return; } + logExplorerAnimation("sync-state-change", { + previousIsOpen, + nextIsOpen: isOpen, + mobileView, + desktopFileExplorerOpen, + windowWidth, + }); + if (isOpen) { translateX.value = withTiming(0, { duration: ANIMATION_DURATION, @@ -76,7 +102,15 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React easing: ANIMATION_EASING, }); } - }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); + }, [ + isOpen, + translateX, + backdropOpacity, + windowWidth, + isGesturing, + mobileView, + desktopFileExplorerOpen, + ]); const animateToOpen = () => { "worklet"; diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index e1a37a400..1b7792477 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -51,4 +51,19 @@ describe("useArchiveAgent", () => { }) ).toBe(false); }); + + it("removes an archived agent from cached list payloads", () => { + const payload = { + entries: [ + { agent: { id: "agent-1" } }, + { agent: { id: "agent-2" } }, + ], + pageInfo: { hasMore: false }, + }; + + const next = __private__.removeAgentFromListPayload(payload, "agent-1"); + + expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]); + expect(next.pageInfo).toEqual({ hasMore: false }); + }); }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 28b3a97cd..cd6bd7c02 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -20,6 +20,10 @@ interface IsAgentArchivingInput extends ArchiveAgentInput { queryClient: QueryClient; } +interface AgentsListQueryData { + entries?: Array<{ agent?: { id?: string | null } | null } | null>; +} + function toArchiveKey(input: ArchiveAgentInput): string { const serverId = input.serverId.trim(); const agentId = input.agentId.trim(); @@ -73,6 +77,66 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean { return Boolean(readPendingState(input.queryClient)[key]); } +function removeAgentFromListPayload( + payload: T, + agentId: string +): T { + if (!payload || !Array.isArray(payload.entries) || !agentId) { + return payload; + } + const filtered = payload.entries.filter((entry) => entry?.agent?.id !== agentId); + if (filtered.length === payload.entries.length) { + return payload; + } + return { + ...payload, + entries: filtered, + } as T; +} + +function removeAgentFromCachedLists( + queryClient: QueryClient, + input: ArchiveAgentInput +): void { + const agentId = input.agentId.trim(); + if (!agentId) { + return; + } + + queryClient.setQueryData( + ["sidebarAgentsList", input.serverId], + (current) => removeAgentFromListPayload(current, agentId) + ); + queryClient.setQueryData( + ["allAgents", input.serverId], + (current) => removeAgentFromListPayload(current, agentId) + ); +} + +function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void { + const archivedAt = new Date(input.archivedAt); + if (Number.isNaN(archivedAt.getTime())) { + return; + } + + const setAgents = useSessionStore.getState().setAgents; + setAgents(input.serverId, (prev) => { + const existing = prev.get(input.agentId); + if (!existing) { + return prev; + } + if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) { + return prev; + } + const next = new Map(prev); + next.set(input.agentId, { + ...existing, + archivedAt, + }); + return next; + }); +} + export function clearArchiveAgentPending(input: IsAgentArchivingInput): void { setAgentArchiving({ ...input, @@ -92,12 +156,12 @@ export function useArchiveAgent() { }); const archiveMutation = useMutation({ - mutationFn: async (input: ArchiveAgentInput) => { + mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => { const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null; if (!client) { throw new Error("Daemon client not available"); } - await client.archiveAgent(input.agentId); + return await client.archiveAgent(input.agentId); }, onMutate: (input) => { setAgentArchiving({ @@ -107,7 +171,21 @@ export function useArchiveAgent() { isArchiving: true, }); }, - onError: (_error, input) => { + onSuccess: (result, input) => { + markAgentArchivedInStore({ + serverId: input.serverId, + agentId: input.agentId, + archivedAt: result.archivedAt, + }); + removeAgentFromCachedLists(queryClient, input); + void queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", input.serverId], + }); + void queryClient.invalidateQueries({ + queryKey: ["allAgents", input.serverId], + }); + }, + onSettled: (_result, _error, input) => { clearArchiveAgentPending({ queryClient, serverId: input.serverId, @@ -154,4 +232,5 @@ export const __private__ = { readPendingState, setAgentArchiving, isAgentArchiving, + removeAgentFromListPayload, }; diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts new file mode 100644 index 000000000..621f192ca --- /dev/null +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { Gesture } from "react-native-gesture-handler"; +import { + Extrapolation, + interpolate, + runOnJS, + useSharedValue, +} from "react-native-reanimated"; +import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; + +interface UseExplorerOpenGestureParams { + enabled: boolean; + onOpen: () => void; +} + +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logExplorerOpenGesture( + event: string, + details: Record +): void { + if (!IS_DEV) { + return; + } + console.log(`[ExplorerOpenGesture] ${event}`, details); +} + +export function useExplorerOpenGesture({ + enabled, + onOpen, +}: UseExplorerOpenGestureParams) { + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + } = useExplorerSidebarAnimation(); + const touchStartX = useSharedValue(0); + const touchStartY = useSharedValue(0); + + return useMemo( + () => + Gesture.Pan() + .enabled(enabled) + .manualActivation(true) + .onTouchesDown((event) => { + const touch = event.changedTouches[0]; + if (!touch) { + return; + } + touchStartX.value = touch.absoluteX; + touchStartY.value = touch.absoluteY; + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0]; + if (!touch || event.numberOfTouches !== 1) { + stateManager.fail(); + return; + } + + const deltaX = touch.absoluteX - touchStartX.value; + const deltaY = touch.absoluteY - touchStartY.value; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + // Fail quickly on rightward or clearly vertical intent. + if (deltaX >= 10) { + stateManager.fail(); + return; + } + if (absDeltaY > 10 && absDeltaY > absDeltaX) { + stateManager.fail(); + return; + } + + // Activate only on intentional leftward movement. + if (deltaX <= -15 && absDeltaX > absDeltaY) { + stateManager.activate(); + } + }) + .onStart(() => { + isGesturing.value = true; + runOnJS(logExplorerOpenGesture)("start", { enabled }); + }) + .onUpdate((event) => { + // Right sidebar: start from closed position (+windowWidth) and move towards 0. + const newTranslateX = Math.max( + 0, + Math.min(windowWidth, windowWidth + event.translationX) + ); + translateX.value = newTranslateX; + backdropOpacity.value = interpolate( + newTranslateX, + [windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isGesturing.value = false; + const shouldOpenByPosition = translateX.value < (windowWidth * 2) / 3; + const shouldOpenByVelocity = event.velocityX < -500; + const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity; + runOnJS(logExplorerOpenGesture)("end", { + translationX: event.translationX, + velocityX: event.velocityX, + panelTranslateX: translateX.value, + windowWidth, + shouldOpenByPosition, + shouldOpenByVelocity, + shouldOpen, + }); + if (shouldOpen) { + animateToOpen(); + runOnJS(onOpen)(); + } else { + animateToClose(); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }), + [ + enabled, + windowWidth, + translateX, + backdropOpacity, + animateToOpen, + animateToClose, + isGesturing, + onOpen, + touchStartX, + touchStartY, + ] + ); +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 339cf2046..0def725b4 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -17,11 +17,8 @@ import { useQueryClient } from "@tanstack/react-query"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue, - runOnJS, - interpolate, - Extrapolation, } from "react-native-reanimated"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { MoreVertical, @@ -41,7 +38,6 @@ import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; import { ExplorerSidebarAnimationProvider, - useExplorerSidebarAnimation, } from "@/contexts/explorer-sidebar-animation-context"; import { usePanelStore } from "@/stores/panel-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; @@ -76,6 +72,7 @@ import { import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { FetchAgentsEntry } from "@server/client/daemon-client"; +import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { DropdownMenu, DropdownMenuContent, @@ -90,6 +87,14 @@ const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; const RECONNECT_NOTICE_DELAY_MS = 10_000; const CONNECTED_NOTICE_DURATION_MS = 2_500; +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logAgentExplorer(event: string, details: Record): void { + if (!IS_DEV) { + return; + } + console.log(`[AgentExplorer] ${event}`, details); +} export function AgentReadyScreen({ serverId, @@ -324,31 +329,27 @@ function AgentScreenContent({ }, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]); const openExplorerForActiveCheckout = useCallback(() => { const checkoutContext = resolveCurrentExplorerCheckout(); + logAgentExplorer("openExplorerForActiveCheckout", { + hasCheckoutContext: Boolean(checkoutContext), + checkoutContext, + }); if (checkoutContext) { activateExplorerTabForCheckout(checkoutContext); } openFileExplorer(); }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); const handleToggleExplorer = useCallback(() => { + logAgentExplorer("handleToggleExplorer", { + isExplorerOpen, + mobileView, + isMobile, + }); if (isExplorerOpen) { toggleFileExplorer(); return; } openExplorerForActiveCheckout(); - }, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]); - - const { - translateX: explorerTranslateX, - backdropOpacity: explorerBackdropOpacity, - windowWidth: explorerWindowWidth, - animateToOpen: animateExplorerToOpen, - animateToClose: animateExplorerToClose, - isGesturing: isExplorerGesturing, - } = useExplorerSidebarAnimation(); - const handleOpenExplorerFromGesture = useCallback(() => { - openExplorerForActiveCheckout(); - animateExplorerToOpen(); - }, [animateExplorerToOpen, openExplorerForActiveCheckout]); + }, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]); useEffect(() => { if (Platform.OS !== "web") { @@ -360,53 +361,10 @@ function AgentScreenContent({ }, [serverId, agentId]); // Swipe-left gesture to open explorer sidebar on mobile - const explorerOpenGesture = useMemo( - () => - Gesture.Pan() - .enabled(isMobile && !isExplorerOpen) - // Only activate after 15px horizontal movement to the left (negative) - .activeOffsetX(-15) - // Fail if 10px vertical movement happens first (allow vertical scroll) - .failOffsetY([-10, 10]) - .onStart(() => { - isExplorerGesturing.value = true; - }) - .onUpdate((event) => { - // Right sidebar: start from closed position (+windowWidth) and move towards 0 - // Swiping left means negative translationX - const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX); - explorerTranslateX.value = newTranslateX; - explorerBackdropOpacity.value = interpolate( - newTranslateX, - [explorerWindowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ); - }) - .onEnd((event) => { - isExplorerGesturing.value = false; - // Open if dragged more than 1/3 of window or fast swipe left - const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500; - if (shouldOpen) { - runOnJS(handleOpenExplorerFromGesture)(); - } else { - animateExplorerToClose(); - } - }) - .onFinalize(() => { - isExplorerGesturing.value = false; - }), - [ - isMobile, - isExplorerOpen, - explorerWindowWidth, - explorerTranslateX, - explorerBackdropOpacity, - animateExplorerToClose, - handleOpenExplorerFromGesture, - isExplorerGesturing, - ] - ); + const explorerOpenGesture = useExplorerOpenGesture({ + enabled: isMobile && mobileView === "agent", + onOpen: openExplorerForActiveCheckout, + }); // Handle hardware back button - close explorer sidebar first, then navigate back useEffect(() => { @@ -947,7 +905,7 @@ function AgentScreenContent({ await Clipboard.setStringAsync(value); toast.show(`Copied ${label}`, { variant: "success", - icon: , + icon: , }); } catch { toast.error("Copy failed"); @@ -1015,7 +973,7 @@ function AgentScreenContent({ {isMobile ? ( checkout?.isGit ? ( ) : ( - + @@ -1170,7 +1131,7 @@ function AgentScreenContent({ } + leading={} disabled={isInitializing || shouldBlockForHistorySync} trailing={ isInitializing ? ( @@ -1202,7 +1163,7 @@ function AgentScreenContent({ > {showConnectedNotice ? ( ) : null} diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index e4e93585c..e5d0a8a64 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -3,11 +3,12 @@ import { createNameId } from 'mnemonic-id' import type { ImageAttachment } from '@/components/message-input' import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native' import { useLocalSearchParams, useRouter } from 'expo-router' +import { useIsFocused } from '@react-navigation/native' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' +import { GestureDetector } from 'react-native-gesture-handler' +import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated' import { Folder, GitBranch, PanelRight } from 'lucide-react-native' import { SidebarMenuToggle } from '@/components/headers/menu-header' import { HeaderToggleButton } from '@/components/headers/header-toggle-button' @@ -31,6 +32,7 @@ import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/bran import { shortenPath } from '@/utils/shorten-path' import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions' import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions' +import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' import { useSessionStore } from '@/stores/session-store' import { useCreateFlowStore } from '@/stores/create-flow-store' import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' @@ -131,6 +133,7 @@ function DraftAgentScreenContent({ onCreateFlowActiveChange, forcedServerId, }: DraftAgentScreenProps = {}) { + const isFocused = useIsFocused() const { theme } = useUnistyles() const router = useRouter() const insets = useSafeAreaInsets() @@ -713,26 +716,10 @@ function DraftAgentScreenContent({ } openExplorerForDraftCheckout() }, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]) - const handleOpenExplorerFromGesture = useCallback(() => { - if (!canOpenExplorer) { - return - } - openExplorerForDraftCheckout() - }, [canOpenExplorer, openExplorerForDraftCheckout]) - const explorerOpenGesture = useMemo( - () => - Gesture.Pan() - .enabled(isMobile && !isExplorerOpen && canOpenExplorer) - .activeOffsetX(-15) - .failOffsetY([-10, 10]) - .onEnd((event) => { - const shouldOpen = event.translationX < -80 || event.velocityX < -500 - if (shouldOpen) { - runOnJS(handleOpenExplorerFromGesture)() - } - }), - [canOpenExplorer, handleOpenExplorerFromGesture, isExplorerOpen, isMobile] - ) + const explorerOpenGesture = useExplorerOpenGesture({ + enabled: isMobile && mobileView === 'agent' && canOpenExplorer, + onOpen: openExplorerForDraftCheckout, + }) const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0 const workingDirSearchError = directorySuggestionsQuery.error instanceof Error @@ -926,20 +913,23 @@ function DraftAgentScreenContent({ workingDir, ]) useEffect(() => { + if (!isFocused) { + return + } setActiveExplorerCheckout(draftExplorerCheckout) - }, [draftExplorerCheckout, setActiveExplorerCheckout]) + }, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]) useEffect(() => { - if (!draftExplorerCheckout) { + if (!isFocused || !draftExplorerCheckout) { return } activateExplorerTabForCheckout(draftExplorerCheckout) - }, [activateExplorerTabForCheckout, draftExplorerCheckout]) + }, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused]) useEffect(() => { - if (canOpenExplorer || !isExplorerOpen) { + if (!isFocused || canOpenExplorer || !isExplorerOpen) { return } closeFileExplorer() - }, [canOpenExplorer, closeFileExplorer, isExplorerOpen]) + }, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused]) useEffect(() => { return () => { setActiveExplorerCheckout(null) @@ -1146,7 +1136,7 @@ function DraftAgentScreenContent({ accessibilityState={{ expanded: isExplorerOpen }} > @@ -1179,7 +1169,7 @@ function DraftAgentScreenContent({ value={displayWorkingDir} placeholder="/path/to/project" onPress={() => setIsWorkingDirOpen(true)} - icon={} + icon={} showLabel={false} valueEllipsizeMode="middle" testID="working-directory-select" @@ -1226,7 +1216,7 @@ function DraftAgentScreenContent({ value={worktreeTriggerValue} placeholder="Select worktree" onPress={() => setIsWorktreePickerOpen(true)} - icon={} + icon={} showLabel={false} valueEllipsizeMode="middle" /> @@ -1239,7 +1229,7 @@ function DraftAgentScreenContent({ placeholder="From branch" onPress={() => setIsBranchOpen(true)} disabled={repoInfoStatus === 'loading'} - icon={} + icon={} showLabel={false} /> ) : null} diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index d258df8d5..592271099 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -51,6 +51,23 @@ export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); + +function logPanelTransition( + action: string, + details: Record +): void { + if (!IS_DEV) { + return; + } + const stack = + new Error().stack + ?.split("\n") + .slice(2, 8) + .join("\n") ?? "stack unavailable"; + console.log(`[PanelStore] ${action}`, details, stack); +} + interface PanelState { // Mobile: which panel is currently shown mobileView: MobilePanelView; @@ -134,53 +151,102 @@ export const usePanelStore = create()( explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, openAgentList: () => - set((state) => ({ - mobileView: "agent-list", - desktop: { ...state.desktop, agentListOpen: true }, - })), + set((state) => { + const nextState = { + mobileView: "agent-list" as const, + desktop: { ...state.desktop, agentListOpen: true }, + }; + logPanelTransition("openAgentList", { + fromMobileView: state.mobileView, + toMobileView: nextState.mobileView, + fromDesktopAgentListOpen: state.desktop.agentListOpen, + toDesktopAgentListOpen: nextState.desktop.agentListOpen, + }); + return nextState; + }), openFileExplorer: () => set((state) => { const resolvedTab = resolveExplorerTabFromActiveCheckout(state); - return { - mobileView: "file-explorer", - desktop: { ...state.desktop, fileExplorerOpen: true }, + const nextMobileView: MobilePanelView = "file-explorer"; + const nextDesktop = { ...state.desktop, fileExplorerOpen: true }; + const nextState = { + mobileView: nextMobileView, + desktop: nextDesktop, ...(resolvedTab ? { explorerTab: resolvedTab } : {}), }; + logPanelTransition("openFileExplorer", { + fromMobileView: state.mobileView, + toMobileView: nextMobileView, + fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen, + toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen, + resolvedTab: resolvedTab ?? null, + activeCheckout: state.activeExplorerCheckout, + }); + return nextState; }), closeFileExplorer: () => - set((state) => ({ - mobileView: state.mobileView === "file-explorer" ? "agent" : state.mobileView, - desktop: { - ...state.desktop, - fileExplorerOpen: false, - }, - })), + set((state) => { + const nextState = { + mobileView: + state.mobileView === "file-explorer" ? ("agent" as const) : state.mobileView, + desktop: { + ...state.desktop, + fileExplorerOpen: false, + }, + }; + logPanelTransition("closeFileExplorer", { + fromMobileView: state.mobileView, + toMobileView: nextState.mobileView, + fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen, + toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen, + }); + return nextState; + }), closeToAgent: () => - set((state) => ({ - mobileView: "agent", - // On desktop, closing depends on which panel triggered it - // This is called when closing via gesture/backdrop, so we close the currently active mobile panel - desktop: { - agentListOpen: - state.mobileView === "agent-list" ? false : state.desktop.agentListOpen, - fileExplorerOpen: - state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen, - }, - })), + set((state) => { + const nextState = { + mobileView: "agent" as const, + // On desktop, closing depends on which panel triggered it + // This is called when closing via gesture/backdrop, so we close the currently active mobile panel + desktop: { + agentListOpen: + state.mobileView === "agent-list" ? false : state.desktop.agentListOpen, + fileExplorerOpen: + state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen, + }, + }; + logPanelTransition("closeToAgent", { + fromMobileView: state.mobileView, + toMobileView: nextState.mobileView, + fromDesktopAgentListOpen: state.desktop.agentListOpen, + toDesktopAgentListOpen: nextState.desktop.agentListOpen, + fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen, + toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen, + }); + return nextState; + }), toggleAgentList: () => set((state) => { // Mobile: toggle between agent and agent-list - const newMobileView = state.mobileView === "agent-list" ? "agent" : "agent-list"; - return { + const newMobileView: MobilePanelView = + state.mobileView === "agent-list" ? "agent" : "agent-list"; + const nextState = { mobileView: newMobileView, desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen, }, }; + logPanelTransition("toggleAgentList", { + fromMobileView: state.mobileView, + toMobileView: nextState.mobileView, + fromDesktopAgentListOpen: state.desktop.agentListOpen, + toDesktopAgentListOpen: nextState.desktop.agentListOpen, + }); + return nextState; }), toggleFileExplorer: () => @@ -188,19 +254,35 @@ export const usePanelStore = create()( // Mobile: toggle between agent and file-explorer const willOpenMobile = state.mobileView !== "file-explorer"; const willOpenDesktop = !state.desktop.fileExplorerOpen; - const nextState: Partial = { - mobileView: willOpenMobile ? "file-explorer" : "agent", - desktop: { - ...state.desktop, - fileExplorerOpen: willOpenDesktop, - }, + const nextMobileView: MobilePanelView = willOpenMobile + ? "file-explorer" + : "agent"; + const nextDesktop = { + ...state.desktop, + fileExplorerOpen: willOpenDesktop, }; + const nextState: Pick & + Partial> = { + mobileView: nextMobileView, + desktop: nextDesktop, + }; + let resolvedTab: ExplorerTab | null = null; if (willOpenMobile || willOpenDesktop) { - const resolvedTab = resolveExplorerTabFromActiveCheckout(state); + resolvedTab = resolveExplorerTabFromActiveCheckout(state); if (resolvedTab) { nextState.explorerTab = resolvedTab; } } + logPanelTransition("toggleFileExplorer", { + fromMobileView: state.mobileView, + toMobileView: nextMobileView, + fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen, + toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen, + willOpenMobile, + willOpenDesktop, + resolvedTab: resolvedTab ?? null, + activeCheckout: state.activeExplorerCheckout, + }); return nextState; }), diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 328d12538..b5fa9a93d 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -215,6 +215,13 @@ const commonTheme = { "4xl": 34, }, + iconSize: { + xs: 12, + sm: 14, + md: 16, + lg: 20, + }, + fontWeight: { normal: "normal" as const, medium: "500" as const, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 2e1e4537e..fbce2eee4 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -168,6 +168,34 @@ describe("daemon client E2E", () => { rmSync(cwd, { recursive: true, force: true }); }, 30000); + test("archives agents and excludes them from default listings", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await ctx.client.archiveAgent(created.id); + + const active = await ctx.client.fetchAgents(); + expect(active.entries.some((entry) => entry.agent.id === created.id)).toBe(false); + + const withArchived = await ctx.client.fetchAgents({ + filter: { includeArchived: true }, + }); + expect(withArchived.entries.some((entry) => entry.agent.id === created.id)).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 30000); + + test("returns rpc error when archiving an unknown agent id", async () => { + await expect(ctx.client.archiveAgent(randomUUID())).rejects.toThrow(); + }, 10000); + test("returns home-scoped directory suggestions", async () => { const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 49c02298e..63e0bd0f5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1675,22 +1675,32 @@ export class Session { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) const archivedAt = new Date().toISOString() - let archivedRecord: StoredAgentRecord | null = null - try { - const existing = await this.agentStorage.get(agentId) - if (existing) { - archivedRecord = { - ...existing, - archivedAt, - } - await this.agentStorage.upsert(archivedRecord) + const existing = await this.agentStorage.get(agentId) + let archivedRecord: StoredAgentRecord | null = existing + if (!archivedRecord) { + const liveAgent = this.agentManager.getAgent(agentId) + if (!liveAgent) { + throw new Error(`Agent not found: ${agentId}`) + } + + await this.agentStorage.applySnapshot(liveAgent, { + title: liveAgent.config.title ?? null, + internal: liveAgent.internal, + }) + archivedRecord = await this.agentStorage.get(agentId) + if (!archivedRecord) { + throw new Error(`Agent not found in storage after snapshot: ${agentId}`) } - this.agentManager.notifyAgentState(agentId) - } catch (error: any) { - this.sessionLogger.error({ err: error, agentId }, `Failed to archive agent ${agentId}`) } + archivedRecord = { + ...archivedRecord, + archivedAt, + } + await this.agentStorage.upsert(archivedRecord) + this.agentManager.notifyAgentState(agentId) + this.emit({ type: 'agent_archived', payload: { @@ -1700,13 +1710,11 @@ export class Session { }, }) - if (archivedRecord) { - await this.maybeArchiveWorktreeAfterLastAgentArchived({ - archivedAgentId: agentId, - archivedAgentCwd: archivedRecord.cwd, - requestId, - }) - } + await this.maybeArchiveWorktreeAfterLastAgentArchived({ + archivedAgentId: agentId, + archivedAgentCwd: archivedRecord.cwd, + requestId, + }) } private async handleUpdateAgentRequest( diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 3a17e483e..96f8560a5 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -118,6 +118,16 @@ describe("createWorktree", () => { expect(ownership.repoRoot).toBe(repoDir); }); + it("treats non-git directories as non-worktrees without throwing", async () => { + const nonGitDir = join(tempDir, "not-a-repo"); + execSync(`mkdir -p ${nonGitDir}`); + + const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome }); + + expect(ownership.allowed).toBe(false); + expect(ownership.worktreePath).toBe(realpathSync(nonGitDir)); + }); + it("creates a worktree with a new branch", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const result = await createWorktree({ diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ed195aeb5..856b2ba35 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -692,7 +692,15 @@ export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: { paseoHome?: string } ): Promise { - const gitCommonDir = await getGitCommonDir(cwd); + let gitCommonDir: string; + try { + gitCommonDir = await getGitCommonDir(cwd); + } catch { + return { + allowed: false, + worktreePath: normalizePathForOwnership(cwd), + }; + } const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;