diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index f50f55b14..f73be2370 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -95,6 +95,15 @@ async function openTerminalsPanel(page: Page): Promise { }); } +async function openFilesPanel(page: Page): Promise { + const filesTab = page.getByTestId("explorer-tab-files").first(); + await expect(filesTab).toBeVisible({ timeout: 30000 }); + await filesTab.click(); + await expect(page.getByTestId("files-pane-header").first()).toBeVisible({ + timeout: 30000, + }); +} + async function getDesktopAgentSidebarOpen(page: Page): Promise { return await page.evaluate(() => { const raw = localStorage.getItem("panel-state"); @@ -241,6 +250,40 @@ test("Terminals tab creates multiple terminals and streams command output", asyn } }); +test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-"); + + try { + await openNewAgentDraft(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, "hello"); + + await openTerminalsPanel(page); + await runTerminalCommand( + page, + "for i in $(seq 1 12000); do echo reattach-$i; done", + "reattach-12000" + ); + + for (let attempt = 0; attempt < 4; attempt += 1) { + await openFilesPanel(page); + await openTerminalsPanel(page); + await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, { + timeout: 30000, + }); + await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, { + timeout: 30000, + }); + } + + const marker = `reattach-health-${Date.now()}`; + await runTerminalCommand(page, `echo ${marker}`, marker); + } finally { + await repo.cleanup(); + } +}); + test("terminal tab is removed when shell exits", async ({ page }) => { const repo = await createTempGitRepo("paseo-e2e-terminal-exit-"); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index d1775739c..1317805cf 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -103,7 +103,7 @@ export function AgentStreamView({ const streamItemCountRef = useRef(0); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); - const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? agent.serverId ?? ""; @@ -154,7 +154,12 @@ export function AgentStreamView({ requestFilePreview(agentId, normalized.file); } - setExplorerTab("files"); + setExplorerTabForCheckout({ + serverId: resolvedServerId, + cwd: agent.cwd, + isGit: agent.projectPlacement?.checkout?.isGit ?? true, + tab: "files", + }); openFileExplorer(); }, [ @@ -163,7 +168,7 @@ export function AgentStreamView({ requestDirectoryListing, requestFilePreview, selectExplorerEntry, - setExplorerTab, + setExplorerTabForCheckout, openFileExplorer, ] ); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 936c776ba..9f9603d97 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -18,7 +18,6 @@ import { } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; -import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { TerminalPane } from "./terminal-pane"; @@ -40,9 +39,10 @@ interface ExplorerSidebarProps { serverId: string; agentId: string; cwd: string; + isGit: boolean; } -export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) { +export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = @@ -52,7 +52,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps const closeToAgent = usePanelStore((state) => state.closeToAgent); const explorerTab = usePanelStore((state) => state.explorerTab); const explorerWidth = usePanelStore((state) => state.explorerWidth); - const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const { width: viewportWidth } = useWindowDimensions(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); @@ -102,9 +102,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps const handleTabPress = useCallback( (tab: ExplorerTab) => { - setExplorerTab(tab); + setExplorerTabForCheckout({ serverId, cwd, isGit, tab }); }, - [setExplorerTab] + [cwd, isGit, serverId, setExplorerTabForCheckout] ); // Swipe gesture to close (swipe right on mobile) @@ -268,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps serverId={serverId} agentId={agentId} cwd={cwd} + isGit={isGit} isMobile={isMobile} /> @@ -300,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps serverId={serverId} agentId={agentId} cwd={cwd} + isGit={isGit} isMobile={false} /> @@ -313,6 +315,7 @@ interface SidebarContentProps { serverId: string; agentId: string; cwd: string; + isGit: boolean; isMobile: boolean; } @@ -323,19 +326,12 @@ function SidebarContent({ serverId, agentId, cwd, + isGit, isMobile, }: SidebarContentProps) { const { theme } = useUnistyles(); - const { status } = useCheckoutStatusQuery({ serverId, cwd }); - const isGit = status?.isGit ?? false; - const hasResolvedCheckoutStatus = status !== null; - - // Switch to Files tab if Changes tab is hidden and user was on it - useEffect(() => { - if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") { - onTabPress("files"); - } - }, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]); + const resolvedTab: ExplorerTab = + !isGit && activeTab === "changes" ? "files" : activeTab; return ( @@ -345,13 +341,13 @@ function SidebarContent({ {isGit && ( onTabPress("changes")} > Changes @@ -360,13 +356,13 @@ function SidebarContent({ )} onTabPress("files")} > Files @@ -374,13 +370,13 @@ function SidebarContent({ onTabPress("terminals")} > Terminals @@ -398,13 +394,13 @@ function SidebarContent({ {/* Content based on active tab */} - {activeTab === "changes" && ( + {resolvedTab === "changes" && ( )} - {activeTab === "files" && ( + {resolvedTab === "files" && ( )} - {activeTab === "terminals" && ( + {resolvedTab === "terminals" && ( )} diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index f68065323..1c7684d09 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -5,92 +5,8 @@ import { getIsTauri } from "@/constants/layout"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Shortcut } from "@/components/ui/shortcut"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; - -type ShortcutHelpRow = { - id: string; - label: string; - keys: ShortcutKey[]; - note?: string; -}; - -type ShortcutHelpSection = { - title: string; - rows: ShortcutHelpRow[]; -}; - -function buildShortcutHelpSections(input: { - isMac: boolean; - isTauri: boolean; -}): ShortcutHelpSection[] { - const leftSidebarKeys: ShortcutKey[] = input.isMac ? ["mod", "B"] : ["mod", "."]; - const newAgentKeys: ShortcutKey[] = input.isTauri - ? ["mod", "N"] - : ["mod", "alt", "N"]; - const quickAgentKeys: ShortcutKey[] = input.isTauri - ? ["mod", "1-9"] - : ["alt", "1-9"]; - - return [ - { - title: "Global", - rows: [ - { - id: "show-shortcuts", - label: "Show keyboard shortcuts", - keys: ["?"], - note: "Available when focus is not in a text field or terminal.", - }, - { - id: "toggle-left-sidebar", - label: "Toggle left sidebar", - keys: leftSidebarKeys, - }, - { - id: "toggle-right-sidebar", - label: "Toggle right sidebar", - keys: ["mod", "E"], - }, - { - id: "toggle-command-center", - label: "Toggle command center", - keys: ["mod", "K"], - }, - { - id: "new-agent", - label: "Create new agent", - keys: newAgentKeys, - }, - { - id: "quick-open-agent", - label: "Open sidebar agent shortcut", - keys: quickAgentKeys, - }, - ], - }, - { - title: "Agent Input", - rows: [ - { - id: "dictation-toggle", - label: "Start/stop dictation", - keys: ["mod", "D"], - }, - { - id: "voice-toggle", - label: "Toggle voice mode", - keys: ["mod", "shift", "D"], - }, - { - id: "voice-mute-toggle", - label: "Mute/unmute voice mode", - keys: ["Space"], - }, - ], - }, - ]; -} +import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; export function KeyboardShortcutsDialog() { const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); @@ -99,7 +15,7 @@ export function KeyboardShortcutsDialog() { const isMac = getShortcutOs() === "mac"; const isTauri = getIsTauri(); const sections = useMemo( - () => buildShortcutHelpSections({ isMac, isTauri }), + () => buildKeyboardShortcutHelpSections({ isMac, isTauri }), [isMac, isTauri] ); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 2266f2ad1..6e40cef23 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -365,6 +365,7 @@ export default function TerminalEmulator({ fitAndEmitResize(); }); resizeObserver.observe(root); + resizeObserver.observe(host); const windowResizeHandler = () => fitAndEmitResize(); window.addEventListener("resize", windowResizeHandler); @@ -378,6 +379,25 @@ export default function TerminalEmulator({ fitAndEmitResize(); }, 250); + const fitTimeoutHandles = [0, 16, 48, 120, 250, 500, 1_000, 2_000].map((delay) => + window.setTimeout(() => { + fitAndEmitResize(true); + }, delay) + ); + + const fontSet = document.fonts; + const fontReadyHandler = () => { + fitAndEmitResize(true); + }; + fontSet?.addEventListener?.("loadingdone", fontReadyHandler); + void fontSet?.ready + .then(() => { + fitAndEmitResize(true); + }) + .catch(() => { + // no-op + }); + window.setTimeout(() => fitAndEmitResize(true), 0); if (outputText.length > 0) { @@ -391,6 +411,10 @@ export default function TerminalEmulator({ window.removeEventListener("resize", windowResizeHandler); visualViewport?.removeEventListener("resize", visualViewportResizeHandler); window.clearInterval(fitInterval); + for (const handle of fitTimeoutHandles) { + window.clearTimeout(handle); + } + fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); root.removeEventListener("touchstart", rootTouchStartHandler); root.removeEventListener("touchmove", rootTouchMoveHandler); root.removeEventListener("touchend", rootTouchEndHandler); @@ -478,12 +502,10 @@ export default function TerminalEmulator({ ref={rootRef} data-testid={testId} style={{ - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, + position: "relative", display: "flex", + width: "100%", + height: "100%", minHeight: 0, minWidth: 0, backgroundColor, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 5685fcabd..24b773635 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -18,9 +18,17 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { useSessionStore } from "@/stores/session-store"; import { hasPendingTerminalModifiers, - mapTerminalDataToKey, normalizeTerminalTransportKey, + resolvePendingModifierDataInput, } from "@/utils/terminal-keys"; +import { + getTerminalResumeOffset, + getTerminalAttachRetryDelayMs, + isTerminalAttachRetryableError, + updateTerminalResumeOffset, + waitForDuration, + withPromiseTimeout, +} from "@/utils/terminal-attach"; import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { @@ -30,6 +38,8 @@ interface TerminalPaneProps { const MAX_OUTPUT_CHARS = 200_000; const TERMINAL_TAB_MAX_WIDTH = 220; +const TERMINAL_ATTACH_MAX_ATTEMPTS = 4; +const TERMINAL_ATTACH_TIMEOUT_MS = 12_000; const MODIFIER_LABELS = { ctrl: "Ctrl", @@ -61,8 +71,8 @@ const EMPTY_MODIFIERS: ModifierState = { alt: false, }; -function terminalScopeKey(serverId: string, cwd: string): string { - return `${serverId}:${cwd}`; +function terminalScopeKey(input: { serverId: string; cwd: string }): string { + return `${input.serverId}:${input.cwd}`; } function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) { @@ -99,9 +109,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { (state) => state.sessions[serverId]?.connection.isConnected ?? false ); - const scopeKey = useMemo(() => terminalScopeKey(serverId, cwd), [serverId, cwd]); + const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const selectedTerminalByScopeRef = useRef>(new Map()); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); + const resumeOffsetByTerminalIdRef = useRef>(new Map()); const [selectedTerminalId, setSelectedTerminalId] = useState(null); const [outputByTerminalId, setOutputByTerminalId] = useState>( @@ -111,6 +122,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { terminalId: string; streamId: number; } | null>(null); + const [attachGeneration, setAttachGeneration] = useState(0); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); @@ -121,11 +133,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ); const hoverOutTimeoutRef = useRef | null>(null); const selectedTerminalIdRef = useRef(selectedTerminalId); + const activeStreamRef = useRef<{ + terminalId: string; + streamId: number; + } | null>(activeStream); useEffect(() => { selectedTerminalIdRef.current = selectedTerminalId; }, [selectedTerminalId]); + useEffect(() => { + activeStreamRef.current = activeStream; + }, [activeStream]); + const clearHoverOutTimeout = useCallback(() => { if (!hoverOutTimeoutRef.current) { return; @@ -201,14 +221,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return; } - if (selectedTerminalIdRef.current === exitedTerminalId) { - setSelectedTerminalId((current) => - current === exitedTerminalId ? null : current - ); + const activeStreamForTerminal = activeStreamRef.current; + if ( + selectedTerminalIdRef.current === exitedTerminalId && + activeStreamForTerminal?.terminalId === exitedTerminalId && + activeStreamForTerminal.streamId === message.payload.streamId + ) { setActiveStream((current) => current?.terminalId === exitedTerminalId ? null : current ); - setIsAttaching(false); + setStreamError("Terminal stream ended. Reconnecting…"); + setIsAttaching(true); + setAttachGeneration((current) => current + 1); setModifiers({ ...EMPTY_MODIFIERS }); } @@ -253,6 +277,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }, onSuccess: (_, terminalId) => { setHoveredTerminalId((current) => (current === terminalId ? null : current)); + resumeOffsetByTerminalIdRef.current.delete(terminalId); if (selectedTerminalIdRef.current === terminalId) { setSelectedTerminalId((current) => current === terminalId ? null : current @@ -310,6 +335,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { } }, [scopeKey, terminals, selectedTerminalId]); + useEffect(() => { + if (terminals.length === 0) { + resumeOffsetByTerminalIdRef.current.clear(); + return; + } + + const terminalIdSet = new Set(terminals.map((terminal) => terminal.id)); + for (const terminalId of Array.from(resumeOffsetByTerminalIdRef.current.keys())) { + if (!terminalIdSet.has(terminalId)) { + resumeOffsetByTerminalIdRef.current.delete(terminalId); + } + } + }, [terminals]); + const appendOutput = useCallback((terminalId: string, text: string) => { if (!text) { return; @@ -343,40 +382,102 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setIsAttaching(true); setStreamError(null); - lastReportedSizeRef.current = null; const attach = async () => { try { - const attachPayload = await client.attachTerminalStream(terminalId); - if (isCancelled) { - if (typeof attachPayload.streamId === "number") { - void client.detachTerminalStream(attachPayload.streamId).catch(() => {}); - } - return; - } + let lastErrorMessage = "Unable to attach terminal stream"; + const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS; + for (let attempt = 0; attempt < totalAttempts; attempt += 1) { + try { + const lastSize = lastReportedSizeRef.current; + const resumeOffset = getTerminalResumeOffset({ + terminalId, + resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current, + }); + const attachPayload = await withPromiseTimeout({ + promise: client.attachTerminalStream(terminalId, { + ...(resumeOffset !== undefined ? { resumeOffset } : {}), + ...(lastSize + ? { + rows: lastSize.rows, + cols: lastSize.cols, + } + : {}), + }), + timeoutMs: TERMINAL_ATTACH_TIMEOUT_MS, + timeoutMessage: "Timed out attaching terminal stream", + }); + if (isCancelled) { + if (typeof attachPayload.streamId === "number") { + void client.detachTerminalStream(attachPayload.streamId).catch(() => {}); + } + return; + } - if (attachPayload.error || typeof attachPayload.streamId !== "number") { - setStreamError(attachPayload.error ?? "Unable to attach terminal stream"); - setActiveStream(null); - return; - } + if (attachPayload.error || typeof attachPayload.streamId !== "number") { + lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream"; + const hasRemainingAttempts = attempt < totalAttempts - 1; + if ( + hasRemainingAttempts && + isTerminalAttachRetryableError({ message: lastErrorMessage }) + ) { + await waitForDuration({ + durationMs: getTerminalAttachRetryDelayMs({ attempt }), + }); + continue; + } + setStreamError(lastErrorMessage); + setActiveStream(null); + return; + } - streamId = attachPayload.streamId; - decoder = new TextDecoder(); - setActiveStream({ terminalId, streamId }); + streamId = attachPayload.streamId; + updateTerminalResumeOffset({ + terminalId, + offset: attachPayload.currentOffset, + resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current, + }); + decoder = new TextDecoder(); + setActiveStream({ terminalId, streamId }); + setStreamError(null); - unsubscribe = client.onTerminalStreamData(streamId, (chunk) => { - if (isCancelled) { + unsubscribe = client.onTerminalStreamData(streamId, (chunk) => { + if (isCancelled) { + return; + } + updateTerminalResumeOffset({ + terminalId, + offset: chunk.endOffset, + resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current, + }); + const text = decoder?.decode(chunk.data, { stream: true }) ?? ""; + appendOutput(terminalId, text); + }); + return; + } catch (error) { + lastErrorMessage = + error instanceof Error ? error.message : "Unable to attach terminal stream"; + const hasRemainingAttempts = attempt < totalAttempts - 1; + if ( + hasRemainingAttempts && + isTerminalAttachRetryableError({ message: lastErrorMessage }) + ) { + await waitForDuration({ + durationMs: getTerminalAttachRetryDelayMs({ attempt }), + }); + continue; + } + + if (!isCancelled) { + setStreamError(lastErrorMessage); + setActiveStream(null); + } return; } - const text = decoder?.decode(chunk.data, { stream: true }) ?? ""; - appendOutput(terminalId, text); - }); - } catch (error) { + } + if (!isCancelled) { - setStreamError( - error instanceof Error ? error.message : "Unable to attach terminal stream" - ); + setStreamError(lastErrorMessage); setActiveStream(null); } } finally { @@ -401,8 +502,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { if (streamId !== null) { void client.detachTerminalStream(streamId).catch(() => {}); } + setActiveStream((current) => + current?.terminalId === terminalId ? null : current + ); + setIsAttaching(false); }; - }, [appendOutput, client, isConnected, selectedTerminalId]); + }, [appendOutput, attachGeneration, client, isConnected, selectedTerminalId]); const activeStreamId = activeStream && activeStream.terminalId === selectedTerminalId @@ -477,21 +582,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { } if (hasPendingTerminalModifiers(modifiers)) { - const mappedKey = mapTerminalDataToKey(data); - if ( - mappedKey && - sendTerminalKey( - { - key: mappedKey, + const pendingResolution = resolvePendingModifierDataInput({ + data, + pendingModifiers: modifiers, + }); + if (pendingResolution.mode === "key") { + if ( + sendTerminalKey({ + key: pendingResolution.key, ctrl: modifiers.ctrl, shift: modifiers.shift, alt: modifiers.alt, meta: false, - } - ) - ) { + }) + ) { + clearPendingModifiers(); + return; + } + } + + if (pendingResolution.clearPendingModifiers) { clearPendingModifiers(); - return; } } @@ -744,9 +855,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { )} {isAttaching ? ( - + - Attaching terminal… ) : null} @@ -912,22 +1022,10 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, attachOverlay: { - position: "absolute", - top: theme.spacing[3], - right: theme.spacing[3], - flexDirection: "row", + ...StyleSheet.absoluteFillObject, alignItems: "center", - gap: theme.spacing[2], - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface0, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - attachOverlayText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.16)", }, errorRow: { paddingHorizontal: theme.spacing[3], diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index de3eacff0..f163b8e1f 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -18,6 +18,7 @@ import { } from "@/utils/new-agent-routing"; import { buildHostAgentDetailRoute, + parseHostAgentDraftRouteFromPathname, parseHostAgentRouteFromPathname, parseServerIdFromPathname, } from "@/utils/host-routes"; @@ -33,12 +34,17 @@ function resolveSelectedOrRouteAgentKey(input: { selectedAgentId?: string; pathname: string; }): string | null { + const DRAFT_AGENT_ID = "__new_agent__"; if (input.selectedAgentId) { return input.selectedAgentId; } const route = parseHostAgentRouteFromPathname(input.pathname); if (!route) { - return null; + const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname); + if (!draftRoute) { + return null; + } + return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`; } return `${route.serverId}:${route.agentId}`; } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index fe14d5d47..e161a17db 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildKeyboardShortcutHelpSections, resolveKeyboardShortcut, type KeyboardShortcutContext, } from "./keyboard-shortcuts"; @@ -148,6 +149,21 @@ describe("keyboard-shortcuts", () => { expect(match?.payload).toEqual({ kind: "voice-mute-toggle" }); }); + it("lets Escape continue to local handlers while routing dictation cancel", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "Escape", + code: "Escape", + }), + context: shortcutContext({ focusScope: "message-input" }), + }); + + expect(match?.action).toBe("message-input.action"); + expect(match?.payload).toEqual({ kind: "dictation-cancel" }); + expect(match?.preventDefault).toBe(false); + expect(match?.stopPropagation).toBe(false); + }); + it("parses Alt+digit sidebar shortcut payload", () => { const match = resolveKeyboardShortcut({ event: keyboardEvent({ @@ -162,3 +178,56 @@ describe("keyboard-shortcuts", () => { expect(match?.payload).toEqual({ digit: 2 }); }); }); + +describe("keyboard-shortcut help sections", () => { + function findRow( + sections: ReturnType, + id: string + ) { + for (const section of sections) { + const row = section.rows.find((candidate) => candidate.id === id); + if (row) { + return row; + } + } + return null; + } + + it("uses non-tauri defaults for new-agent and quick-open", () => { + const sections = buildKeyboardShortcutHelpSections({ + isMac: true, + isTauri: false, + }); + + expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]); + expect(findRow(sections, "quick-open-agent")?.keys).toEqual([ + "alt", + "1-9", + ]); + }); + + it("switches to tauri bindings in help rows", () => { + const sections = buildKeyboardShortcutHelpSections({ + isMac: true, + isTauri: true, + }); + + expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]); + expect(findRow(sections, "quick-open-agent")?.keys).toEqual([ + "mod", + "1-9", + ]); + }); + + it("uses mod+period as non-mac left sidebar shortcut", () => { + const sections = buildKeyboardShortcutHelpSections({ + isMac: false, + isTauri: false, + }); + + expect(findRow(sections, "toggle-left-sidebar")?.keys).toEqual([ + "mod", + ".", + ]); + }); +}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 2e63e657a..6ef0906f7 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -1,3 +1,4 @@ +import type { ShortcutKey } from "@/utils/format-shortcut"; import type { KeyboardActionId, KeyboardFocusScope, @@ -20,6 +21,33 @@ export type KeyboardShortcutMatch = { stopPropagation: boolean; }; +export type KeyboardShortcutHelpRow = { + id: string; + label: string; + keys: ShortcutKey[]; + note?: string; +}; + +export type KeyboardShortcutHelpSection = { + id: "global" | "agent-input"; + title: string; + rows: KeyboardShortcutHelpRow[]; +}; + +type KeyboardShortcutPlatformContext = { + isMac: boolean; + isTauri: boolean; +}; + +type KeyboardShortcutHelpEntry = { + id: string; + section: KeyboardShortcutHelpSection["id"]; + label: string; + keys: ShortcutKey[]; + note?: string; + when?: (context: KeyboardShortcutPlatformContext) => boolean; +}; + type KeyboardShortcutBinding = { id: string; action: KeyboardActionId; @@ -28,6 +56,15 @@ type KeyboardShortcutBinding = { payload?: (event: KeyboardEvent) => KeyboardShortcutPayload; preventDefault?: boolean; stopPropagation?: boolean; + help?: KeyboardShortcutHelpEntry; +}; + +const SHORTCUT_HELP_SECTION_TITLES: Record< + KeyboardShortcutHelpSection["id"], + string +> = { + global: "Global", + "agent-input": "Agent Input", }; function isMod(event: KeyboardEvent): boolean { @@ -82,6 +119,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.shiftKey && (event.code === "KeyN" || event.key.toLowerCase() === "n"), when: () => true, + help: { + id: "new-agent", + section: "global", + label: "Create new agent", + keys: ["mod", "alt", "N"], + when: (context) => !context.isTauri, + }, }, { id: "agent-new-tauri-mod-n", @@ -92,6 +136,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.shiftKey && (event.code === "KeyN" || event.key.toLowerCase() === "n"), when: (context) => context.isTauri, + help: { + id: "new-agent", + section: "global", + label: "Create new agent", + keys: ["mod", "N"], + when: (context) => context.isTauri, + }, }, { id: "command-center-toggle", @@ -102,12 +153,25 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.shiftKey && (event.code === "KeyK" || event.key.toLowerCase() === "k"), when: () => true, + help: { + id: "toggle-command-center", + section: "global", + label: "Toggle command center", + keys: ["mod", "K"], + }, }, { id: "shortcuts-dialog-toggle-question-mark", action: "shortcuts.dialog.toggle", matches: isQuestionMarkShortcut, when: (context) => context.focusScope === "other", + help: { + id: "show-shortcuts", + section: "global", + label: "Show keyboard shortcuts", + keys: ["?"], + note: "Available when focus is not in a text field or terminal.", + }, }, { id: "sidebar-toggle-left-mac-cmd-b", @@ -119,6 +183,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.shiftKey && (event.code === "KeyB" || event.key.toLowerCase() === "b"), when: (context) => context.isMac, + help: { + id: "toggle-left-sidebar", + section: "global", + label: "Toggle left sidebar", + keys: ["mod", "B"], + when: (context) => context.isMac, + }, }, { id: "sidebar-toggle-left-mod-period", @@ -129,6 +200,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.shiftKey && (event.code === "Period" || event.key === "."), when: (context) => !context.commandCenterOpen, + help: { + id: "toggle-left-sidebar", + section: "global", + label: "Toggle left sidebar", + keys: ["mod", "."], + when: (context) => !context.isMac, + }, }, { id: "sidebar-toggle-right-mod-e", @@ -139,6 +217,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ !event.shiftKey && (event.code === "KeyE" || event.key.toLowerCase() === "e"), when: (context) => context.hasSelectedAgent && !context.commandCenterOpen, + help: { + id: "toggle-right-sidebar", + section: "global", + label: "Toggle right sidebar", + keys: ["mod", "E"], + }, }, { id: "sidebar-toggle-right-ctrl-backquote", @@ -163,6 +247,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ payload: withMessageInputAction("voice-toggle"), when: (context) => !context.commandCenterOpen && context.focusScope !== "terminal", + help: { + id: "voice-toggle", + section: "agent-input", + label: "Toggle voice mode", + keys: ["mod", "shift", "D"], + }, }, { id: "message-input-dictation-toggle", @@ -175,6 +265,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ payload: withMessageInputAction("dictation-toggle"), when: (context) => !context.commandCenterOpen && context.focusScope !== "terminal", + help: { + id: "dictation-toggle", + section: "agent-input", + label: "Start/stop dictation", + keys: ["mod", "D"], + }, }, { id: "message-input-dictation-cancel", @@ -183,6 +279,14 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ payload: withMessageInputAction("dictation-cancel"), when: (context) => !context.commandCenterOpen && context.focusScope !== "terminal", + preventDefault: false, + stopPropagation: false, + help: { + id: "dictation-cancel", + section: "agent-input", + label: "Cancel dictation", + keys: ["Esc"], + }, }, { id: "message-input-voice-mute-toggle", @@ -197,6 +301,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ payload: withMessageInputAction("voice-mute-toggle"), when: (context) => !context.commandCenterOpen && context.focusScope === "other", + help: { + id: "voice-mute-toggle", + section: "agent-input", + label: "Mute/unmute voice mode", + keys: ["Space"], + }, }, { id: "sidebar-shortcut-alt-digit", @@ -207,6 +317,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ return digit ? { digit } : null; }, when: (context) => !context.commandCenterOpen, + help: { + id: "quick-open-agent", + section: "global", + label: "Open sidebar agent shortcut", + keys: ["alt", "1-9"], + when: (context) => !context.isTauri, + }, }, { id: "sidebar-shortcut-tauri-mod-digit", @@ -217,6 +334,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ return digit ? { digit } : null; }, when: (context) => context.isTauri && !context.commandCenterOpen, + help: { + id: "quick-open-agent", + section: "global", + label: "Open sidebar agent shortcut", + keys: ["mod", "1-9"], + when: (context) => context.isTauri, + }, }, ]; @@ -242,3 +366,58 @@ export function resolveKeyboardShortcut(input: { } return null; } + +export function buildKeyboardShortcutHelpSections( + input: KeyboardShortcutPlatformContext +): KeyboardShortcutHelpSection[] { + const seenRows = new Set(); + const rowsBySection = new Map([ + ["global", []], + ["agent-input", []], + ]); + + for (const binding of SHORTCUT_BINDINGS) { + const help = binding.help; + if (!help) { + continue; + } + if (help.when && !help.when(input)) { + continue; + } + const rowKey = `${help.section}:${help.id}`; + if (seenRows.has(rowKey)) { + continue; + } + seenRows.add(rowKey); + + const rows = rowsBySection.get(help.section); + if (!rows) { + continue; + } + rows.push({ + id: help.id, + label: help.label, + keys: help.keys, + ...(help.note ? { note: help.note } : {}), + }); + } + + const sectionOrder: KeyboardShortcutHelpSection["id"][] = [ + "global", + "agent-input", + ]; + + return sectionOrder.flatMap((sectionId) => { + const rows = rowsBySection.get(sectionId) ?? []; + if (rows.length === 0) { + return []; + } + return [ + { + id: sectionId, + title: SHORTCUT_HELP_SECTION_TITLES[sectionId], + rows, + }, + ]; + }); +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 81d2bff6b..7ecf9237d 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -13,6 +13,7 @@ import * as Clipboard from "expo-clipboard"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import { useQueryClient } from "@tanstack/react-query"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue, @@ -59,7 +60,11 @@ import { extractAgentModel } from "@/utils/extract-agent-model"; import { startPerfMonitor } from "@/utils/perf-monitor"; import { shortenPath } from "@/utils/shorten-path"; import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; -import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; +import { + checkoutStatusQueryKey, + type CheckoutStatusPayload, + useCheckoutStatusQuery, +} from "@/hooks/use-checkout-status-query"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useToast } from "@/contexts/toast-context"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; @@ -67,6 +72,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; +import type { FetchAgentsEntry } from "@server/client/daemon-client"; import { DropdownMenu, DropdownMenuContent, @@ -75,6 +81,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { buildHostAgentDraftRoute } from "@/utils/host-routes"; +import type { ExplorerCheckoutContext } from "@/stores/panel-store"; const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -210,6 +217,8 @@ function AgentScreenContent({ const toast = useToast(); const insets = useSafeAreaInsets(); const router = useRouter(); + const queryClient = useQueryClient(); + const resolvedAgentId = agentId; const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); @@ -229,22 +238,100 @@ function AgentScreenContent({ const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const closeToAgent = usePanelStore((state) => state.closeToAgent); - const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); + const activateExplorerTabForCheckout = usePanelStore( + (state) => state.activateExplorerTabForCheckout + ); // Derive isExplorerOpen from the unified panel state const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - const openExplorerWithDefaultTab = useCallback(() => { - // Generic explorer toggles should land on Changes by default. - setExplorerTab("changes"); + // Select only the specific agent + const agent = useSessionStore((state) => + resolvedAgentId + ? state.sessions[serverId]?.agents?.get(resolvedAgentId) + : undefined + ); + // Checkout status for header subtitle + git fallback when cached project placement is absent + const checkoutStatusQuery = useCheckoutStatusQuery({ + serverId, + cwd: agent?.cwd ?? "", + }); + const checkout = checkoutStatusQuery.status; + const resolveCachedCheckoutIsGit = useCallback( + (params: { + agentId?: string | null; + cwd?: string | null; + projectPlacementIsGit?: boolean; + checkoutStatusIsGit?: boolean; + }): boolean | null => { + if (typeof params.projectPlacementIsGit === "boolean") { + return params.projectPlacementIsGit; + } + + const agentId = params.agentId?.trim(); + if (agentId) { + const sidebarAgents = queryClient.getQueryData<{ + entries: FetchAgentsEntry[]; + }>(["sidebarAgentsList", serverId]); + const sidebarIsGit = sidebarAgents?.entries.find( + (entry) => entry.agent.id === agentId + )?.project?.checkout?.isGit; + if (typeof sidebarIsGit === "boolean") { + return sidebarIsGit; + } + } + + const cwd = params.cwd?.trim(); + if (!cwd) { + return null; + } + const cachedCheckout = queryClient.getQueryData( + checkoutStatusQueryKey(serverId, cwd) + ); + if (typeof cachedCheckout?.isGit === "boolean") { + return cachedCheckout.isGit; + } + if (typeof params.checkoutStatusIsGit === "boolean") { + return params.checkoutStatusIsGit; + } + return null; + }, + [queryClient, serverId] + ); + const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => { + if (!resolvedAgentId) { + return null; + } + const currentAgent = useSessionStore + .getState() + .sessions[serverId] + ?.agents?.get(resolvedAgentId); + const cwd = currentAgent?.cwd?.trim(); + const isGit = resolveCachedCheckoutIsGit({ + agentId: resolvedAgentId, + cwd, + projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit, + checkoutStatusIsGit: checkout?.isGit, + }); + if (!cwd || typeof isGit !== "boolean") { + return null; + } + return { serverId, cwd, isGit }; + }, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]); + const openExplorerForActiveCheckout = useCallback(() => { + const checkoutContext = resolveCurrentExplorerCheckout(); + if (checkoutContext) { + activateExplorerTabForCheckout(checkoutContext); + } openFileExplorer(); - }, [openFileExplorer, setExplorerTab]); + }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); const handleToggleExplorer = useCallback(() => { if (isExplorerOpen) { toggleFileExplorer(); return; } - openExplorerWithDefaultTab(); - }, [isExplorerOpen, openExplorerWithDefaultTab, toggleFileExplorer]); + openExplorerForActiveCheckout(); + }, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]); const { translateX: explorerTranslateX, @@ -254,6 +341,10 @@ function AgentScreenContent({ animateToClose: animateExplorerToClose, isGesturing: isExplorerGesturing, } = useExplorerSidebarAnimation(); + const handleOpenExplorerFromGesture = useCallback(() => { + openExplorerForActiveCheckout(); + animateExplorerToOpen(); + }, [animateExplorerToOpen, openExplorerForActiveCheckout]); useEffect(() => { if (Platform.OS !== "web") { @@ -293,8 +384,7 @@ function AgentScreenContent({ // Open if dragged more than 1/3 of window or fast swipe left const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500; if (shouldOpen) { - animateExplorerToOpen(); - runOnJS(openExplorerWithDefaultTab)(); + runOnJS(handleOpenExplorerFromGesture)(); } else { animateExplorerToClose(); } @@ -308,9 +398,8 @@ function AgentScreenContent({ explorerWindowWidth, explorerTranslateX, explorerBackdropOpacity, - animateExplorerToOpen, animateExplorerToClose, - openExplorerWithDefaultTab, + handleOpenExplorerFromGesture, isExplorerGesturing, ] ); @@ -330,14 +419,43 @@ function AgentScreenContent({ return () => handler.remove(); }, [isExplorerOpen, closeToAgent]); - const resolvedAgentId = agentId; + const activeExplorerCheckout = useMemo(() => { + const cwd = agent?.cwd?.trim(); + const isGit = resolveCachedCheckoutIsGit({ + agentId: resolvedAgentId, + cwd, + projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit, + checkoutStatusIsGit: checkout?.isGit, + }); + if (!cwd || typeof isGit !== "boolean") { + return null; + } + return { serverId, cwd, isGit }; + }, [ + agent?.cwd, + agent?.projectPlacement?.checkout?.isGit, + resolveCachedCheckoutIsGit, + resolvedAgentId, + checkout?.isGit, + serverId, + ]); - // Select only the specific agent - const agent = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agents?.get(resolvedAgentId) - : undefined - ); + useEffect(() => { + setActiveExplorerCheckout(activeExplorerCheckout); + }, [activeExplorerCheckout, setActiveExplorerCheckout]); + + useEffect(() => { + if (!activeExplorerCheckout) { + return; + } + activateExplorerTabForCheckout(activeExplorerCheckout); + }, [activateExplorerTabForCheckout, activeExplorerCheckout]); + + useEffect(() => { + return () => { + setActiveExplorerCheckout(null); + }; + }, [setActiveExplorerCheckout]); // Select only the specific stream tail - use stable empty array to avoid infinite loop const streamItemsRaw = useSessionStore((state) => @@ -480,12 +598,7 @@ function AgentScreenContent({ }; }, [showConnectedNotice]); - // Checkout status for header subtitle - const checkoutStatusQuery = useCheckoutStatusQuery({ - serverId, - cwd: agent?.cwd ?? "", - }); - const checkout = checkoutStatusQuery.status; + const isGitCheckout = activeExplorerCheckout?.isGit ?? false; useEffect(() => { if (!resolvedAgentId) { @@ -1105,7 +1218,12 @@ function AgentScreenContent({ {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} {!isMobile && isExplorerOpen && resolvedAgentId && ( - + )} ); @@ -1122,7 +1240,12 @@ function AgentScreenContent({ {/* Mobile Explorer Sidebar Overlay */} {isMobile && resolvedAgentId && ( - + )} ); diff --git a/packages/app/src/stores/explorer-tab-memory.ts b/packages/app/src/stores/explorer-tab-memory.ts new file mode 100644 index 000000000..6bc9463d1 --- /dev/null +++ b/packages/app/src/stores/explorer-tab-memory.ts @@ -0,0 +1,34 @@ +export type ExplorerTab = "changes" | "files" | "terminals"; + +export function isExplorerTab(value: unknown): value is ExplorerTab { + return value === "changes" || value === "files" || value === "terminals"; +} + +export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null { + const trimmedServerId = serverId.trim(); + const trimmedCwd = cwd.trim(); + if (!trimmedServerId || !trimmedCwd) { + return null; + } + return `${trimmedServerId}::${trimmedCwd}`; +} + +export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab { + if (!isGit && tab === "changes") { + return "files"; + } + return tab; +} + +export function resolveExplorerTabForCheckout(params: { + serverId: string; + cwd: string; + isGit: boolean; + explorerTabByCheckout: Record; +}): ExplorerTab { + const key = buildExplorerCheckoutKey(params.serverId, params.cwd); + const stored = key ? params.explorerTabByCheckout[key] : null; + const defaultTab: ExplorerTab = params.isGit ? "changes" : "files"; + const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab; + return coerceExplorerTabForCheckout(nextTab, params.isGit); +} diff --git a/packages/app/src/stores/panel-store.test.ts b/packages/app/src/stores/panel-store.test.ts new file mode 100644 index 000000000..e128b5403 --- /dev/null +++ b/packages/app/src/stores/panel-store.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + buildExplorerCheckoutKey, + resolveExplorerTabForCheckout, +} from "@/stores/explorer-tab-memory"; + +describe("panel-store explorer tab resolution", () => { + const serverId = "server-1"; + const cwd = "/tmp/repo"; + + it("defaults to changes for git checkouts", () => { + expect( + resolveExplorerTabForCheckout({ + serverId, + cwd, + isGit: true, + explorerTabByCheckout: {}, + }) + ).toBe("changes"); + }); + + it("defaults to files for non-git checkouts", () => { + expect( + resolveExplorerTabForCheckout({ + serverId, + cwd, + isGit: false, + explorerTabByCheckout: {}, + }) + ).toBe("files"); + }); + + it("restores a stored files tab for git checkouts", () => { + const key = buildExplorerCheckoutKey(serverId, cwd)!; + expect( + resolveExplorerTabForCheckout({ + serverId, + cwd, + isGit: true, + explorerTabByCheckout: { + [key]: "files", + }, + }) + ).toBe("files"); + }); + + it("restores a stored terminals tab for git checkouts", () => { + const key = buildExplorerCheckoutKey(serverId, cwd)!; + expect( + resolveExplorerTabForCheckout({ + serverId, + cwd, + isGit: true, + explorerTabByCheckout: { + [key]: "terminals", + }, + }) + ).toBe("terminals"); + }); + + it("coerces stored changes to files for non-git checkouts", () => { + const key = buildExplorerCheckoutKey(serverId, cwd)!; + expect( + resolveExplorerTabForCheckout({ + serverId, + cwd, + isGit: false, + explorerTabByCheckout: { + [key]: "changes", + }, + }) + ).toBe("files"); + }); +}); diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 8aa46f501..f94d59a8e 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -2,6 +2,14 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { Platform } from "react-native"; +import { + buildExplorerCheckoutKey, + coerceExplorerTabForCheckout, + isExplorerTab, + resolveExplorerTabForCheckout, + type ExplorerTab, +} from "./explorer-tab-memory"; +export type { ExplorerTab } from "./explorer-tab-memory"; /** * Mobile panel state machine. @@ -27,8 +35,12 @@ interface DesktopSidebarState { fileExplorerOpen: boolean; } -export type ExplorerTab = "changes" | "files" | "terminals"; export type SortOption = "name" | "modified" | "size"; +export interface ExplorerCheckoutContext { + serverId: string; + cwd: string; + isGit: boolean; +} export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400; export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; @@ -48,6 +60,8 @@ interface PanelState { // File explorer settings (shared between mobile/desktop) explorerTab: ExplorerTab; + explorerTabByCheckout: Record; + activeExplorerCheckout: ExplorerCheckoutContext | null; explorerWidth: number; explorerSortOption: SortOption; explorerFilesSplitRatio: number; @@ -61,6 +75,9 @@ interface PanelState { // File explorer settings actions setExplorerTab: (tab: ExplorerTab) => void; + setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; + activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; + setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void; setExplorerWidth: (width: number) => void; setExplorerSortOption: (option: SortOption) => void; setExplorerFilesSplitRatio: (ratio: number) => void; @@ -81,6 +98,18 @@ function clampExplorerFilesSplitRatio(ratio: number): number { return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); } +function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null { + if (!state.activeExplorerCheckout) { + return null; + } + return resolveExplorerTabForCheckout({ + serverId: state.activeExplorerCheckout.serverId, + cwd: state.activeExplorerCheckout.cwd, + isGit: state.activeExplorerCheckout.isGit, + explorerTabByCheckout: state.explorerTabByCheckout, + }); +} + const DEFAULT_DESKTOP_OPEN = Platform.OS === "web"; export const usePanelStore = create()( @@ -97,6 +126,8 @@ export const usePanelStore = create()( // File explorer defaults explorerTab: "changes", + explorerTabByCheckout: {}, + activeExplorerCheckout: null, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerSortOption: "name", explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, @@ -108,10 +139,14 @@ export const usePanelStore = create()( })), openFileExplorer: () => - set((state) => ({ - mobileView: "file-explorer", - desktop: { ...state.desktop, fileExplorerOpen: true }, - })), + set((state) => { + const resolvedTab = resolveExplorerTabFromActiveCheckout(state); + return { + mobileView: "file-explorer", + desktop: { ...state.desktop, fileExplorerOpen: true }, + ...(resolvedTab ? { explorerTab: resolvedTab } : {}), + }; + }), closeToAgent: () => set((state) => ({ @@ -142,17 +177,63 @@ export const usePanelStore = create()( toggleFileExplorer: () => set((state) => { // Mobile: toggle between agent and file-explorer - const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer"; - return { - mobileView: newMobileView, + const willOpenMobile = state.mobileView !== "file-explorer"; + const willOpenDesktop = !state.desktop.fileExplorerOpen; + const nextState: Partial = { + mobileView: willOpenMobile ? "file-explorer" : "agent", desktop: { ...state.desktop, - fileExplorerOpen: !state.desktop.fileExplorerOpen, + fileExplorerOpen: willOpenDesktop, }, }; + if (willOpenMobile || willOpenDesktop) { + const resolvedTab = resolveExplorerTabFromActiveCheckout(state); + if (resolvedTab) { + nextState.explorerTab = resolvedTab; + } + } + return nextState; }), setExplorerTab: (tab) => set({ explorerTab: tab }), + setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) => + set((state) => { + const resolvedTab = coerceExplorerTabForCheckout(tab, isGit); + const key = buildExplorerCheckoutKey(serverId, cwd); + const nextState: Partial = { explorerTab: resolvedTab }; + if (key) { + const current = state.explorerTabByCheckout[key]; + if (current !== resolvedTab) { + nextState.explorerTabByCheckout = { + ...state.explorerTabByCheckout, + [key]: resolvedTab, + }; + } + } + return nextState; + }), + activateExplorerTabForCheckout: (checkout) => + set((state) => ({ + activeExplorerCheckout: checkout, + explorerTab: resolveExplorerTabForCheckout({ + serverId: checkout.serverId, + cwd: checkout.cwd, + isGit: checkout.isGit, + explorerTabByCheckout: state.explorerTabByCheckout, + }), + })), + setActiveExplorerCheckout: (checkout) => + set((state) => { + const current = state.activeExplorerCheckout; + if ( + current?.serverId === checkout?.serverId && + current?.cwd === checkout?.cwd && + current?.isGit === checkout?.isGit + ) { + return state; + } + return { activeExplorerCheckout: checkout }; + }), setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), setExplorerFilesSplitRatio: (ratio) => @@ -164,7 +245,7 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 3, + version: 4, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => { const state = persistedState as Partial & Record; @@ -197,12 +278,29 @@ export const usePanelStore = create()( } } + if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) { + state.explorerTabByCheckout = {}; + } else { + const entries = Object.entries(state.explorerTabByCheckout as Record); + const next: Record = {}; + for (const [key, value] of entries) { + if (!isExplorerTab(value)) { + continue; + } + next[key] = value; + } + state.explorerTabByCheckout = next; + } + + state.activeExplorerCheckout = null; + return state as PanelState; }, partialize: (state) => ({ mobileView: state.mobileView, desktop: state.desktop, explorerTab: state.explorerTab, + explorerTabByCheckout: state.explorerTabByCheckout, explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption, explorerFilesSplitRatio: state.explorerFilesSplitRatio, @@ -234,10 +332,14 @@ export function usePanelState(isMobile: boolean) { toggleFileExplorer: store.toggleFileExplorer, // Explorer settings explorerTab: store.explorerTab, + explorerTabByCheckout: store.explorerTabByCheckout, explorerWidth: store.explorerWidth, explorerSortOption: store.explorerSortOption, explorerFilesSplitRatio: store.explorerFilesSplitRatio, setExplorerTab: store.setExplorerTab, + setExplorerTabForCheckout: store.setExplorerTabForCheckout, + activateExplorerTabForCheckout: store.activateExplorerTabForCheckout, + setActiveExplorerCheckout: store.setActiveExplorerCheckout, setExplorerWidth: store.setExplorerWidth, setExplorerSortOption: store.setExplorerSortOption, setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio, @@ -262,10 +364,14 @@ export function usePanelState(isMobile: boolean) { toggleFileExplorer: store.toggleFileExplorer, // Explorer settings explorerTab: store.explorerTab, + explorerTabByCheckout: store.explorerTabByCheckout, explorerWidth: store.explorerWidth, explorerSortOption: store.explorerSortOption, explorerFilesSplitRatio: store.explorerFilesSplitRatio, setExplorerTab: store.setExplorerTab, + setExplorerTabForCheckout: store.setExplorerTabForCheckout, + activateExplorerTabForCheckout: store.activateExplorerTabForCheckout, + setActiveExplorerCheckout: store.setActiveExplorerCheckout, setExplorerWidth: store.setExplorerWidth, setExplorerSortOption: store.setExplorerSortOption, setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio, diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts new file mode 100644 index 000000000..93caea2e7 --- /dev/null +++ b/packages/app/src/utils/host-routes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + parseHostAgentDraftRouteFromPathname, + parseHostAgentRouteFromPathname, +} from "./host-routes"; + +describe("parseHostAgentDraftRouteFromPathname", () => { + it("parses draft route server id", () => { + expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({ + serverId: "local", + }); + }); + + it("parses encoded server id", () => { + expect( + parseHostAgentDraftRouteFromPathname("/h/team%20host/agent") + ).toEqual({ + serverId: "team host", + }); + }); + + it("does not match agent detail routes", () => { + expect(parseHostAgentDraftRouteFromPathname("/h/local/agent/abc123")).toBeNull(); + }); +}); + +describe("parseHostAgentRouteFromPathname", () => { + it("continues parsing detail routes", () => { + expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({ + serverId: "local", + agentId: "abc123", + }); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 2974edcf9..a1aba01bd 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -12,6 +12,14 @@ function encodeSegment(value: string): string { return encodeURIComponent(value); } +function decodeSegment(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + export function parseServerIdFromPathname(pathname: string): string | null { const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/); if (!match) { @@ -21,11 +29,7 @@ export function parseServerIdFromPathname(pathname: string): string | null { if (!raw) { return null; } - try { - return trimNonEmpty(decodeURIComponent(raw)); - } catch { - return trimNonEmpty(raw); - } + return trimNonEmpty(decodeSegment(raw)); } export function parseHostAgentRouteFromPathname( @@ -41,16 +45,8 @@ export function parseHostAgentRouteFromPathname( return null; } - const decode = (value: string) => { - try { - return decodeURIComponent(value); - } catch { - return value; - } - }; - - const serverId = trimNonEmpty(decode(encodedServerId)); - const agentId = trimNonEmpty(decode(encodedAgentId)); + const serverId = trimNonEmpty(decodeSegment(encodedServerId)); + const agentId = trimNonEmpty(decodeSegment(encodedAgentId)); if (!serverId || !agentId) { return null; } @@ -58,6 +54,24 @@ export function parseHostAgentRouteFromPathname( return { serverId, agentId }; } +export function parseHostAgentDraftRouteFromPathname( + pathname: string +): { serverId: string } | null { + const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/); + if (!match) { + return null; + } + const encodedServerId = match[1]; + if (!encodedServerId) { + return null; + } + const serverId = trimNonEmpty(decodeSegment(encodedServerId)); + if (!serverId) { + return null; + } + return { serverId }; +} + export function buildHostAgentDraftRoute(serverId: string): string { const normalized = trimNonEmpty(serverId); if (!normalized) { @@ -115,4 +129,3 @@ export function mapPathnameToServer( } return `${base}/agent`; } - diff --git a/packages/app/src/utils/terminal-attach.test.ts b/packages/app/src/utils/terminal-attach.test.ts new file mode 100644 index 000000000..f5c212d73 --- /dev/null +++ b/packages/app/src/utils/terminal-attach.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + getTerminalResumeOffset, + getTerminalAttachRetryDelayMs, + isTerminalAttachRetryableError, + updateTerminalResumeOffset, + withPromiseTimeout, +} from "./terminal-attach"; + +describe("terminal-attach", () => { + it("computes bounded exponential retry delays", () => { + expect(getTerminalAttachRetryDelayMs({ attempt: 0 })).toBe(250); + expect(getTerminalAttachRetryDelayMs({ attempt: 1 })).toBe(500); + expect(getTerminalAttachRetryDelayMs({ attempt: 2 })).toBe(1_000); + expect(getTerminalAttachRetryDelayMs({ attempt: 3 })).toBe(2_000); + expect(getTerminalAttachRetryDelayMs({ attempt: 8 })).toBe(2_000); + }); + + it("matches retryable attach errors", () => { + expect( + isTerminalAttachRetryableError({ message: "Terminal not found while attaching" }) + ).toBe(true); + expect( + isTerminalAttachRetryableError({ message: "Network disconnected during attach" }) + ).toBe(true); + expect( + isTerminalAttachRetryableError({ message: "stream ended before ack" }) + ).toBe(true); + expect( + isTerminalAttachRetryableError({ message: "permission denied" }) + ).toBe(false); + }); + + it("reads and updates resume offsets monotonically", () => { + const offsets = new Map(); + const terminalId = "term-1"; + + expect( + getTerminalResumeOffset({ + terminalId, + resumeOffsetByTerminalId: offsets, + }) + ).toBeUndefined(); + + updateTerminalResumeOffset({ + terminalId, + offset: 8, + resumeOffsetByTerminalId: offsets, + }); + expect( + getTerminalResumeOffset({ + terminalId, + resumeOffsetByTerminalId: offsets, + }) + ).toBe(8); + + // Stale offsets must not move resume backwards. + updateTerminalResumeOffset({ + terminalId, + offset: 3, + resumeOffsetByTerminalId: offsets, + }); + expect( + getTerminalResumeOffset({ + terminalId, + resumeOffsetByTerminalId: offsets, + }) + ).toBe(8); + }); + + it("resolves before timeout when promise completes", async () => { + await expect( + withPromiseTimeout({ + promise: Promise.resolve("ok"), + timeoutMs: 50, + timeoutMessage: "timed out", + }) + ).resolves.toBe("ok"); + }); + + it("rejects when timeout wins", async () => { + await expect( + withPromiseTimeout({ + promise: new Promise(() => {}), + timeoutMs: 10, + timeoutMessage: "timed out", + }) + ).rejects.toThrow("timed out"); + }); +}); diff --git a/packages/app/src/utils/terminal-attach.ts b/packages/app/src/utils/terminal-attach.ts new file mode 100644 index 000000000..969a4c34b --- /dev/null +++ b/packages/app/src/utils/terminal-attach.ts @@ -0,0 +1,83 @@ +const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [ + "terminal not found", + "timed out", + "timeout", + "connection", + "network", + "disconnected", + "stream ended", +] as const; + +export function getTerminalResumeOffset(input: { + terminalId: string; + resumeOffsetByTerminalId: Map; +}): number | undefined { + const offset = input.resumeOffsetByTerminalId.get(input.terminalId); + if (typeof offset !== "number" || !Number.isFinite(offset)) { + return undefined; + } + + const normalizedOffset = Math.max(0, Math.floor(offset)); + return normalizedOffset; +} + +export function updateTerminalResumeOffset(input: { + terminalId: string; + offset: number; + resumeOffsetByTerminalId: Map; +}): void { + if (!Number.isFinite(input.offset)) { + return; + } + + const normalizedOffset = Math.max(0, Math.floor(input.offset)); + const previousOffset = + getTerminalResumeOffset({ + terminalId: input.terminalId, + resumeOffsetByTerminalId: input.resumeOffsetByTerminalId, + }) ?? -1; + if (normalizedOffset <= previousOffset) { + return; + } + + input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset); +} + +export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number { + const clampedAttempt = Math.max(0, input.attempt); + const exponentialDelay = 250 * (2 ** clampedAttempt); + return Math.min(2_000, exponentialDelay); +} + +export function isTerminalAttachRetryableError(input: { message: string }): boolean { + const normalized = input.message.toLowerCase(); + return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) => + normalized.includes(pattern) + ); +} + +export async function waitForDuration(input: { durationMs: number }): Promise { + await new Promise((resolve) => { + setTimeout(resolve, Math.max(0, input.durationMs)); + }); +} + +export async function withPromiseTimeout(input: { + promise: Promise; + timeoutMs: number; + timeoutMessage: string; +}): Promise { + let timeoutHandle: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(input.timeoutMessage)); + }, Math.max(0, input.timeoutMs)); + }); + try { + return await Promise.race([input.promise, timeoutPromise]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index bd01fe99a..c1585a4e3 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -6,6 +6,7 @@ import { mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolvePendingModifierDataInput, shouldInterceptDomTerminalKey, } from "./terminal-keys"; @@ -96,4 +97,41 @@ describe("terminal key helpers", () => { expect(mapTerminalDataToKey("\x03")).toBeNull(); expect(mapTerminalDataToKey("")).toBeNull(); }); + + it("clears pending modifiers when fallback input cannot map to a key", () => { + expect( + resolvePendingModifierDataInput({ + data: "hello", + pendingModifiers: { ctrl: true, shift: false, alt: false }, + }) + ).toEqual({ + mode: "raw", + clearPendingModifiers: true, + }); + }); + + it("maps pending modifier fallback to key transport when possible", () => { + expect( + resolvePendingModifierDataInput({ + data: "c", + pendingModifiers: { ctrl: true, shift: false, alt: false }, + }) + ).toEqual({ + mode: "key", + key: "c", + clearPendingModifiers: true, + }); + }); + + it("keeps raw mode unchanged when no pending modifiers exist", () => { + expect( + resolvePendingModifierDataInput({ + data: "c", + pendingModifiers: { ctrl: false, shift: false, alt: false }, + }) + ).toEqual({ + mode: "raw", + clearPendingModifiers: false, + }); + }); }); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index 2cb612f04..db3b1edb6 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -157,3 +157,38 @@ export function mapTerminalDataToKey(data: string): string | null { return null; } + +export function resolvePendingModifierDataInput(args: { + data: string; + pendingModifiers: PendingTerminalModifiers; +}): + | { + mode: "key"; + key: string; + clearPendingModifiers: true; + } + | { + mode: "raw"; + clearPendingModifiers: boolean; + } { + if (!hasPendingTerminalModifiers(args.pendingModifiers)) { + return { + mode: "raw", + clearPendingModifiers: false, + }; + } + + const mappedKey = mapTerminalDataToKey(args.data); + if (!mappedKey) { + return { + mode: "raw", + clearPendingModifiers: true, + }; + } + + return { + mode: "key", + key: mappedKey, + clearPendingModifiers: true, + }; +} diff --git a/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts b/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts index 9170a4c0a..4652f728a 100644 --- a/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts +++ b/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts @@ -28,7 +28,8 @@ describe("TerminalStreamManager", () => { manager.receiveChunk({ chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }), }); - expect(sendAck).not.toHaveBeenCalled(); + expect(sendAck).toHaveBeenCalledTimes(1); + expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 }); manager.subscribe({ streamId: 7, @@ -77,6 +78,9 @@ describe("TerminalStreamManager", () => { manager.receiveChunk({ chunk: createChunk({ streamId: 5, offset: 2, data: "C" }), }); + expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 }); + expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 }); + expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 }); const seen: string[] = []; manager.subscribe({ @@ -87,8 +91,7 @@ describe("TerminalStreamManager", () => { }); expect(seen).toEqual(["B", "C"]); - expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 }); - expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 }); + expect(sendAck).toHaveBeenCalledTimes(3); }); test("tracks explicit ack offsets and skips stale auto-acks", () => { diff --git a/packages/server/src/client/daemon-client-terminal-stream-manager.ts b/packages/server/src/client/daemon-client-terminal-stream-manager.ts index df683df81..89a32232a 100644 --- a/packages/server/src/client/daemon-client-terminal-stream-manager.ts +++ b/packages/server/src/client/daemon-client-terminal-stream-manager.ts @@ -79,6 +79,12 @@ export class TerminalStreamManager { const streamHandlers = this.handlers.get(chunk.streamId); if (!streamHandlers || streamHandlers.size === 0) { this.bufferChunk({ chunk }); + // Ack buffered chunks immediately to keep the daemon-side stream window + // moving even before a UI subscriber is attached. + this.maybeAckChunk({ + streamId: chunk.streamId, + endOffset: chunk.endOffset, + }); return; } diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 4f46e0882..06fa277dd 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -46,14 +46,21 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; -import { createWorktree } from "../../utils/worktree.js"; +import { appendTimelineItemIfAgentKnown } from "./timeline-append.js"; +import { type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { expandUserPath } from "../path-utils.js"; +import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { + createAgentWorktree, + runAsyncWorktreeBootstrap, +} from "../worktree-bootstrap.js"; export interface AgentManagementMcpOptions { agentManager: AgentManager; agentStorage: AgentStorage; + terminalManager?: TerminalManager | null; paseoHome?: string; logger: Logger; } @@ -313,12 +320,13 @@ export async function createAgentManagementMcpServer( }; let resolvedCwd = expandUserPath(cwd); + let worktreeConfig: WorktreeConfig | undefined; if (worktreeName) { if (!baseBranch) { throw new Error("baseBranch is required when creating a worktree"); } - const worktree = await createWorktree({ + const worktree = await createAgentWorktree({ branchName: worktreeName, cwd: resolvedCwd, baseBranch, @@ -326,6 +334,7 @@ export async function createAgentManagementMcpServer( paseoHome: options.paseoHome, }); resolvedCwd = worktree.worktreePath; + worktreeConfig = worktree; } const provider: AgentProvider = agentType ?? "claude"; @@ -337,6 +346,21 @@ export async function createAgentManagementMcpServer( title: normalizedTitle ?? undefined, }); + if (worktreeConfig) { + void runAsyncWorktreeBootstrap({ + agentId: snapshot.id, + worktree: worktreeConfig, + terminalManager: options.terminalManager ?? null, + appendTimelineItem: (item) => + appendTimelineItemIfAgentKnown({ + agentManager, + agentId: snapshot.id, + item, + }), + logger: childLogger, + }); + } + const trimmedPrompt = initialPrompt?.trim(); if (trimmedPrompt) { scheduleAgentMetadataGeneration({ diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index d03485365..c654e27c9 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -23,6 +23,39 @@ type McpClient = { close: () => Promise; }; +async function withTimeout( + options: { promise: Promise; timeoutMs: number; label: string } +): Promise { + let timeoutHandle: ReturnType | null = null; + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`)); + }, options.timeoutMs); + }); + try { + return await Promise.race([options.promise, timeout]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} + +async function waitForPathExists( + options: { targetPath: string; timeoutMs: number } +): Promise { + const start = Date.now(); + while (Date.now() - start < options.timeoutMs) { + if (existsSync(options.targetPath)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}` + ); +} + async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = net.createServer(); @@ -53,10 +86,13 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null { return null; } -async function waitForAgentCompletion(client: McpClient, agentId: string): Promise { - const waitResult = (await client.callTool({ +async function waitForAgentCompletion(options: { + client: McpClient; + agentId: string; +}): Promise { + const waitResult = (await options.client.callTool({ name: "wait_for_agent", - args: { agentId }, + args: { agentId: options.agentId }, })) as McpToolResult; const payload = getStructuredContent(waitResult); if (!payload) { @@ -127,7 +163,7 @@ describe("agent MCP end-to-end (offline)", () => { agentId = (payload?.agentId as string | undefined) ?? null; expect(agentId).toBeTruthy(); - await waitForAgentCompletion(client, agentId!); + await waitForAgentCompletion({ client, agentId: agentId! }); if (existsSync(filePath)) { const contents = await readFile(filePath, "utf8"); @@ -148,4 +184,115 @@ describe("agent MCP end-to-end (offline)", () => { }, 30_000 ); + + test( + "create_agent with worktree is async and boots terminals only after setup success", + async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-")); + const port = await getAvailablePort(); + + const daemonConfig: PaseoDaemonConfig = { + listen: `127.0.0.1:${port}`, + paseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + }; + + const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); + await daemon.start(); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${port}/mcp/agents`) + ); + const client = (await experimental_createMCPClient({ transport })) as McpClient; + + let agentId: string | null = null; + try { + const { execSync } = await import("node:child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"'; + await writeFile( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Dev Server", + command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', + }, + ], + }, + }), + "utf8" + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const result = (await withTimeout({ + promise: client.callTool({ + name: "create_agent", + args: { + cwd: repoRoot, + title: "MCP worktree setup terminals", + agentType: "claude", + initialMode: "bypassPermissions", + initialPrompt: "say done and stop", + worktreeName: "mcp-worktree-setup-test", + baseBranch: "main", + background: true, + }, + }), + timeoutMs: 2500, + label: "create_agent should not block on setup", + })) as McpToolResult; + + const payload = getStructuredContent(result); + agentId = (payload?.agentId as string | undefined) ?? null; + expect(agentId).toBeTruthy(); + const worktreePath = (payload?.cwd as string | undefined) ?? ""; + expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`); + expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false); + expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false); + + await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8"); + + await waitForPathExists({ + targetPath: path.join(worktreePath, "setup-done.txt"), + timeoutMs: 15000, + }); + await waitForPathExists({ + targetPath: path.join(worktreePath, "dev-terminal.txt"), + timeoutMs: 15000, + }); + } finally { + if (agentId) { + await client.callTool({ name: "kill_agent", args: { agentId } }); + } + await client.close(); + await daemon.stop(); + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + await rm(repoRoot, { recursive: true, force: true }); + } + }, + 60_000 + ); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 24cc0ef38..6b143c5c1 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,7 +28,8 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; -import { createWorktree } from "../../utils/worktree.js"; +import { appendTimelineItemIfAgentKnown } from "./timeline-append.js"; +import { type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { @@ -36,10 +37,16 @@ import type { VoiceSpeakHandler, } from "../voice-types.js"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; +import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { + createAgentWorktree, + runAsyncWorktreeBootstrap, +} from "../worktree-bootstrap.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; agentStorage: AgentStorage; + terminalManager?: TerminalManager | null; paseoHome?: string; /** * ID of the agent that is connecting to this MCP server. @@ -289,6 +296,7 @@ export async function createAgentMcpServer( const { agentManager, agentStorage, + terminalManager, callerAgentId, resolveSpeakHandler, resolveCallerContext, @@ -469,6 +477,7 @@ export async function createAgentMcpServer( let resolvedCwd: string; let resolvedMode: string | undefined; + let worktreeConfig: WorktreeConfig | undefined; if (callerAgentId) { const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args); @@ -514,7 +523,7 @@ export async function createAgentMcpServer( if (!baseBranch) { throw new Error("baseBranch is required when creating a worktree"); } - const worktree = await createWorktree({ + const worktree = await createAgentWorktree({ branchName: worktreeName, cwd: resolvedCwd, baseBranch, @@ -522,6 +531,7 @@ export async function createAgentMcpServer( paseoHome: options.paseoHome, }); resolvedCwd = worktree.worktreePath; + worktreeConfig = worktree; } resolvedMode = initialMode; @@ -542,6 +552,21 @@ export async function createAgentMcpServer( childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined ); + if (worktreeConfig) { + void runAsyncWorktreeBootstrap({ + agentId: snapshot.id, + worktree: worktreeConfig, + terminalManager: terminalManager ?? null, + appendTimelineItem: (item) => + appendTimelineItemIfAgentKnown({ + agentManager, + agentId: snapshot.id, + item, + }), + logger: childLogger, + }); + } + const trimmedPrompt = initialPrompt.trim(); scheduleAgentMetadataGeneration({ agentManager, diff --git a/packages/server/src/server/agent/timeline-append.ts b/packages/server/src/server/agent/timeline-append.ts new file mode 100644 index 000000000..f68f6b848 --- /dev/null +++ b/packages/server/src/server/agent/timeline-append.ts @@ -0,0 +1,23 @@ +import type { AgentManager } from "./agent-manager.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; + +export interface AppendTimelineItemIfAgentKnownOptions { + agentManager: AgentManager; + agentId: string; + item: AgentTimelineItem; +} + +export async function appendTimelineItemIfAgentKnown( + options: AppendTimelineItemIfAgentKnownOptions +): Promise { + try { + await options.agentManager.appendTimelineItem(options.agentId, options.item); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Unknown agent")) { + return false; + } + throw error; + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 86a8ab264..ee4f3ed29 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -306,6 +306,7 @@ export async function createPaseoDaemon( const agentMcpServer = await createAgentMcpServer({ agentManager, agentStorage, + terminalManager, paseoHome: config.paseoHome, enableVoiceTools: false, resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null, @@ -329,6 +330,7 @@ export async function createPaseoDaemon( const agentMcpServer = await createAgentMcpServer({ agentManager, agentStorage, + terminalManager, paseoHome: config.paseoHome, callerAgentId, enableVoiceTools: false, @@ -448,6 +450,7 @@ export async function createPaseoDaemon( return createAgentMcpServer({ agentManager, agentStorage, + terminalManager, paseoHome: config.paseoHome, callerAgentId, voiceOnly: true, diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 5db24dca9..cd087b4c5 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -16,18 +16,16 @@ function tmpCwd(): string { } async function withTimeout( - promise: Promise, - timeoutMs: number, - label: string + options: { promise: Promise; timeoutMs: number; label: string } ): Promise { let timeoutHandle: ReturnType | null = null; const timeout = new Promise((_, reject) => { timeoutHandle = setTimeout(() => { - reject(new Error(`Timed out after ${timeoutMs}ms (${label})`)); - }, timeoutMs); + reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`)); + }, options.timeoutMs); }); try { - return await Promise.race([promise, timeout]); + return await Promise.race([options.promise, timeout]); } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); @@ -92,6 +90,21 @@ async function waitForTimelineToolCall( ); } +async function waitForPathExists( + options: { targetPath: string; timeoutMs: number; label: string } +): Promise { + const start = Date.now(); + while (Date.now() - start < options.timeoutMs) { + if (existsSync(options.targetPath)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `Timed out after ${options.timeoutMs}ms waiting for ${options.label}: ${options.targetPath}` + ); +} + // Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -352,8 +365,8 @@ describe("daemon E2E", () => { stdio: "pipe", }); - const agent = await withTimeout( - ctx.client.createAgent({ + const agent = await withTimeout({ + promise: ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, @@ -367,9 +380,9 @@ describe("daemon E2E", () => { worktreeSlug: "async-setup-test", }, }), - 2500, - "createAgent should not block on setup" - ); + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); @@ -396,6 +409,107 @@ describe("daemon E2E", () => { 60000 ); + test( + "bootstraps configured worktree terminals after setup succeeds", + async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"'; + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Dev Server", + command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', + }, + { + command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null', + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Async Worktree Setup + Terminals Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "async-setup-terminals-test", + worktreeSlug: "async-setup-terminals-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); + expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); + + writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); + + await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_setup" && item.status === "completed", + 20000 + ); + + await waitForPathExists({ + targetPath: path.join(agent.cwd, "dev-terminal.txt"), + timeoutMs: 15000, + label: "dev terminal marker", + }); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "lint-terminal.txt"), + timeoutMs: 15000, + label: "lint terminal marker", + }); + + const list = await ctx.client.listTerminals(agent.cwd); + expect(list.error).toBeUndefined(); + expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); + expect(list.terminals.length).toBeGreaterThanOrEqual(2); + + await ctx.client.deleteAgent(agent.id); + rmSync(repoRoot, { recursive: true, force: true }); + }, + 60000 + ); + test( "reports failures via timeline tool_call without deleting the created worktree", async () => { @@ -421,7 +535,17 @@ describe("daemon E2E", () => { 'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7'; writeFileSync( path.join(repoRoot, "paseo.json"), - JSON.stringify({ worktree: { setup: [setupCommand] } }) + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Should Not Start", + command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null', + }, + ], + }, + }) ); execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", { @@ -429,8 +553,8 @@ describe("daemon E2E", () => { stdio: "pipe", }); - const agent = await withTimeout( - ctx.client.createAgent({ + const agent = await withTimeout({ + promise: ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, @@ -444,9 +568,9 @@ describe("daemon E2E", () => { worktreeSlug: "async-setup-failure-test", }, }), - 2500, - "createAgent should not block on failing setup" - ); + timeoutMs: 2500, + label: "createAgent should not block on failing setup", + }); expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); expect(existsSync(agent.cwd)).toBe(true); @@ -469,6 +593,7 @@ describe("daemon E2E", () => { ); expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); + expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined; const commands = output?.commands as any[] | undefined; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 18782bd71..f8b9228a3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ import type { } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; import { toAgentPayload } from "./agent/agent-projections.js"; +import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js"; import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js"; import { StructuredAgentResponseError, @@ -83,7 +84,6 @@ import type { AgentStreamEvent, AgentProvider, AgentPersistenceHandle, - AgentTimelineItem, } from "./agent/agent-sdk-types.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; @@ -101,18 +101,18 @@ import { import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { - createWorktree, - runWorktreeSetupCommands, - WorktreeSetupError, type WorktreeConfig, - type WorktreeSetupCommandResult, - slugify, - validateBranchSlug, - listPaseoWorktrees, - deletePaseoWorktree, - isPaseoOwnedWorktreeCwd, - resolvePaseoWorktreeRootForCwd, - } from "../utils/worktree.js"; + slugify, + validateBranchSlug, + listPaseoWorktrees, + deletePaseoWorktree, + isPaseoOwnedWorktreeCwd, + resolvePaseoWorktreeRootForCwd, +} from "../utils/worktree.js"; +import { + createAgentWorktree, + runAsyncWorktreeBootstrap, +} from "./worktree-bootstrap.js"; import { getCheckoutDiff, getCheckoutStatus, @@ -208,19 +208,22 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null { return `remote:${cleanedHost}/${cleanedPath}`; } -function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string { - const remoteKey = deriveRemoteProjectKey(remoteUrl); +function deriveProjectGroupingKey(options: { + cwd: string; + remoteUrl: string | null; +}): string { + const remoteKey = deriveRemoteProjectKey(options.remoteUrl); if (remoteKey) { return remoteKey; } const worktreeMarker = ".paseo/worktrees/"; - const idx = cwd.indexOf(worktreeMarker); + const idx = options.cwd.indexOf(worktreeMarker); if (idx !== -1) { - return cwd.slice(0, idx).replace(/\/$/, ""); + return options.cwd.slice(0, idx).replace(/\/$/, ""); } - return cwd; + return options.cwd; } function deriveProjectGroupingName(projectKey: string): string { @@ -1123,7 +1126,10 @@ export class Session { const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome }) .then((status) => this.toProjectCheckoutLite(cwd, status)) .catch(() => this.buildFallbackProjectCheckout(cwd)); - const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl); + const projectKey = deriveProjectGroupingKey({ + cwd, + remoteUrl: checkout.remoteUrl, + }); return { projectKey, projectName: deriveProjectGroupingName(projectKey), @@ -2398,7 +2404,18 @@ export class Session { } if (worktreeConfig) { - void this.runAsyncWorktreeSetup(snapshot.id, worktreeConfig); + void runAsyncWorktreeBootstrap({ + agentId: snapshot.id, + worktree: worktreeConfig, + terminalManager: this.terminalManager, + appendTimelineItem: (item) => + appendTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId: snapshot.id, + item, + }), + logger: this.sessionLogger, + }); } this.sessionLogger.info( @@ -2627,12 +2644,11 @@ export class Session { }' for branch ${targetBranch}` ); - const createdWorktree = await createWorktree({ + const createdWorktree = await createAgentWorktree({ branchName: targetBranch, cwd, baseBranch: normalized.baseBranch!, worktreeSlug: normalized.worktreeSlug ?? targetBranch, - runSetup: false, paseoHome: this.paseoHome, }); cwd = createdWorktree.worktreePath; @@ -2656,108 +2672,6 @@ export class Session { }; } - private async runAsyncWorktreeSetup( - agentId: string, - worktree: WorktreeConfig - ): Promise { - const callId = uuidv4(); - let results: WorktreeSetupCommandResult[] = []; - try { - const started = await this.safeAppendTimelineItem(agentId, { - type: "tool_call", - name: "paseo_worktree_setup", - callId, - status: "running", - detail: { - type: "unknown", - input: { - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - }, - output: null, - }, - error: null, - }); - if (!started) { - return; - } - - results = await runWorktreeSetupCommands({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - cleanupOnFailure: false, - }); - - await this.safeAppendTimelineItem(agentId, { - type: "tool_call", - name: "paseo_worktree_setup", - callId, - status: "completed", - detail: { - type: "unknown", - input: { - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - }, - output: { - worktreePath: worktree.worktreePath, - commands: results.map((result) => ({ - command: result.command, - cwd: result.cwd, - exitCode: result.exitCode, - output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), - })), - }, - }, - error: null, - }); - } catch (error: any) { - if (error instanceof WorktreeSetupError) { - results = error.results; - } - const message = error instanceof Error ? error.message : String(error); - await this.safeAppendTimelineItem(agentId, { - type: "tool_call", - name: "paseo_worktree_setup", - callId, - status: "failed", - detail: { - type: "unknown", - input: { - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - }, - output: { - worktreePath: worktree.worktreePath, - commands: results.map((result) => ({ - command: result.command, - cwd: result.cwd, - exitCode: result.exitCode, - output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), - })), - }, - }, - error: { message }, - }); - } - } - - private async safeAppendTimelineItem( - agentId: string, - item: AgentTimelineItem - ): Promise { - try { - await this.agentManager.appendTimelineItem(agentId, item); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Unknown agent")) { - return false; - } - throw error; - } - } - private async handleListProviderModelsRequest( msg: Extract ): Promise { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts new file mode 100644 index 000000000..873ebc1ed --- /dev/null +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -0,0 +1,309 @@ +import { v4 as uuidv4 } from "uuid"; +import type { Logger } from "pino"; +import type { TerminalManager } from "../terminal/terminal-manager.js"; +import { + createWorktree, + getWorktreeTerminalSpecs, + runWorktreeSetupCommands, + WorktreeSetupError, + type WorktreeConfig, + type WorktreeSetupCommandResult, +} from "../utils/worktree.js"; +import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; + +export interface WorktreeBootstrapTerminalResult { + name: string | null; + command: string; + status: "started" | "failed"; + terminalId: string | null; + error: string | null; +} + +export interface RunAsyncWorktreeBootstrapOptions { + agentId: string; + worktree: WorktreeConfig; + terminalManager: TerminalManager | null; + appendTimelineItem: (item: AgentTimelineItem) => Promise; + logger?: Logger; +} + +export interface CreateAgentWorktreeOptions { + cwd: string; + branchName: string; + baseBranch: string; + worktreeSlug: string; + paseoHome?: string; +} + +export async function createAgentWorktree( + options: CreateAgentWorktreeOptions +): Promise { + return createWorktree({ + branchName: options.branchName, + cwd: options.cwd, + baseBranch: options.baseBranch, + worktreeSlug: options.worktreeSlug, + runSetup: false, + paseoHome: options.paseoHome, + }); +} + +function buildSetupTimelineItem(input: { + callId: string; + status: "running" | "completed" | "failed"; + worktree: WorktreeConfig; + results: WorktreeSetupCommandResult[]; + errorMessage: string | null; +}): AgentTimelineItem { + const detailInput = { + worktreePath: input.worktree.worktreePath, + branchName: input.worktree.branchName, + }; + const detailOutput = { + worktreePath: input.worktree.worktreePath, + commands: input.results.map((result) => ({ + command: result.command, + cwd: result.cwd, + exitCode: result.exitCode, + output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), + })), + }; + + if (input.status === "running") { + return { + type: "tool_call", + name: "paseo_worktree_setup", + callId: input.callId, + status: "running", + detail: { + type: "unknown", + input: detailInput, + output: null, + }, + error: null, + }; + } + + if (input.status === "completed") { + return { + type: "tool_call", + name: "paseo_worktree_setup", + callId: input.callId, + status: "completed", + detail: { + type: "unknown", + input: detailInput, + output: detailOutput, + }, + error: null, + }; + } + + return { + type: "tool_call", + name: "paseo_worktree_setup", + callId: input.callId, + status: "failed", + detail: { + type: "unknown", + input: detailInput, + output: detailOutput, + }, + error: { message: input.errorMessage ?? "Worktree setup failed" }, + }; +} + +function buildTerminalTimelineItem(input: { + callId: string; + status: "running" | "completed" | "failed"; + worktree: WorktreeConfig; + results: WorktreeBootstrapTerminalResult[]; + errorMessage: string | null; +}): AgentTimelineItem { + const detailInput = { + worktreePath: input.worktree.worktreePath, + branchName: input.worktree.branchName, + }; + const detailOutput = { + worktreePath: input.worktree.worktreePath, + terminals: input.results, + }; + + if (input.status === "running") { + return { + type: "tool_call", + name: "paseo_worktree_terminals", + callId: input.callId, + status: "running", + detail: { + type: "unknown", + input: detailInput, + output: null, + }, + error: null, + }; + } + + if (input.status === "completed") { + return { + type: "tool_call", + name: "paseo_worktree_terminals", + callId: input.callId, + status: "completed", + detail: { + type: "unknown", + input: detailInput, + output: detailOutput, + }, + error: null, + }; + } + + return { + type: "tool_call", + name: "paseo_worktree_terminals", + callId: input.callId, + status: "failed", + detail: { + type: "unknown", + input: detailInput, + output: detailOutput, + }, + error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" }, + }; +} + +async function runWorktreeTerminalBootstrap( + options: RunAsyncWorktreeBootstrapOptions +): Promise { + const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath); + if (terminalSpecs.length === 0) { + return; + } + + const callId = uuidv4(); + const started = await options.appendTimelineItem( + buildTerminalTimelineItem({ + callId, + status: "running", + worktree: options.worktree, + results: [], + errorMessage: null, + }) + ); + if (!started) { + return; + } + + if (!options.terminalManager) { + await options.appendTimelineItem( + buildTerminalTimelineItem({ + callId, + status: "failed", + worktree: options.worktree, + results: [], + errorMessage: "Terminal manager not available", + }) + ); + return; + } + + const results: WorktreeBootstrapTerminalResult[] = []; + for (const spec of terminalSpecs) { + try { + const terminal = await options.terminalManager.createTerminal({ + cwd: options.worktree.worktreePath, + name: spec.name, + }); + terminal.send({ + type: "input", + data: `${spec.command}\r`, + }); + results.push({ + name: terminal.name ?? spec.name ?? null, + command: spec.command, + status: "started", + terminalId: terminal.id, + error: null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.logger?.warn( + { agentId: options.agentId, command: spec.command, err: error }, + "Failed to bootstrap worktree terminal" + ); + results.push({ + name: spec.name ?? null, + command: spec.command, + status: "failed", + terminalId: null, + error: message, + }); + } + } + + await options.appendTimelineItem( + buildTerminalTimelineItem({ + callId, + status: "completed", + worktree: options.worktree, + results, + errorMessage: null, + }) + ); +} + +export async function runAsyncWorktreeBootstrap( + options: RunAsyncWorktreeBootstrapOptions +): Promise { + const setupCallId = uuidv4(); + let setupResults: WorktreeSetupCommandResult[] = []; + + try { + const started = await options.appendTimelineItem( + buildSetupTimelineItem({ + callId: setupCallId, + status: "running", + worktree: options.worktree, + results: [], + errorMessage: null, + }) + ); + if (!started) { + return; + } + + setupResults = await runWorktreeSetupCommands({ + worktreePath: options.worktree.worktreePath, + branchName: options.worktree.branchName, + cleanupOnFailure: false, + }); + + await options.appendTimelineItem( + buildSetupTimelineItem({ + callId: setupCallId, + status: "completed", + worktree: options.worktree, + results: setupResults, + errorMessage: null, + }) + ); + } catch (error) { + if (error instanceof WorktreeSetupError) { + setupResults = error.results; + } + const message = error instanceof Error ? error.message : String(error); + await options.appendTimelineItem( + buildSetupTimelineItem({ + callId: setupCallId, + status: "failed", + worktree: options.worktree, + results: setupResults, + errorMessage: message, + }) + ); + return; + } + + await runWorktreeTerminalBootstrap(options); +} diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 8cd852ad8..9c62c09b6 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createWorktree, deletePaseoWorktree, + getWorktreeTerminalSpecs, isPaseoOwnedWorktreeCwd, listPaseoWorktrees, slugify, @@ -262,6 +263,43 @@ describe("createWorktree", () => { // Verify worktree was cleaned up expect(existsSync(expectedWorktreePath)).toBe(false); }); + + it("reads worktree terminal specs from paseo.json with optional name", async () => { + const paseoConfig = { + worktree: { + terminals: [ + { name: "Dev Server", command: "npm run dev" }, + { command: "cd packages/app && npm run dev" }, + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + + expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + { name: "Dev Server", command: "npm run dev" }, + { command: "cd packages/app && npm run dev" }, + ]); + }); + + it("filters invalid worktree terminal specs", async () => { + const paseoConfig = { + worktree: { + terminals: [ + null, + {}, + { name: " ", command: " " }, + { name: " Watch ", command: "npm run watch", cwd: "packages/app" }, + { name: 123, command: "npm run test" }, + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + + expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + { name: "Watch", command: "npm run watch" }, + { command: "npm run test" }, + ]); + }); }); describe("paseo worktree manager", () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 037536ad4..e87c41a26 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -11,6 +11,7 @@ interface PaseoConfig { worktree?: { setup?: string[]; destroy?: string[]; + terminals?: WorktreeTerminalConfig[]; }; } @@ -33,6 +34,11 @@ export type WorktreeSetupCommandResult = { exitCode: number | null; }; +export interface WorktreeTerminalConfig { + name?: string; + command: string; +} + export class WorktreeSetupError extends Error { readonly results: WorktreeSetupCommandResult[]; @@ -107,6 +113,42 @@ export function getWorktreeDestroyCommands(repoRoot: string): string[] { return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); } +export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { + const config = readPaseoConfig(repoRoot); + const terminals = config?.worktree?.terminals; + if (!Array.isArray(terminals) || terminals.length === 0) { + return []; + } + + const specs: WorktreeTerminalConfig[] = []; + for (const terminal of terminals) { + if (!terminal || typeof terminal !== "object") { + continue; + } + + const rawCommand = terminal.command; + if (typeof rawCommand !== "string") { + continue; + } + const command = rawCommand.trim(); + if (!command) { + continue; + } + + const rawName = terminal.name; + const name = typeof rawName === "string" && rawName.trim().length > 0 + ? rawName.trim() + : undefined; + + specs.push({ + ...(name ? { name } : {}), + command, + }); + } + + return specs; +} + async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv }