From 2716bd96debda550cbbaa66867e930b738aaebe3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:27:04 +0000 Subject: [PATCH 01/32] test(terminal): observe original process completion after exit (#8892) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/terminal/terminal-worker-reaper.test.ts | 10 ++++++++++ .../src/terminal/worker-terminal-manager.test.ts | 13 ++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/server/src/terminal/terminal-worker-reaper.test.ts b/packages/server/src/terminal/terminal-worker-reaper.test.ts index a20d571b7f..66315b61d3 100644 --- a/packages/server/src/terminal/terminal-worker-reaper.test.ts +++ b/packages/server/src/terminal/terminal-worker-reaper.test.ts @@ -270,6 +270,16 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); expect(isAlive(unrelated.pid!)).toBe(true); + // Completion means the recorded process is gone, even if its PID is occupied. + await expect + .poll( + async () => + !isAlive(original.pid) || + classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)).status === + "unrelated", + { timeout: 10000 }, + ) + .toBe(true); expect(existsSync(recordPath)).toBe(false); expect( classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)), diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 0bcc2acf92..759f73e639 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -867,7 +867,18 @@ it.each([false, true])( writeFileSync(exitRequestPath, ""); await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000); expect(existsSync(exitObservedPath)).toBe(true); - expect(isProcessAlive(endingPid)).toBe(false); + const originalEnding = record!.terminals.find((entry) => entry.id === ending.id)!; + // Exit delivery does not reserve the PID. Wait for the original process to + // disappear, allowing Windows to reuse its PID just as in the restart check below. + await expect + .poll( + async () => + !isProcessAlive(endingPid) || + classifyTerminalProgramProcess(originalEnding, await inspector.inspect(endingPid)) + .status === "unrelated", + { timeout: 10000 }, + ) + .toBe(true); } finally { if (recordFd !== null) closeSync(recordFd); } From 1e2ab3d59621b3cc6d42d570669ce4681c9eacdc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 13:38:10 +0700 Subject: [PATCH 02/32] Keep the first prompt on screen when a new agent starts on Android (#8898) * refactor(app): stop resetting the chat timeline when its agent id changes A mounted timeline only ever shows one agent: every host keys its panel by tab id, and a tab keeps its id across a target change only when a draft becomes the agent it created. The per-agent resets inside the timeline therefore never had a different agent to reset for. They only fired at that create handoff, where they hid and re-placed a timeline that was already on screen. Delete them: LegendList's dataKey and the placed agent id, the web viewport key, the bottom anchor's resetForAgent, the history window's agent id, and the expansion and path-opener resets in the stream view. * fix(app): keep one chat timeline across the draft-to-agent handoff A draft tab and the agent its first prompt creates rendered two different panels: DraftPanel mounted its own dock and list, and the retarget swapped it for AgentPanel's. On Android, with the LegendList timeline, the swap blanked the first turn and re-placed it. Render both from the agent panel. A draft supplies its timeline from the pending create in the create-flow store (composer/draft/timeline.ts) and its composer in the composer slot. The retarget then re-renders the same stream view with the agent's timeline, so the list instance and its measured rows stay mounted. Agent gating is unchanged. Float the compact header over draft tabs as it already floats over agent tabs. The header sat in flow above a draft and over an agent, so the list viewport grew 92 dp at the retarget and the turn moved. The handoff E2E now fails if the first row disappears, hides, or shifts down while the draft becomes the agent. * fix(app): load subagents and tasks only for an agent, not a draft tab --- .../browser/agent-message-submission.spec.ts | 24 +- .../agent-stream/bottom-anchor-controller.ts | 16 - .../app/src/agent-stream/strategy-native.tsx | 27 +- .../app/src/agent-stream/strategy-web.tsx | 12 +- .../use-stream-history-window.test.tsx | 13 +- .../agent-stream/use-stream-history-window.ts | 21 +- packages/app/src/agent-stream/view.tsx | 7 +- .../src/composer/draft/create-flow.test.ts | 11 - .../app/src/composer/draft/create-flow.ts | 43 +-- .../app/src/composer/draft/timeline.test.ts | 103 ++++++ packages/app/src/composer/draft/timeline.ts | 102 ++++++ .../src/composer/draft/workspace-tab-core.ts | 11 + .../app/src/composer/draft/workspace-tab.tsx | 322 +++++++----------- packages/app/src/panels/agent-panel.tsx | 252 +++++++++----- packages/app/src/panels/agent-tracks.tsx | 25 +- .../screens/workspace/workspace-screen.tsx | 6 +- 16 files changed, 556 insertions(+), 439 deletions(-) create mode 100644 packages/app/src/composer/draft/timeline.test.ts create mode 100644 packages/app/src/composer/draft/timeline.ts diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index dcf872f8be..24f26f1c44 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck( lastTop: initialRow.getBoundingClientRect().top, largestDownwardShift: 0, sawMissing: false, + sawHidden: false, }; const windowState = window as unknown as Record; windowState.__messageSubmissionTimelineRowStability = state; @@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck( requestAnimationFrame(checkFrame); return; } + if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) { + state.sawHidden = true; + } const top = initialRow.getBoundingClientRect().top; state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop); state.lastTop = top; @@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck( const result = await page.evaluate(() => { const windowState = window as unknown as Record; const state = windowState.__messageSubmissionTimelineRowStability as - | { active: boolean; largestDownwardShift: number; sawMissing: boolean } + | { + active: boolean; + largestDownwardShift: number; + sawMissing: boolean; + sawHidden: boolean; + } | undefined; if (!state) throw new Error("Timeline-row stability check was not started"); state.active = false; delete windowState.__messageSubmissionTimelineRowStability; - return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing }; + return { + largestDownwardShift: state.largestDownwardShift, + sawMissing: state.sawMissing, + sawHidden: state.sawHidden, + }; }); expect(result.sawMissing).toBe(false); + expect(result.sawHidden).toBe(false); expect(result.largestDownwardShift).toBeLessThanOrEqual(2); }; } @@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => { test.setTimeout(120_000); const toasts = await recordPanelToasts(page); const pending = await beginDraftCreateSubmission(page, draftCreateScenario); + // The created agent takes over the draft's timeline: the same row stays mounted and painted. + const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck( + page, + pending.userMessage, + ); await completeDraftCreateSubmission(page, draftCreateScenario, pending); + await finishTimelineRowStabilityCheck(); // A chat this client just created is current by construction; it is never out of date. await toasts.expectNeverShown("agent-updating-toast"); }); diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index 84535caaf5..f666481be8 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -65,7 +65,6 @@ interface BottomAnchorControllerDriver { pendingVerification: AttemptContext | null; blockedReason: BottomAnchorBlockedReason | null; }; - resetForAgent: () => void; applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void; requestLocalAnchor: (request: BottomAnchorLocalRequest) => void; beginUserScroll: () => void; @@ -480,17 +479,6 @@ function createBottomAnchorControllerDriver( blockedReason, }; }, - resetForAgent() { - lastRouteRequestKey = null; - pendingRequest = null; - blockedReason = null; - cancelPendingAttempt(); - stickyMeasurementRevision = 0; - lastVerifiedStickyMeasurementRevision = 0; - isUserScrollActive = false; - mode = "sticky-bottom"; - input.onModeChange("sticky-bottom"); - }, applyRouteRequest(request) { if (!request) { return; @@ -741,10 +729,6 @@ export function useBottomAnchorController(input: { }); } - useEffect(() => { - driverRef.current?.resetForAgent(); - }, [input.agentId]); - useEffect(() => { driverRef.current?.applyRouteRequest(input.routeRequest); }, [input.routeRequest]); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index f894880d23..3288e2be25 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -137,9 +137,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } = props; const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers; const listRef = useRef(null); - // LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`). - const [placedAgentId, setPlacedAgentId] = useState(null); - const isListPlaced = placedAgentId === agentId; + // LegendList places the rows once, behind a hidden reveal, when the list mounts. + const [isListPlaced, setIsListPlaced] = useState(false); const isListPlacedRef = useRef(isListPlaced); isListPlacedRef.current = isListPlaced; const streamViewportMetricsRef = useRef({ @@ -384,23 +383,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ); useEffect(() => { - streamViewportMetricsRef.current = { - containerKey: "native-virtualized", - contentHeight: 0, - viewportWidth: 0, - viewportHeight: 0, - offsetY: 0, - viewportMeasuredForKey: null, - contentMeasuredForKey: null, - }; - scrollOffsetYRef.current = 0; - isUserScrollActiveRef.current = false; - clearPendingUserScrollEnd(); - disclosureHold.cancel(); - historyStartReadyRef.current = false; - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -411,7 +393,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]); + }, [clearPendingUserScrollEnd, evaluateHistoryStart]); useEffect(() => { const handle: StreamViewportHandle = { @@ -623,7 +605,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const handleListPlaced = useStableEvent(() => { isListPlacedRef.current = true; - setPlacedAgentId(agentId); + setIsListPlaced(true); streamViewportMetricsRef.current = { ...streamViewportMetricsRef.current, contentMeasuredForKey: "native-virtualized", @@ -711,7 +693,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onContentSizeChange={handleContentSizeChange} onItemSizeChanged={handleItemSizeChanged} onReady={handleListPlaced} - dataKey={agentId} maintainVisibleContentPosition={maintainVisibleContentPosition} renderScrollComponent={renderScrollComponent} recycleItems={false} diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 904ab9c157..b511ac72dd 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -839,10 +839,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]); useEffect(() => { - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); - readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -853,7 +849,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId, readingAnchor]); + }, [evaluateHistoryStart]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -1395,11 +1391,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { return createStreamStrategy({ render: (renderInput) => ( - + ), orderTailReverse: false, orderHeadReverse: false, diff --git a/packages/app/src/agent-stream/use-stream-history-window.test.tsx b/packages/app/src/agent-stream/use-stream-history-window.test.tsx index 2d8f0b4326..1db49ecdfd 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.test.tsx +++ b/packages/app/src/agent-stream/use-stream-history-window.test.tsx @@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => { ]).flat(); const { result } = renderHook(() => - useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }), + useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }), ); expect(result.current.start).toBe(2); @@ -35,7 +35,6 @@ describe("useStreamHistoryWindow", () => { const loadRemoteOlder = vi.fn(async () => true); const { result } = renderHook(() => useStreamHistoryWindow({ - agentId: "agent-1", items: [ message("u1", "user_message"), message("a1", "assistant_message"), @@ -67,8 +66,7 @@ describe("useStreamHistoryWindow", () => { message("a2", "assistant_message"), ]; const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: items } }, ); @@ -91,8 +89,7 @@ describe("useStreamHistoryWindow", () => { message("a2", "assistant_message"), ]; const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: items } }, ); @@ -108,8 +105,7 @@ describe("useStreamHistoryWindow", () => { configurable: true, }); const { result, rerender } = renderHook( - ({ history }) => - useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }), + ({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }), { initialProps: { history: [ @@ -142,7 +138,6 @@ describe("useStreamHistoryWindow", () => { }); const { result } = renderHook(() => useStreamHistoryWindow({ - agentId: "agent-1", items: [ message("u1", "user_message"), message("a1", "assistant_message"), diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts index 340ecb4da4..cc6d5425d6 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -28,22 +28,17 @@ function findEarlierHistoryWindowStart(input: { } interface HistoryWindowBoundary { - agentId: string; boundaryItemId: string | null; initialized: boolean; } function reconcileHistoryWindow(input: { current: HistoryWindowBoundary; - agentId: string; boundaryIndex: number; initialBoundaryItemId: string | null; hasItems: boolean; }): HistoryWindowBoundary { - const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input; - if (current.agentId !== agentId) { - return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems }; - } + const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input; if (!current.initialized && hasItems) { return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true }; } @@ -54,28 +49,26 @@ function reconcileHistoryWindow(input: { } export function useStreamHistoryWindow(input: { - agentId: string; items: StreamItem[]; loadRemoteOlder: () => boolean | Promise; }) { - const { agentId, items, loadRemoteOlder } = input; + const { items, loadRemoteOlder } = input; const initialStart = useMemo( () => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }), [items], ); const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null); const [window, setWindow] = useState(() => ({ - agentId, boundaryItemId: initialBoundaryItemId, initialized: items.length > 0, })); - const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null; + const boundaryItemId = window.boundaryItemId; const boundaryIndex = useMemo( () => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)), [boundaryItemId, items], ); let start = initialStart; - if (window.agentId === agentId && window.initialized) { + if (window.initialized) { if (window.boundaryItemId === null) { start = 0; } else if (boundaryIndex >= 0) { @@ -87,13 +80,12 @@ export function useStreamHistoryWindow(input: { setWindow((current) => reconcileHistoryWindow({ current, - agentId, boundaryIndex, initialBoundaryItemId, hasItems: items.length > 0, }), ); - }, [agentId, boundaryIndex, initialBoundaryItemId, items.length]); + }, [boundaryIndex, initialBoundaryItemId, items.length]); const revealLoadedHistory = useCallback( (itemId?: string): boolean => { @@ -106,13 +98,12 @@ export function useStreamHistoryWindow(input: { return false; } setWindow({ - agentId, boundaryItemId: items[nextStart]?.id ?? null, initialized: true, }); return true; }, - [agentId, items, start], + [items, start], ); const loadOlder = useCallback(async (): Promise => { return revealLoadedHistory() || (await loadRemoteOlder()); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 15d9435fd7..ab9d3b8258 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -430,10 +430,6 @@ const AgentStreamViewComponent = forwardRef { - setExpandedInlineToolCallIds(new Set()); - setExpandedTurnHostIds(new Set()); - }, [agentId]); const isActive = useRetainedPanelActive(); const openInlineFile = useStableEvent( @@ -476,7 +472,7 @@ const AgentStreamViewComponent = forwardRef { if (!isActive) inlinePathOpener.cancel(); return () => inlinePathOpener.cancel(); - }, [inlinePathOpener, isActive, agentId]); + }, [inlinePathOpener, isActive]); const handleInlinePathPress = useStableEvent( (target: InlinePathTarget, disposition: OpenFileDisposition) => { if (isActive) void inlinePathOpener.open(target, disposition); @@ -551,7 +547,6 @@ const AgentStreamViewComponent = forwardRef { expect(result.current.isSubmitting).toBe(true); expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); - expect(result.current.submittedStreamItems).toEqual([ - { - kind: "user_message", - id: "msg-prepared", - clientMessageId: "msg-prepared", - text: "build this", - timestamp: attempt.timestamp, - images: [image], - attachments: [attachment], - }, - ]); expect(createRequest).not.toHaveBeenCalled(); await act(async () => { diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index bd4d0fe95c..28ab74d145 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -8,15 +8,8 @@ import { import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; -import { - createUserMessage, - type StreamItem, - type UserMessageImageAttachment, -} from "@/types/stream"; +import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; -import type { PendingMessageSubmission } from "@/composer/submission/model"; - -const EMPTY_STREAM_ITEMS: StreamItem[] = []; interface CreateAttempt { clientMessageId: string; @@ -153,38 +146,6 @@ export function useDraftAgentCreateFlow({ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const submittedStreamItems = useMemo(() => { - if (machine.tag !== "creating") { - return EMPTY_STREAM_ITEMS; - } - - if ( - !machine.attempt.text && - (!machine.attempt.images || machine.attempt.images.length === 0) && - (!machine.attempt.attachments || machine.attempt.attachments.length === 0) - ) { - return EMPTY_STREAM_ITEMS; - } - - return [ - createUserMessage({ - clientMessageId: machine.attempt.clientMessageId, - text: machine.attempt.text, - timestamp: machine.attempt.timestamp, - images: machine.attempt.images, - attachments: machine.attempt.attachments, - }), - ]; - }, [machine]); - const pendingMessageSubmissions = useMemo(() => { - if (machine.tag !== "creating") return []; - return [ - { - clientMessageId: machine.attempt.clientMessageId, - }, - ]; - }, [machine]); - const draftAgent = machine.tag === "creating" ? machine.draftAgent : null; const startCreateAttempt = useCallback( (attempt: CreateAttempt) => { @@ -365,8 +326,6 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, diff --git a/packages/app/src/composer/draft/timeline.test.ts b/packages/app/src/composer/draft/timeline.test.ts new file mode 100644 index 0000000000..a1344f8830 --- /dev/null +++ b/packages/app/src/composer/draft/timeline.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PendingCreateAttempt } from "@/stores/create-flow-store"; +import type { UserMessageImageAttachment } from "@/types/stream"; +import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline"; + +const image: UserMessageImageAttachment = { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-key", + createdAt: 123, +}; +const attachment = { + type: "review", + cwd: "/repo", + summary: "Review", +} as unknown as AgentAttachment; + +function pendingAttempt(overrides: Partial = {}): PendingCreateAttempt { + return { + draftId: "draft-1", + serverId: "server-1", + agentId: null, + clientMessageId: "draft-1:initial-message", + text: "build this", + timestamp: Date.parse("2026-05-25T00:00:00.000Z"), + lifecycle: "active", + images: [image], + attachments: [attachment], + ...overrides, + }; +} + +describe("resolveDraftTimeline", () => { + it("shows the sent prompt pending with the turn working", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt(), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.agent).toEqual({ + serverId: "server-1", + id: "tab-1", + status: "running", + cwd: "/repo", + capabilities: DRAFT_CAPABILITIES, + }); + expect(timeline?.streamItems).toEqual([ + { + kind: "user_message", + id: "draft-1:initial-message", + clientMessageId: "draft-1:initial-message", + text: "build this", + timestamp: new Date("2026-05-25T00:00:00.000Z"), + images: [image], + attachments: [attachment], + }, + ]); + expect(timeline?.pendingMessageSubmissions).toEqual([ + { clientMessageId: "draft-1:initial-message" }, + ]); + expect(timeline?.turnPresentation.isActive).toBe(true); + }); + + it("keeps the prompt once the agent is created, until the tab retargets", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.streamItems).toHaveLength(1); + }); + + it("shows only the working turn for a prompt without content", () => { + const timeline = resolveDraftTimeline({ + pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }), + serverId: "server-1", + tabId: "tab-1", + cwd: "/repo", + }); + + expect(timeline?.streamItems).toEqual([]); + expect(timeline?.turnPresentation.isActive).toBe(true); + }); + + it("shows no timeline before a prompt is sent, after it fails, or on another host", () => { + const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" }; + + expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull(); + expect( + resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }), + ).toBeNull(); + expect( + resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }), + ).toBeNull(); + expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/draft/timeline.ts b/packages/app/src/composer/draft/timeline.ts new file mode 100644 index 0000000000..bbc0aecbce --- /dev/null +++ b/packages/app/src/composer/draft/timeline.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { + resolveTurnPresentation, + TURN_LIVENESS_IDLE, + type TurnPresentation, +} from "@/timeline/turn-liveness"; +import { createUserMessage, type StreamItem } from "@/types/stream"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; +import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core"; + +export const DRAFT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +}; + +export interface DraftConversation { + tabId: string; + workspaceId: string; + draftId: string; + setup?: WorkspaceDraftTabSetup; +} + +/** The timeline a draft shows while its first prompt creates the agent. */ +export interface DraftTimeline { + agent: AgentScreenAgent; + streamItems: StreamItem[]; + pendingMessageSubmissions: readonly PendingMessageSubmission[]; + turnPresentation: TurnPresentation; +} + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +/** + * The sent prompt, pending, with the turn working. The created agent's timeline carries the same + * row (same client message id), so the handoff only changes who owns it. + */ +export function resolveDraftTimeline(input: { + pending: PendingCreateAttempt | undefined; + serverId: string; + tabId: string; + cwd: string | null; +}): DraftTimeline | null { + const { pending, serverId, tabId, cwd } = input; + if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) { + return null; + } + const hasContent = + pending.text.length > 0 || + (pending.images?.length ?? 0) > 0 || + (pending.attachments?.length ?? 0) > 0; + return { + agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES }, + streamItems: hasContent + ? [ + createUserMessage({ + clientMessageId: pending.clientMessageId, + text: pending.text, + timestamp: new Date(pending.timestamp), + images: pending.images, + attachments: pending.attachments, + }), + ] + : EMPTY_STREAM_ITEMS, + pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }], + turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true), + }; +} + +export function useDraftTimeline(input: { + serverId: string; + draft: DraftConversation | null; +}): DraftTimeline | null { + const { serverId, draft } = input; + const pending = useCreateFlowStore((state) => + draft ? state.pendingByDraftId[draft.draftId] : undefined, + ); + const workspaceDirectory = useWorkspaceFields( + serverId, + draft?.workspaceId ?? null, + (workspace) => workspace.workspaceDirectory, + ); + const cwd = draft + ? resolveDraftWorkingDirectory({ + workspaceDirectory: workspaceDirectory || null, + initialSetup: draft.setup ?? null, + }) + : null; + const tabId = draft?.tabId ?? ""; + return useMemo( + () => resolveDraftTimeline({ pending, serverId, tabId, cwd }), + [cwd, pending, serverId, tabId], + ); +} diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 26bf548064..89211571a2 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -1,4 +1,5 @@ import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface WorkspaceDraftAutoSubmitConfig { provider: string; @@ -50,3 +51,13 @@ export function validateDraftSubmission(input: { }); return readiness.ok ? null : (readiness.reason ?? null); } + +export function resolveDraftWorkingDirectory(input: { + workspaceDirectory: string | null; + initialSetup: WorkspaceDraftTabSetup | null; +}): string | null { + if (input.initialSetup) { + return input.initialSetup.cwd; + } + return input.workspaceDirectory; +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index c4ef45cb3f..2b99ac8c09 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,21 +1,17 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; +import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; -import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; -import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles"; -import { AgentStreamView } from "@/agent-stream/view"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; -import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; @@ -25,13 +21,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; import { encodeImages } from "@/utils/encode-images"; -import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { + resolveDraftWorkingDirectory, shouldAllowEmptyDraftText, validateDraftSubmission, } from "@/composer/draft/workspace-tab-core"; -import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; +import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; @@ -50,16 +46,6 @@ import { import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; -const EMPTY_PENDING_PERMISSIONS = new Map(); -const DRAFT_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - interface AutoSubmitConfig { provider: string; modeId: string | null; @@ -260,25 +246,15 @@ function buildDraftInitialValues(input: { }; } -function resolveDraftWorkingDirectory(input: { - workspaceDirectory: string | null; - initialSetup: WorkspaceDraftTabSetup | null; -}): string | null { - if (input.initialSetup) { - return input.initialSetup.cwd; - } - return input.workspaceDirectory; -} - -interface WorkspaceDraftAgentTabProps { +interface WorkspaceDraftComposerProps { serverId: string; workspaceId: string; tabId: string; draftId: string; initialSetup?: WorkspaceDraftTabSetup; isPaneFocused: boolean; + editingSession: ComposerEditingSession; onCreated: (snapshot: AgentSnapshotPayload) => void; - onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void; onOpenImportSheet?: () => void; } @@ -292,17 +268,21 @@ function resolveImportPillPress( return onOpenImportSheet ?? null; } -export function WorkspaceDraftAgentTab({ +/** + * Creates the agent from the draft's first prompt. The conversation panel shows that prompt in + * the timeline from the create-flow store while the agent is created. + */ +export function WorkspaceDraftComposer({ serverId, workspaceId, tabId, draftId, initialSetup = undefined, isPaneFocused, + editingSession, onCreated, - onOpenWorkspaceFile, onOpenImportSheet, -}: WorkspaceDraftAgentTabProps) { +}: WorkspaceDraftComposerProps) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -387,7 +367,6 @@ export function WorkspaceDraftAgentTab({ }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); - const editingSession = useComposerEditingSession("chat"); const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_CONTENT_WIDTH, @@ -421,82 +400,71 @@ export function WorkspaceDraftAgentTab({ [isCompactFormFactor, openInSidePane, serverId, workspaceId], ); - const { - formErrorMessage, - isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, - draftAgent, - handleCreateFromInput, - continueCreateFromAttempt, - } = useDraftAgentCreateFlow({ - draftId, - getPendingServerId: () => serverId, - initialAttempt: initialCreateAttempt, - allowEmptyText: allowsEmptyAutoSubmit, - validateBeforeSubmit: ({ text, attachments }) => { - const allowsEmptyDraftText = shouldAllowEmptyDraftText({ - allowsEmptyAutoSubmit, - attachments, - }); - return validateDraftSubmission({ - text, - allowsEmptyAutoSubmit: allowsEmptyDraftText, - composerState, - autoSubmitConfig, - workspaceDirectory: draftWorkingDirectory, - isConnected, - }); - }, - onBeforeSubmit: async () => { - await composerState.persistFormPreferences(); - if (isWeb) { - (document.activeElement as HTMLElement | null)?.blur?.(); - } - Keyboard.dismiss(); - }, - buildDraftAgent: (attempt) => - buildDraftAgentSnapshot({ - attempt, - serverId, - tabId, - workspaceDirectory: draftWorkingDirectory, - autoSubmitConfig, - composerState, - selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => { - if (pendingAutoSubmit?.agentCreation) { - const result = await pendingAutoSubmit.agentCreation.result; - return { agentId: result.id, result }; - } - return submitDraftCreateRequest({ - draftId, - attempt, - text, - images, - attachments, - cwd, - client, - workspaceDirectory: draftWorkingDirectory, - workspaceId: workspaceFields?.id ?? null, - autoSubmitConfig, - composerState, - hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), - selectModelMessage: t("workspaceSetup.errors.selectModel"), - }); - }, - onCreateSuccess: ({ result }) => { - clearDraftInput("sent"); - clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); - useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); - onCreated(result); - }, - }); - const turnPresentation = useMemo( - () => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0), - [pendingMessageSubmissions], - ); + const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } = + useDraftAgentCreateFlow({ + draftId, + getPendingServerId: () => serverId, + initialAttempt: initialCreateAttempt, + allowEmptyText: allowsEmptyAutoSubmit, + validateBeforeSubmit: ({ text, attachments }) => { + const allowsEmptyDraftText = shouldAllowEmptyDraftText({ + allowsEmptyAutoSubmit, + attachments, + }); + return validateDraftSubmission({ + text, + allowsEmptyAutoSubmit: allowsEmptyDraftText, + composerState, + autoSubmitConfig, + workspaceDirectory: draftWorkingDirectory, + isConnected, + }); + }, + onBeforeSubmit: async () => { + await composerState.persistFormPreferences(); + if (isWeb) { + (document.activeElement as HTMLElement | null)?.blur?.(); + } + Keyboard.dismiss(); + }, + buildDraftAgent: (attempt) => + buildDraftAgentSnapshot({ + attempt, + serverId, + tabId, + workspaceDirectory: draftWorkingDirectory, + autoSubmitConfig, + composerState, + selectModelMessage: t("workspaceSetup.errors.selectModel"), + }), + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, + attempt, + text, + images, + attachments, + cwd, + client, + workspaceDirectory: draftWorkingDirectory, + workspaceId: workspaceFields?.id ?? null, + autoSubmitConfig, + composerState, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + selectModelMessage: t("workspaceSetup.errors.selectModel"), + }); + }, + onCreateSuccess: ({ result }) => { + clearDraftInput("sent"); + clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); + onCreated(result); + }, + }); useAgentControlCommandCenterActions({ sourceId: `draft:${serverId}:${tabId}`, enabled: isPaneFocused && !isSubmitting, @@ -600,75 +568,48 @@ export function WorkspaceDraftAgentTab({ }), [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); - const dockContent = ( - - {isSubmitting && draftAgent ? ( - - - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - ); - return ( - - - {dockContent} - - {importPillPress && !isComposerFullscreen ? ( - - - - - - ) : null} - + + {formErrorMessage ? ( + + + {formErrorMessage} + - - + ) : null} + {importPillPress && !isComposerFullscreen ? ( + + + + + + ) : null} + + ); } @@ -682,27 +623,10 @@ const animatedStaticStyles = RNStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, + formErrorRow: { width: "100%", - backgroundColor: theme.colors.surface0, - }, - contentContainer: { - flex: 1, - }, - streamContainer: { - flex: 1, - }, - scrollView: { - flex: 1, - }, - configScrollContent: { paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[6], - }, - configSection: { - gap: theme.spacing[3], + alignItems: "center", }, importPillRow: { width: "100%", @@ -722,8 +646,10 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: theme.contentMaxWidth, flexDirection: "row", }, - errorContainer: { - marginTop: theme.spacing[2], + formError: { + width: "100%", + maxWidth: theme.contentMaxWidth, + marginBottom: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 32ad1aedbb..1373a5d283 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -78,7 +78,8 @@ import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; -import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; +import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab"; +import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline"; import { AgentTracks } from "@/panels/agent-tracks"; import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -90,14 +91,13 @@ import { useSessionStore, } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PendingPermission } from "@/types/shared"; -import type { StreamItem, TodoEntry } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; -import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -225,6 +225,29 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: () ); } +/** A draft has no agent to wait for; an agent shows its loading and error states until ready. */ +function renderConversationNonReadyView(args: { + draft: DraftConversation | null; + agentId: string | undefined; + cwd: string | null; + viewState: AgentScreenViewState; + effectiveAgent: AgentScreenAgent | null; + t: TFunction; + onRetryLoad: () => void; +}): React.ReactElement | null { + if (args.draft) { + return null; + } + const nonReadyView = renderChatAgentNonReadyView(args); + if (nonReadyView) { + return nonReadyView; + } + invariant(args.agentId, "agent id is defined when agent content is ready"); + invariant(args.effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); + invariant(args.cwd, "agent cwd is defined when agent content is ready"); + return null; +} + function renderChatAgentNonReadyView(args: { viewState: AgentScreenViewState; effectiveAgent: AgentScreenAgent | null; @@ -303,17 +326,31 @@ function storeFetchedAgentDetail(input: { return hydrated; } -function AgentPanel() { +type ConversationTarget = Extract; + +/** The agent a conversation shows; a draft has none until its first prompt creates one. */ +function resolveConversationAgentId(target: ConversationTarget): string | undefined { + return target.kind === "agent" ? target.agentId.trim() || undefined : undefined; +} + +/** + * Drafts and agents render through one chain so the timeline stays mounted when the draft's + * first prompt creates the agent and the tab retargets. The agent gates are idle for a draft. + */ +export function AgentConversationPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); - invariant(target.kind === "agent", "AgentPanel requires agent target"); + invariant( + target.kind === "agent" || target.kind === "draft", + "AgentConversationPanel requires an agent or draft target", + ); return ( @@ -321,55 +358,6 @@ function AgentPanel() { ); } -function DraftPanel() { - const { - serverId, - workspaceId, - tabId, - target, - openFileInWorkspace, - openImportSheet, - retargetCurrentTab, - } = usePaneContext(); - const { isInteractive } = usePaneFocus(); - invariant(target.kind === "draft", "DraftPanel requires draft target"); - - const handleCreated = useCallback( - (agentSnapshot: Parameters[0]) => { - const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); - const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); - retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); - }, - [retargetCurrentTab, serverId], - ); - - return ( - - ); -} - -export function AgentConversationPanel() { - const { target } = usePaneContext(); - if (target.kind === "draft") { - return ; - } - if (target.kind === "agent") { - return ; - } - invariant(false, "AgentConversationPanel requires an agent or draft target"); -} - export const agentPanelRegistration = definePanel("agent", { component: AgentConversationPanel, useDescriptor: useAgentDescriptor, @@ -444,18 +432,18 @@ type AgentLookupState = function AgentPanelContent({ serverId, workspaceId, - agentId, + target, isPaneFocused, onOpenWorkspaceFile, }: { serverId: string; workspaceId: string; - agentId: string; + target: ConversationTarget; isPaneFocused: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); - const resolvedAgentId = agentId.trim() || undefined; + const resolvedAgentId = resolveConversationAgentId(target); const resolvedServerId = serverId.trim() || undefined; const daemons = useHosts(); const runtimeServerId = resolvedServerId ?? ""; @@ -484,7 +472,7 @@ function AgentPanelContent({ : runtimeConnectionStatus; const lastConnectionError = runtimeLastError; - if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) { + if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) { return ( ; isConnected: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + const agentId = resolveConversationAgentId(target); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const projectPlacement = useStoreWithEqualityFn( @@ -641,7 +630,7 @@ function AgentPanelBody({ } : null; - if (!agent) { + if (target.kind === "agent" && !agent) { return ( @@ -655,7 +644,7 @@ function AgentPanelBody({ ; isConnected: boolean; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + const { tabId } = usePaneContext(); + const agentId = resolveConversationAgentId(target); const isPaneVisible = useRetainedPanelActive(); const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); @@ -988,17 +979,23 @@ function ChatAgentContent({ visibilityCatchUpStatus, ]); - const nonReadyView = renderChatAgentNonReadyView({ + const draft = useMemo( + () => + target.kind === "draft" + ? { tabId, workspaceId, draftId: target.draftId, setup: target.setup } + : null, + [tabId, target, workspaceId], + ); + const nonReadyView = renderConversationNonReadyView({ + draft, + agentId, + cwd: agentState.cwd, viewState, effectiveAgent, t, onRetryLoad: retryAgentLoad, }); if (nonReadyView) return nonReadyView; - invariant(agentId, "agent id is defined when agent content is ready"); - invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); - const agentCwd = agentState.cwd; - invariant(agentCwd, "agent cwd is defined when agent content is ready"); const showHistorySyncOverlay = viewState.tag === "ready" && viewState.sync.status === "catching_up" && @@ -1013,7 +1010,8 @@ function ChatAgentContent({ - state.sessions[serverId]?.agentTasks.get(agentId), - ); - const archiveFinishedSubagents = useArchiveFinishedSubagents({ - serverId, - parentAgentId: agentId, - rows: subagentRows, - }); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; const parentAgentId = useParentAgentId(serverId, agentId); // With a composer the parent is a pill above it; without one, the box takes the composer's place. @@ -1161,7 +1154,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const parentCallout = parentAgentId ? ( ) : null; - const composerSection = showsComposer ? ( + const agentComposerSection = showsComposer ? ( + ) : ( + agentComposerSection + ); const streamContent = ( {showsComposer ? null : } @@ -1191,6 +1194,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId={serverId} agentId={agentId} agent={effectiveAgent} + draft={draft} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} hasActiveComposer={hasActiveComposer} @@ -1198,16 +1202,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ onOpenWorkspaceFile={onOpenWorkspaceFile} /> - {hasActiveComposer ? ( + {hasActiveComposer && !draft ? ( ) : null} @@ -1263,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); return wrapAgentCommandProvider({ - enabled: showsComposer && hasActiveComposer, + enabled: showsComposer && hasActiveComposer && !draft, serverId, agentId, editingSession: composerEditingSession, @@ -1355,6 +1355,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ serverId, agentId, agent, + draft, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, hasActiveComposer, @@ -1364,7 +1365,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef: React.RefObject; serverId: string; agentId?: string; - agent: AgentScreenAgent; + agent: AgentScreenAgent | null; + draft: DraftConversation | null; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; hasActiveComposer: boolean; @@ -1422,7 +1424,31 @@ const AgentStreamSection = memo(function AgentStreamSection({ } return new Map(pendingPermissionList.map((permission) => [permission.key, permission])); }, [pendingPermissionList]); + // A draft's timeline exists only while its first prompt creates the agent. The created agent's + // timeline then replaces this input in the same mounted view. + const draftTimeline = useDraftTimeline({ serverId, draft }); + if (draftTimeline) { + return ( + + ); + } + if (!agent) { + return null; + } return ( [0]) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); + getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + }, + [retargetCurrentTab, serverId], + ); + + return ( + + ); +} + const AgentComposerSection = memo(function AgentComposerSection({ agentId, serverId, diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 433264eac9..ee1926565f 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; import { useSessionStore } from "@/stores/session-store"; import { - type ArchiveFinishedStatus, + useArchiveFinishedSubagents, useArchiveSubagent, useDetachSubagent, useSubagentSheetTab, - type SubagentRow, + useSubagentsForParent, } from "@/subagents"; import { ParentAgentPill } from "@/subagents/parent"; import { SubagentsTrack } from "@/subagents/track"; @@ -35,20 +35,21 @@ export const AgentTracks = memo(function AgentTracks({ workspaceId, agentId, parentAgentId, - subagentRows, - tasks, - archiveFinishedStatus, - onArchiveFinished, }: { serverId: string; workspaceId: string; agentId: string; parentAgentId: string | null; - subagentRows: SubagentRow[]; - tasks: TodoEntry[] | undefined; - archiveFinishedStatus: ArchiveFinishedStatus; - onArchiveFinished: () => void; }): ReactElement { + const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); + const tasks = useSessionStore((state): TodoEntry[] | undefined => + state.sessions[serverId]?.agentTasks.get(agentId), + ); + const archiveFinishedSubagents = useArchiveFinishedSubagents({ + serverId, + parentAgentId: agentId, + rows: subagentRows, + }); const { tabId } = usePaneContext(); const isCompact = useIsCompactFormFactor(); const openInSidePane = useSettings((settings) => settings.openInSidePane); @@ -119,8 +120,8 @@ export const AgentTracks = memo(function AgentTracks({ onOpenProviderSubagent={handleOpenProviderSubagent} sheetTabFor={sheetTabFor} onArchiveSubagent={archiveSubagent} - onArchiveFinished={onArchiveFinished} - archiveFinishedStatus={archiveFinishedStatus} + onArchiveFinished={archiveFinishedSubagents.archiveFinished} + archiveFinishedStatus={archiveFinishedSubagents.status} onDetachSubagent={canDetachSubagents ? detachSubagent : undefined} /> Date: Fri, 6 Nov 2026 14:08:09 +0700 Subject: [PATCH 03/32] fix(app): keep chat rows their size while their tab is hidden on Android (#8911) --- .../legend-list-held-row.browser.test.tsx | 21 +++++- patches/@legendapp+list+3.7.1.patch | 67 +++++++++++++------ 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx index cc52429b23..a16763f467 100644 --- a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx +++ b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx @@ -129,7 +129,8 @@ function mountList(heldRowId: string) { } return position - state.scroll; }; - return { render, scrollTo, heldRowTop }; + const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId); + return { render, scrollTo, heldRowTop, rowSize }; } it("anchors a data change on a held row whose top is above the viewport", async () => { @@ -169,3 +170,21 @@ it("anchors a size change on a held row whose top is above the viewport", async expect(list.heldRowTop()).toBe(-20); }); + +it("keeps measured row sizes while the list is not laid out", async () => { + // A hidden retained tab is display: none. Its rows still render, and a row measured there + // reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout + // event, so a 0 recorded while hidden would stay and the next row would overlap it. + const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 })); + const list = mountList("row-0"); + list.render(rows); + await wait(150); + expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]); + + host.style.display = "none"; + await wait(150); + list.render(rows.map((row) => ({ ...row }))); + await wait(150); + + expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]); +}); diff --git a/patches/@legendapp+list+3.7.1.patch b/patches/@legendapp+list+3.7.1.patch index 3a59c09550..b232494bf5 100644 --- a/patches/@legendapp+list+3.7.1.patch +++ b/patches/@legendapp+list+3.7.1.patch @@ -25,6 +25,9 @@ # 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a # data change already does, so a disclosure that only grows (a todo card) holds the cut-off # row instead of the first row in view. +# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab +# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row +# whose frame was unchanged when the tab showed again got no layout event to correct it. # # Upgrading: drop each hunk whose fix the new version ships, regenerate with # `npx patch-package @legendapp/list`, and copy this header back on top. @@ -45,7 +48,7 @@ index 983e937..970ba51 100644 * Returns a stable item type used for pooling and size estimation. */ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index eeb3fb0..b1689ad 100644 +index eeb3fb0..5fe940d 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js @@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no @@ -205,7 +208,19 @@ index eeb3fb0..b1689ad 100644 } // src/core/updateItemSizes.ts -@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif +@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) { + const result = {}; + const previousTotalSize = state.totalSize; + for (const measurement of measurements) { ++ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a ++ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed ++ // the row, and when the list showed again a row whose frame had not changed got no layout event ++ // to correct it, so the next row overlapped it. ++ if (measurement.size.width === 0 && measurement.size.height === 0) continue; + const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey); + if (ownsMeasuredItem) { + const index = state.indexByKey.get(measurement.itemKey); +@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif state.fixedItemSizeKeys.add(itemKey); } const prevSizeKnown = sizesKnown.get(itemKey); @@ -214,7 +229,7 @@ index eeb3fb0..b1689ad 100644 return 0; } const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); -@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { +@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { // src/components/Containers.native.tsx var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; @@ -225,7 +240,7 @@ index eeb3fb0..b1689ad 100644 var ContainersLayer = typedMemo(function ContainersLayer2({ children, freshDataTransitionEpoch, -@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ }) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -234,7 +249,7 @@ index eeb3fb0..b1689ad 100644 const minSize = useCommittedMinSize(); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); -@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont +@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont null ); }); @@ -249,7 +264,7 @@ index eeb3fb0..b1689ad 100644 var ListComponent = typedMemo(function ListComponent2({ canRender, freshDataTransitionEpoch, -@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({ style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style }, /* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null), @@ -258,7 +273,7 @@ index eeb3fb0..b1689ad 100644 ListEmptyComponent && getComponent(ListEmptyComponent), shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }), canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement( -@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({ stickyHeaderConfig } ), @@ -267,7 +282,7 @@ index eeb3fb0..b1689ad 100644 IS_DEV && ENABLE_DEVMODE ); }); -@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { +@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { const state = ctx.state; if (stylePaddingTop !== void 0) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; @@ -279,7 +294,7 @@ index eeb3fb0..b1689ad 100644 let prevTotalSize = peek$(ctx, "totalSize") || 0; set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); state.timeoutSetPaddingTop = setTimeout(() => { -@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded estimatedListSize, extraData, getFixedItemSize, @@ -287,7 +302,7 @@ index eeb3fb0..b1689ad 100644 getItemType, horizontal, rtl, -@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance, estimatedItemSize, @@ -296,7 +311,7 @@ index eeb3fb0..b1689ad 100644 getItemType: useWrapIfItem(getItemType), hasExternalScroll, diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index fe5f415..e61945e 100644 +index fe5f415..7f1b9dd 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs @@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no @@ -456,7 +471,19 @@ index fe5f415..e61945e 100644 } // src/core/updateItemSizes.ts -@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif +@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) { + const result = {}; + const previousTotalSize = state.totalSize; + for (const measurement of measurements) { ++ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a ++ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed ++ // the row, and when the list showed again a row whose frame had not changed got no layout event ++ // to correct it, so the next row overlapped it. ++ if (measurement.size.width === 0 && measurement.size.height === 0) continue; + const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey); + if (ownsMeasuredItem) { + const index = state.indexByKey.get(measurement.itemKey); +@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif state.fixedItemSizeKeys.add(itemKey); } const prevSizeKnown = sizesKnown.get(itemKey); @@ -465,7 +492,7 @@ index fe5f415..e61945e 100644 return 0; } const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); -@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { +@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { // src/components/Containers.native.tsx var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; @@ -476,7 +503,7 @@ index fe5f415..e61945e 100644 var ContainersLayer = typedMemo(function ContainersLayer2({ children, freshDataTransitionEpoch, -@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ }) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -485,7 +512,7 @@ index fe5f415..e61945e 100644 const minSize = useCommittedMinSize(); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); -@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont +@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont null ); }); @@ -500,7 +527,7 @@ index fe5f415..e61945e 100644 var ListComponent = typedMemo(function ListComponent2({ canRender, freshDataTransitionEpoch, -@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({ style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style }, /* @__PURE__ */ React2.createElement(ScrollAdjust, null), @@ -509,7 +536,7 @@ index fe5f415..e61945e 100644 ListEmptyComponent && getComponent(ListEmptyComponent), shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }), canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement( -@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({ stickyHeaderConfig } ), @@ -518,7 +545,7 @@ index fe5f415..e61945e 100644 IS_DEV && ENABLE_DEVMODE ); }); -@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { +@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { const state = ctx.state; if (stylePaddingTop !== void 0) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; @@ -530,7 +557,7 @@ index fe5f415..e61945e 100644 let prevTotalSize = peek$(ctx, "totalSize") || 0; set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); state.timeoutSetPaddingTop = setTimeout(() => { -@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded estimatedListSize, extraData, getFixedItemSize, @@ -538,7 +565,7 @@ index fe5f415..e61945e 100644 getItemType, horizontal, rtl, -@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance, estimatedItemSize, From e79e826e23846311c2325be2329b090b380ff98f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 14:48:10 +0700 Subject: [PATCH 04/32] Stop re-rendering every chat message on each agent update (#8916) * fix(app): keep unchanged agent fields when an agent update is accepted * fix(app): share unchanged agent fields at the store write, not in the update policy --- .../directory-sync/agent-replica.test.ts | 35 +++++++++++++++++++ .../directory-sync/internal/agent-store.ts | 25 +++++++++++-- .../agent-directory-reconciliation.test.ts | 17 +++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 648c66c590..03fc0632a9 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -390,4 +390,39 @@ describe("AgentDirectoryReplica", () => { expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); store.clearSession(serverId); }); + it("keeps unchanged agent fields when an update changes only the status", () => { + const serverId = "agent-replica-unchanged-fields"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent"); + replica.commitSnapshot([entry(payload("agent"))], []); + const before = agentInStore(); + + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" }, + project: entry(payload("agent")).project, + }); + const afterDelta = agentInStore(); + replica.commitSnapshot( + [entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })], + [], + ); + const afterSnapshot = agentInStore(); + + expect(afterDelta?.status).toBe("running"); + expect(afterSnapshot?.status).toBe("idle"); + for (const agent of [afterDelta, afterSnapshot]) { + expect(agent?.capabilities).toBe(before?.capabilities); + expect(agent?.availableModes).toBe(before?.availableModes); + expect(agent?.labels).toBe(before?.labels); + expect(agent?.projectPlacement).toBe(before?.projectPlacement); + } + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 97c8208da5..6be4b5e9a5 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -21,6 +21,26 @@ function withoutAgent(agentId: string) { }; } +/** + * Every update arrives as a whole new agent, so each nested value (capabilities, modes, labels) is + * a new object even when it did not change. Keep the stored value for every field that is equal, + * and the stored agent when nothing changed, so a reader of one field sees a new value only when + * that field changes. + */ +function keepUnchangedFields(stored: Agent, incoming: Agent): Agent { + const next = { ...incoming }; + let changed = Object.keys(stored).length !== Object.keys(next).length; + for (const key of Object.keys(next) as (keyof Agent)[]) { + if (next[key] === stored[key]) continue; + if (key in stored && equal(next[key], stored[key])) { + Object.assign(next, { [key]: stored[key] }); + } else { + changed = true; + } + } + return changed ? next : stored; +} + function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { if (!previous) return incoming; const activeTurn = @@ -59,7 +79,8 @@ export class AgentStoreProjection { let accepted = agent; useSessionStore.getState().setAgents(this.serverId, (current) => { const previous = current.get(agent.id); - accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + accepted = previous ? keepUnchangedFields(previous, winner) : winner; if (accepted === previous) return current; const next = new Map(current); next.set(agent.id, accepted); @@ -117,7 +138,7 @@ export class AgentStoreProjection { for (const [agentId, fetchedAgent] of fetched) { const existing = current.get(agentId); const merged = mergeSnapshotTurn(existing, fetchedAgent); - agents.set(agentId, existing && equal(existing, merged) ? existing : merged); + agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged); if (fetchedAgent.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index 85e311345b..35feb27cd2 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -126,6 +126,23 @@ describe("agent directory reconciliation", () => { }).toEqual({ title: "newer page", status: "running", projectName: "repo" }); }); + it("takes the project of a newer buffered upsert", () => { + const snapshotEntry = entry("agent", "idle"); + const newerProject = { ...snapshotEntry.project, projectName: "renamed project" }; + const result = reconcileAgentDirectory({ + snapshot: [snapshotEntry], + deltas: [ + { + kind: "upsert", + agent: { ...snapshot("agent", "idle"), updatedAt: "2026-07-12T11:00:00.000Z" }, + project: newerProject, + }, + ], + }); + + expect(result[0]?.project.projectName).toBe("renamed project"); + }); + it("clears a snapshot stop when a newer buffered upsert is running", () => { const result = reconcileAgentDirectory({ snapshot: [entry("agent", "idle")], From 4281372bee42e22356f684031d2bba42bfe90562 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 15:14:23 +0700 Subject: [PATCH 05/32] fix(app): keep the chat readable with the keyboard open on iOS 26+ (#8918) iOS 26 adds a scroll edge effect, a blur at each edge of every scroll view, and places it in the scroll view's own coordinates without applying transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands over the readable conversation and stays until the next scroll. The app draws its own header fade, so the app's ScrollView component class, which backs every React Native ScrollView, hides all four edge effects. Co-authored-by: David Greenspan <155814+dgreensp@users.noreply.github.com> --- .../ios/PaseoScrollViewComponentView.mm | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm b/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm index 6b9ca95435..4bd5cfe2f3 100644 --- a/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm +++ b/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm @@ -54,6 +54,28 @@ using namespace facebook::react; [[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self]; } +// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates, +// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands +// over the readable chat and stays until the next scroll (#7883). React Native hides it for +// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app +// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side +// effects, which also cover a transformed list on device. +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 + if (@available(iOS 26.0, *)) { + UIScrollView *scrollView = self.scrollView; + scrollView.topEdgeEffect.hidden = YES; + scrollView.bottomEdgeEffect.hidden = YES; + scrollView.leftEdgeEffect.hidden = YES; + scrollView.rightEdgeEffect.hidden = YES; + } +#endif + } + return self; +} + - (void)mountingTransactionWillMount:(const MountingTransaction &)transaction withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry { From 4b285a113df799b6219c71ea1ee28c7582116ade Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 15:56:09 +0700 Subject: [PATCH 06/32] Space the touch composer toolbar by what it paints (#8909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): tighten the touch composer toolbar onto the text column On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the attach + and the trailing control floated inside the text column. Touch now places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px foreground stroke, keeps label-paired glyphs at 20pt, and runs the context ring at 26pt with a 3px stroke. The row overhangs into the composer padding by each end control's ink inset (composerToolbarOverhang), so the + ink and the trailing glyph or send/stop disc sit on the text column's edges. Pointer sizing is unchanged. The folded model control now drops its thinking label before the model label truncates, and the model label truncates on a word boundary ("Ten second…") instead of leaving a space before the ellipsis. Both are decided from measured widths. The web reports layout widths rounded to whole pixels, so a tie counts as not fitting. * fix(app): measure replacement model labels instead of hiding them The folded model label and TailEllipsisText kept their measuring views mounted when the label text changed, while discarding the cached widths. A retained measuring node whose size does not change emits no new layout event on web or native, so after switching to a model whose label (or a shortened prefix of it) has the same width, the widths stayed unknown and the label stayed transparent. Keying each measurer by what it measures remounts it, so every width is measured again. Also move the label width sweep into a named helper so the test body states the expected label order. * feat(app): space the touch composer toolbar by what it paints - Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills. - Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px. - Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch. - Stop the picker row's clip from flattening the picker highlight's left edge. - Budget the density ladder on laid-out widths. * fix(app): disable the stand-in send disc and keep form model fields in their box - An empty new chat's stand-in Send is disabled until there is content, as in fullscreen. - A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins. --- docs/design.md | 8 +- .../browser/composer-control-density.spec.ts | 131 +++++++++ .../helpers/composer-control-density.ts | 189 +++++++++++++ packages/app/src/agent-controls/icons.ts | 1 + .../components/combined-model-selector.tsx | 35 ++- .../src/components/context-window-meter.tsx | 41 ++- packages/app/src/components/model-browser.tsx | 4 +- packages/app/src/components/provider-icons.ts | 2 + .../components/ui/control-geometry.test.ts | 65 +++++ .../app/src/components/ui/control-geometry.ts | 130 ++++++++- .../tail-ellipsis-text/index.browser.test.tsx | 61 +++++ .../ui/tail-ellipsis-text/index.tsx | 145 ++++++++++ .../ui/tail-ellipsis-text/resolve.test.ts | 64 +++++ .../ui/tail-ellipsis-text/resolve.ts | 47 ++++ .../src/composer/agent-controls/control.tsx | 29 +- .../app/src/composer/agent-controls/glyph.tsx | 36 +-- .../app/src/composer/agent-controls/index.tsx | 80 +++--- .../agent-controls/layout-context.tsx | 5 +- .../composer/agent-controls/layout.test.ts | 96 ++++++- .../app/src/composer/agent-controls/layout.ts | 69 ++++- .../composer/agent-controls/mode-control.tsx | 5 +- .../composer/agent-controls/model-sheet.tsx | 256 +++++++++++++++--- packages/app/src/composer/index.tsx | 189 +++++++------ packages/app/src/composer/input/input.tsx | 215 +++++++++------ .../src/composer/input/toolbar-rails.test.ts | 30 ++ .../app/src/composer/input/toolbar-rails.ts | 85 ++++++ 26 files changed, 1674 insertions(+), 344 deletions(-) create mode 100644 packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx create mode 100644 packages/app/src/components/ui/tail-ellipsis-text/index.tsx create mode 100644 packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts create mode 100644 packages/app/src/components/ui/tail-ellipsis-text/resolve.ts create mode 100644 packages/app/src/composer/input/toolbar-rails.test.ts create mode 100644 packages/app/src/composer/input/toolbar-rails.ts diff --git a/docs/design.md b/docs/design.md index e840e81bfa..2d769e4f2d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,7 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. -Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. +Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. + +Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover. + +In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size. @@ -140,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset. -The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. +The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`). Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical. diff --git a/packages/app/e2e/browser/composer-control-density.spec.ts b/packages/app/e2e/browser/composer-control-density.spec.ts index ebd9717feb..aad17cd1ae 100644 --- a/packages/app/e2e/browser/composer-control-density.spec.ts +++ b/packages/app/e2e/browser/composer-control-density.spec.ts @@ -1,8 +1,14 @@ import { test, expect } from "../support/fixtures"; import { openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { fillComposerDraft } from "../support/helpers/composer"; import { + expectComposerToolbarOnTextRails, expectNoCollapsedComposerToolbarFrame, + expectNoFrameTruncatesModelBesideThinking, + readSettledModelTriggerLabels, recordComposerToolbarFrames, + recordModelTriggerFramesFromLoad, + sweepModelTriggerLabels, } from "../support/helpers/composer-control-density"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -124,3 +130,128 @@ test.describe("Composer controls folding on a touch device", () => { } }); }); + +test.describe("Folded model control on a phone", () => { + // Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit. + test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true }); + test.describe.configure({ timeout: 180_000 }); + + test("hides the thinking label before the model label truncates", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "composer-density-labels-", + title: "Label priority chat", + model: "ten-second-stream", + }); + try { + await recordModelTriggerFramesFromLoad(page); + await openAgentRoute(page, session); + await expect( + page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }), + ).toHaveText("Low"); + await readSettledModelTriggerLabels(page); + // From the first frame on, the control never guesses: it paints both labels only once it + // has measured that they fit. + await expectNoFrameTruncatesModelBesideThinking(page); + + const sweep = await sweepModelTriggerLabels(page, { + from: 480, + to: 280, + step: 4, + height: 844, + }); + + expect(sweep.widest).toMatchObject({ + modelText: "Ten second stream", + thinkingShown: true, + modelTruncated: false, + }); + expect(sweep.thinkingHidden).toMatchObject({ + modelText: "Ten second stream", + modelTruncated: false, + }); + expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width); + // A clean tail: the label ends on a whole word and the ellipsis, never on a space. + expect(sweep.modelTruncated).toMatchObject({ + modelText: "Ten second…", + thinkingShown: false, + }); + } finally { + await session.cleanup(); + } + }); +}); + +test.describe("Composer toolbar rails on touch", () => { + test.use({ isMobile: true, hasTouch: true }); + test.describe.configure({ timeout: 180_000 }); + + for (const viewport of [ + { device: "phone", width: 390, height: 844 }, + { device: "tablet", width: 1024, height: 1366 }, + ]) { + test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({ + page, + }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + const idle = await seedMockAgentWorkspace({ + repoPrefix: "composer-rails-idle-", + title: "Rails idle chat", + }); + let running: Awaited> | null = null; + try { + await openAgentRoute(page, idle); + await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({ + timeout: 30_000, + }); + await test.step("empty composer: attach on the left, voice mode on the right", async () => { + await expectComposerToolbarOnTextRails(page); + }); + await test.step("draft: the send disc on the right", async () => { + await fillComposerDraft(page, "Refactor the composer toolbar"); + await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible(); + await expectComposerToolbarOnTextRails(page); + }); + + running = await seedMockAgentWorkspace({ + repoPrefix: "composer-rails-running-", + title: "Rails running chat", + model: "one-minute-stream", + initialPrompt: "stream for a while", + }); + await openAgentRoute(page, running); + await test.step("running agent: the stop disc on the right", async () => { + await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({ + timeout: 30_000, + }); + await expectComposerToolbarOnTextRails(page); + }); + } finally { + await idle.cleanup(); + await running?.cleanup(); + } + }); + } +}); + +test.describe("Composer trailing disc on a new chat", () => { + test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } }); + + test("an empty draft holds a disabled send disc until there is something to send", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + const send = page + .getByTestId("message-input-root") + .filter({ visible: true }) + .getByRole("button", { name: "Send message", exact: true }); + await expect(send).toBeDisabled({ timeout: 30_000 }); + await fillComposerDraft(page, "Refactor the composer toolbar"); + await expect(send).toBeEnabled(); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/composer-control-density.ts b/packages/app/e2e/support/helpers/composer-control-density.ts index ff5e53b25c..b8ebc22ab6 100644 --- a/packages/app/e2e/support/helpers/composer-control-density.ts +++ b/packages/app/e2e/support/helpers/composer-control-density.ts @@ -53,3 +53,192 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise const collapsed = frames.filter((frame) => frame.labels.some((label) => label === "")); expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]); } + +/** What the folded model control's labels show, read from the DOM. */ +export interface ModelTriggerLabelState { + thinkingShown: boolean; + modelShown: boolean; + modelText: string; + modelTruncated: boolean; +} + +declare global { + interface Window { + __modelTriggerFrames?: ModelTriggerLabelState[]; + } +} + +function readModelTriggerLabelStateInPage(): ModelTriggerLabelState { + const visible = (node: Element) => node.getClientRects().length > 0; + const model = Array.from( + document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'), + ).find(visible); + const thinking = Array.from( + document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'), + ).find(visible); + const shown = (node: Element | null): boolean => { + for (let current = node; current; current = current.parentElement) { + if (Number(getComputedStyle(current).opacity) === 0) return false; + } + return true; + }; + const modelShown = model !== undefined && shown(model); + const modelText = model?.textContent ?? ""; + return { + modelShown, + modelText, + thinkingShown: modelShown && thinking !== undefined, + modelTruncated: + model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth), + }; +} + +/** Records the folded model control's labels on every painted frame from page load. */ +export async function recordModelTriggerFramesFromLoad(page: Page): Promise { + await page.addInitScript(`(() => { + const read = ${readModelTriggerLabelStateInPage.toString()}; + const frames = []; + window.__modelTriggerFrames = frames; + const tick = () => { + frames.push(read()); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + })()`); +} + +/** The model control's labels once two reads 100ms apart agree. */ +export async function readSettledModelTriggerLabels(page: Page): Promise { + let previous = await page.evaluate(readModelTriggerLabelStateInPage); + for (;;) { + await page.waitForTimeout(100); + const next = await page.evaluate(readModelTriggerLabelStateInPage); + if (JSON.stringify(next) === JSON.stringify(previous)) return next; + previous = next; + } +} + +/** The labels at the widths where the folded model control changes what it shows. */ +export interface ModelTriggerLabelSweep { + /** The labels at the widest width. */ + widest: ModelTriggerLabelState & { width: number }; + /** The first width at which the thinking label is hidden. */ + thinkingHidden: ModelTriggerLabelState & { width: number }; + /** The first width at which the model label is truncated. */ + modelTruncated: ModelTriggerLabelState & { width: number }; +} + +/** + * Narrows the viewport step by step and records where the thinking label disappears and where the + * model label first truncates. + */ +export async function sweepModelTriggerLabels( + page: Page, + widths: { from: number; to: number; step: number; height: number }, +): Promise { + const steps: (ModelTriggerLabelState & { width: number })[] = []; + for (let width = widths.from; width >= widths.to; width -= widths.step) { + await page.setViewportSize({ width, height: widths.height }); + const labels = await readSettledModelTriggerLabels(page); + steps.push({ ...labels, width }); + if (labels.modelTruncated || !labels.modelShown) break; + } + const thinkingHidden = steps.find((step) => !step.thinkingShown); + const modelTruncated = steps.find((step) => step.modelTruncated); + if (!thinkingHidden || !modelTruncated) { + throw new Error(`the sweep never hid the thinking label and truncated the model label`); + } + return { widest: steps[0], thinkingHidden, modelTruncated }; +} + +/** No frame recorded so far painted a truncated model label beside the thinking label. */ +export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise { + const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []); + expect(frames.some((frame) => frame.modelShown)).toBe(true); + const wrong = frames + .map((frame, index) => ({ ...frame, index })) + .filter((frame) => frame.thinkingShown && frame.modelTruncated); + expect(wrong, `of ${frames.length} frames`).toEqual([]); +} + +/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */ +export interface ComposerToolbarRails { + /** Where the text column starts. */ + textLeft: number; + /** Where the toolbar's leftmost ink starts. */ + inkLeft: number; + /** How far the trailing disc sits from the composer's right edge. */ + discRightInset: number; + /** How far the trailing disc sits from the composer's bottom edge. */ + discBottomInset: number; +} + +/** + * Reads where the toolbar's ink starts against the text column and where the trailing disc sits + * in the composer's corner. A control's ink is its filled disc when it has one, otherwise its + * glyph's stroked geometry. + */ +export async function readComposerToolbarRails(page: Page): Promise { + const root = page.locator('[data-testid="message-input-root"]:visible').first(); + return root.evaluate((element) => { + const textarea = element.querySelector("textarea"); + if (!textarea) throw new Error("composer has no text input"); + const composerRect = element.getBoundingClientRect(); + const textLeft = + textarea.getBoundingClientRect().left + + Number.parseFloat(getComputedStyle(textarea).paddingLeft); + + const isFilled = (node: Element) => { + const color = getComputedStyle(node).backgroundColor; + return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color); + }; + const glyphInkLeft = (svg: SVGSVGElement) => { + let left = Number.POSITIVE_INFINITY; + for (const shape of Array.from( + svg.querySelectorAll("path, circle, rect, line, polyline"), + )) { + const matrix = shape.getScreenCTM(); + if (!matrix) continue; + const box = shape.getBBox(); + const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0"); + left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e); + } + return left; + }; + + const band = composerRect.bottom - 64; + let inkLeft = Number.POSITIVE_INFINITY; + let trailingDisc: DOMRect | null = null; + for (const control of Array.from(element.querySelectorAll('[role="button"]'))) { + const rect = control.getBoundingClientRect(); + if (rect.width === 0 || rect.top < band) continue; + const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled); + const svg = control.querySelector("svg"); + if (disc) { + const discRect = disc.getBoundingClientRect(); + inkLeft = Math.min(inkLeft, discRect.left); + if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect; + } else if (svg) { + inkLeft = Math.min(inkLeft, glyphInkLeft(svg)); + } + } + if (!trailingDisc) throw new Error("composer toolbar has no trailing disc"); + return { + textLeft, + inkLeft, + discRightInset: composerRect.right - trailingDisc.right, + discBottomInset: composerRect.bottom - trailingDisc.bottom, + }; + }); +} + +/** + * The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the + * corner, as far from the right edge as from the bottom edge. Within half a pixel. + */ +export async function expectComposerToolbarOnTextRails(page: Page): Promise { + const rails = await readComposerToolbarRails(page); + // A precision of 0 digits passes within half a pixel. + expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0); + expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0); +} diff --git a/packages/app/src/agent-controls/icons.ts b/packages/app/src/agent-controls/icons.ts index 06b2759071..8effc9831d 100644 --- a/packages/app/src/agent-controls/icons.ts +++ b/packages/app/src/agent-controls/icons.ts @@ -18,6 +18,7 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol export interface AgentControlIconProps { size: number; color: string; + strokeWidth?: number; } export type AgentControlIcon = ComponentType; diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 2f6ab0137e..cfd33227a2 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -13,7 +13,11 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { + TOOLBAR_GLYPH_SIZES, + toolbarContentSlot, + type ToolbarGlyph, +} from "@/components/ui/control-geometry"; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -61,7 +65,7 @@ interface CombinedModelSelectorProps { */ triggerFill?: boolean; toolbar?: { - glyphSize: number; + glyph: ToolbarGlyph; showCaret: boolean; }; } @@ -253,11 +257,13 @@ export function CombinedModelSelector({ chevron={toolbar?.showCaret === false ? null : undefined} > {selectedProvider.trim().length > 0 ? ( - + ) : null} @@ -293,31 +299,30 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: 0, }, trigger: { - height: TOOLBAR_CONTROL_HEIGHT, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, }, triggerHovered: { backgroundColor: theme.colors.surface2, }, - toolbarGlyph16: { - width: 16, - height: 16, + glyph: { + width: ICON_SIZE.md, + height: ICON_SIZE.md, flexShrink: 0, }, - toolbarGlyph20: { - width: 20, - height: 20, + toolbarGlyph: { + width: TOOLBAR_GLYPH_SIZES.label, + height: TOOLBAR_GLYPH_SIZES.label, flexShrink: 0, }, triggerPressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, triggerDisabled: { opacity: 0.5, @@ -329,9 +334,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, + // A custom trigger, such as a form field, owns its own box: none of the toolbar slot applies. customTriggerWrapper: { paddingHorizontal: 0, paddingVertical: 0, + marginHorizontal: 0, height: "auto", }, triggerFill: { diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 4d78ea7592..e0ed5b22a8 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,5 +1,11 @@ import { useCallback, useMemo, useState } from "react"; -import { Pressable, Text, View, useWindowDimensions } from "react-native"; +import { + Pressable, + type PressableStateCallbackType, + Text, + View, + useWindowDimensions, +} from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -9,7 +15,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { useHostReportsUsage } from "@/usage"; import type { Theme } from "@/styles/theme"; -import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry"; import { ContextWindowDetails } from "./context-window-details"; import { ContextWindowSheet } from "./context-window-sheet"; import { formatContextPercentage } from "./context-window-meter.utils"; @@ -21,8 +27,8 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - /** Optional glyph envelope for icon-toolbar alignment. */ - glyphSize?: number; + /** The ring's box and stroke when it sits in an icon toolbar. */ + toolbarRing?: ToolbarRing; } const SVG_SIZE = 14; @@ -71,7 +77,7 @@ function getProgressColor(percentage: number, theme: Theme): string { return theme.colors.foregroundMuted; } -function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { +function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) { if (showPercentage) { return { svgSize: COMPACT_SVG_SIZE, @@ -80,8 +86,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { containerStyle: styles.containerWithLabel, }; } - const resolvedSize = glyphSize ?? SVG_SIZE; - const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH; + const resolvedSize = ring?.size ?? SVG_SIZE; + const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH; return { svgSize: resolvedSize, radius: (resolvedSize - resolvedStrokeWidth) / 2, @@ -151,7 +157,7 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, - glyphSize, + toolbarRing, }: ContextWindowMeterProps) { const { t, i18n } = useTranslation(); const { width } = useWindowDimensions(); @@ -165,7 +171,7 @@ export function ContextWindowMeter({ const closeSheet = useCallback(() => setIsSheetOpen(false), []); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; - const geometry = getMeterGeometry(showPercentage, glyphSize); + const geometry = getMeterGeometry(showPercentage, toolbarRing); const context = useMemo( () => @@ -184,6 +190,10 @@ export function ContextWindowMeter({ const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; const containerStyle = geometry.containerStyle; + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [containerStyle, pressed && styles.pressed], + [containerStyle], + ); const ring = ( ({ container: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, containerWithLabel: { - height: TOOLBAR_CONTROL_HEIGHT, + height: TOOLBAR_DISC_SIZE, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], borderRadius: theme.borderRadius.full, }, + pressed: { + backgroundColor: theme.colors.surface2, + }, percentageLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index ec1a26a1d1..82edd1458d 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -216,17 +216,19 @@ export function ModelProviderGlyph({ provider, serverId, size, + strokeWidth, tone = "muted", }: { provider: string; serverId: string | null; size: number; + strokeWidth?: number; tone?: ProviderGlyphTone; }) { const Icon = useProviderIcon(provider, serverId); const color = tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; - return ; + return ; } function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index d8548cbd16..995c0876da 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -20,6 +20,8 @@ import { resolveProviderIconName } from "@/components/provider-icon-name"; export interface ProviderIconProps { size: number; color: string; + /** Honoured by outline glyphs such as the fallback; brand marks are filled and ignore it. */ + strokeWidth?: number; } export type ProviderIconComponent = ComponentType; diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 9b0ef21308..431bb0434b 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -12,10 +12,19 @@ import { resolveControlSizing, switchGeometryForLayout, getControlInteractionPhase, + TOOLBAR_CONTROL_GAP, + TOOLBAR_CONTROL_GAPS, TOOLBAR_CONTROL_HEIGHT, TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_GLYPH_SIZES, TOOLBAR_DISC_SIZE, TOOLBAR_DISC_SIZES, + TOOLBAR_GLYPH_SIZES, + TOOLBAR_RINGS, + toolbarGlyphColor, + toolbarGlyphsFor, + toolbarGlyphTone, + toolbarSlot, TOUCH_TARGET, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; @@ -109,6 +118,62 @@ describe("control sizing gate", () => { }); }); +describe("toolbar glyphs and spacing", () => { + it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => { + expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 }); + expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 }); + // A 20pt glyph's disc-sized frame reaches 8 into the gap on each side. + expect(toolbarSlot(20)).toEqual({ + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + marginHorizontal: { xs: -8, md: 0 }, + }); + }); + + it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => { + const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; + expect(toolbarGlyphsFor("touch").icon).toEqual({ + size: 20, + strokeWidth: 1.5, + tone: "foreground", + }); + expect(toolbarGlyphsFor("pointer")).toEqual({ icon: pointerGlyph, label: pointerGlyph }); + expect(TOOLBAR_GLYPH_SIZES.icon).toEqual({ xs: 20, md: 16 }); + expect(toolbarGlyphsFor("touch").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.touch); + expect(toolbarGlyphsFor("pointer").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.pointer); + // A disc's solid glyph stays at the `md` size so the stop square does not fill its disc. + expect(TOOLBAR_DISC_GLYPH_SIZES).toEqual({ touch: 20, pointer: 16 }); + }); + + it("keeps a touch glyph beside a label at the label's 20pt scale", () => { + expect(toolbarGlyphsFor("touch").label).toEqual({ + size: 20, + strokeWidth: 1.5, + tone: "foreground", + }); + expect(TOOLBAR_GLYPH_SIZES.label).toEqual({ xs: 20, md: 16 }); + }); + + it("sizes the touch context ring to the toolbar glyph's ink and keeps the pointer ring", () => { + expect(TOOLBAR_RINGS).toEqual({ + touch: { size: 18, strokeWidth: 2 }, + pointer: { size: 16, strokeWidth: 2 }, + }); + }); + + it("brightens a muted toolbar glyph under a hovering pointer", () => { + const pointer = toolbarGlyphsFor("pointer").icon; + const touch = toolbarGlyphsFor("touch").icon; + expect(toolbarGlyphTone(pointer, false)).toBe("muted"); + expect(toolbarGlyphTone(pointer, true)).toBe("foreground"); + expect(toolbarGlyphTone(touch, false)).toBe("foreground"); + const colors = { foreground: "#fafafa", foregroundMuted: "#a1a5a4" }; + const palette = { colors } as unknown as Theme; + expect(toolbarGlyphColor(palette, "foreground")).toBe("#fafafa"); + expect(toolbarGlyphColor(palette, "muted")).toBe("#a1a5a4"); + }); +}); + describe("control geometry", () => { it("keeps resting control borders transparent while preserving border geometry", () => { const geometry = createControlGeometry(theme); diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index ae552a4c78..0ec75ad6ab 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -1,5 +1,5 @@ import type { StyleProp, ViewStyle } from "react-native"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { ICON_SIZE, ICON_STROKE_WIDTH, type Theme } from "@/styles/theme"; import { bySizing, controlSizingForLayout, @@ -87,20 +87,63 @@ export const ICON_BUTTON_SIZES = { } satisfies Record<"large" | "small", SizingValue>; /** - * A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled - * pill. Touch-sized it is one touch target. + * The height of a dense toolbar row, such as the composer's. Touch-sized it is one touch target; + * its controls are disc-sized and centre in it. */ export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue; export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES); /** - * The painted disc of a filled toolbar control, such as the composer's send and stop. The - * control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so - * touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame. + * The disc of a filled toolbar control, such as the composer's send and stop, and the frame and + * highlight of every other toolbar control (`toolbarSlot`). Pointer-sized it fills the row. */ export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue; export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES); +/** + * The space between adjacent toolbar controls. Touch-sized it is the visible space between what + * the controls paint, because each control lays out at its painted width (`toolbarSlot`). + */ +export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue; +export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS); + +/** + * A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight. + * Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches + * into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square + * control. + */ +export function toolbarSlot(shapeWidth: number): { + width: LayoutValue; + height: LayoutValue; + marginHorizontal: LayoutValue; +} { + return { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + marginHorizontal: bySizing({ + touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2, + pointer: 0, + }), + }; +} + +/** + * A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized + * its padding reaches into the gaps, so its content is what the row spaces. + */ +export function toolbarContentSlot(padding: number): { + height: LayoutValue; + paddingHorizontal: number; + marginHorizontal: LayoutValue; +} { + return { + height: TOOLBAR_DISC_SIZE, + paddingHorizontal: padding, + marginHorizontal: bySizing({ touch: -padding, pointer: 0 }), + }; +} + /** * Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body * text, so each step is larger. @@ -112,6 +155,81 @@ export function iconSizeFor(size: IconSize): SizingValue { return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] }; } +/** + * The glyph a toolbar control paints. Touch-sized every toolbar glyph matches the header icons: + * 20pt at the app's 1.5px stroke, resting at `foreground`. Pointer-sized it is the `md` glyph at + * the same stroke and rests muted until hovered. + */ +export type ToolbarGlyphTone = "foreground" | "muted"; + +export interface ToolbarGlyph { + size: number; + strokeWidth: number; + /** The tone at rest; a hovering pointer brightens it to `foreground`. */ + tone: ToolbarGlyphTone; +} + +/** `icon` is an icon-only control's glyph; `label` is a glyph paired with a label. */ +export interface ToolbarGlyphs { + icon: ToolbarGlyph; + label: ToolbarGlyph; +} + +const POINTER_TOOLBAR_GLYPH: ToolbarGlyph = { + size: ICON_SIZE.md, + strokeWidth: ICON_STROKE_WIDTH, + tone: "muted", +}; + +const TOOLBAR_GLYPHS: Record = { + touch: { + icon: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" }, + label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" }, + }, + pointer: { icon: POINTER_TOOLBAR_GLYPH, label: POINTER_TOOLBAR_GLYPH }, +}; + +export function toolbarGlyphsFor(sizing: ControlSizing): ToolbarGlyphs { + return TOOLBAR_GLYPHS[sizing]; +} + +/** The slot each toolbar glyph sits in, as stylesheet values. */ +export const TOOLBAR_GLYPH_SIZES = { + icon: bySizing({ touch: TOOLBAR_GLYPHS.touch.icon.size, pointer: POINTER_TOOLBAR_GLYPH.size }), + label: bySizing({ touch: TOOLBAR_GLYPHS.touch.label.size, pointer: POINTER_TOOLBAR_GLYPH.size }), +} satisfies Record; + +/** + * The context ring in a toolbar row. A full circle fills its box while an outline glyph's ink + * sits inset, so touch-sized the ring is 18pt, the height of a 20pt glyph's ink. Its 2px stroke + * is half a pixel over the glyphs' so the thin circle does not read lighter than them. + * Pointer-sized it is the `md` glyph. + */ +export interface ToolbarRing { + size: number; + strokeWidth: number; +} + +export const TOOLBAR_RINGS = { + touch: { size: 18, strokeWidth: 2 }, + pointer: { size: ICON_SIZE.md, strokeWidth: 2 }, +} satisfies Record; + +export function toolbarGlyphTone(glyph: ToolbarGlyph, hovered: boolean): ToolbarGlyphTone { + return hovered ? "foreground" : glyph.tone; +} + +export function toolbarGlyphColor(theme: Theme, tone: ToolbarGlyphTone): string { + return tone === "foreground" ? theme.colors.foreground : theme.colors.foregroundMuted; +} + +/** + * The solid glyph inside a toolbar disc, such as the stop square. It stays at the `md` glyph + * size: at the toolbar glyph size the square would fill half its disc. Send's outline arrow + * takes the toolbar glyph. + */ +export const TOOLBAR_DISC_GLYPH_SIZES = iconSizeFor("md"); + /** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */ export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number { return CONTROL_SIZES[size][controlSizingForLayout(isCompact)]; diff --git a/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx b/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx new file mode 100644 index 0000000000..fd0706d4fd --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx @@ -0,0 +1,61 @@ +import React, { act, useMemo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { View } from "react-native"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { TailEllipsisText } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +const TEXT_STYLE = { fontSize: 16, fontFamily: "monospace" } as const; + +function LabelRow({ text, width }: { text: string; width: number }) { + const rowStyle = useMemo(() => ({ width, flexDirection: "row" as const }), [width]); + return ( + + + + ); +} + +function renderLabel(text: string, width: number): void { + act(() => root.render()); +} + +function shownLabel(): { text: string; visible: boolean } { + const label = host.querySelector('[data-testid="label"]'); + if (!label) throw new Error("label did not render"); + return { text: label.textContent ?? "", visible: getComputedStyle(label).opacity !== "0" }; +} + +it("shows a replacement label that shares its shortened prefixes with the previous one", async () => { + // Wide enough for "Ten second…" in monospace, too narrow for either full label. + const width = 12 * 10; + renderLabel("Ten second stream", width); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true })); + + renderLabel("Ten second thread", width); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true })); +}); + +it("shows a replacement label as wide as the previous one", async () => { + renderLabel("Opus 4.7", 400); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.7", visible: true })); + + renderLabel("Opus 4.8", 400); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.8", visible: true })); +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/index.tsx b/packages/app/src/components/ui/tail-ellipsis-text/index.tsx new file mode 100644 index 0000000000..d8d477def1 --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/index.tsx @@ -0,0 +1,145 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View, type LayoutChangeEvent, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { readMeasuredWidth } from "@/hooks/use-container-width"; +import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve"; + +interface MeasuredWidths { + text: string; + full?: number; + candidates: (number | undefined)[]; +} + +/** + * One line of text that shrinks with its row and, when it does not fit, ends on a whole word + * and an ellipsis ("Ten second…"), never on a space. It measures itself, so it stays invisible + * for the frame before its widths are known. + */ +export function TailEllipsisText({ + text, + style, + testID, +}: { + text: string; + /** Text styles; the component owns its layout. */ + style: StyleProp; + testID?: string; +}) { + const candidates = useMemo(() => tailEllipsisCandidates(text), [text]); + const [boxWidth, setBoxWidth] = useState(undefined); + const [widths, setWidths] = useState({ text, candidates: [] }); + const current = widths.text === text ? widths : { text, candidates: [] }; + + const onBoxLayout = useCallback((event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setBoxWidth(width); + }, []); + const measure = useCallback( + (slot: "full" | number) => (event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width === null) return; + setWidths((previous) => { + const base = previous.text === text ? previous : { text, candidates: [] }; + if (slot === "full") return { ...base, full: width }; + const next = [...base.candidates]; + next[slot] = width; + return { ...base, candidates: next }; + }); + }, + [text], + ); + + const decision = resolveTailEllipsis({ + candidates, + measurements: { boxWidth, fullWidth: current.full, candidateWidths: current.candidates }, + }); + const shownText = decision.kind === "truncated" ? decision.text : text; + + return ( + + {/* Sizes the box: as wide as the text, or as the row allows. */} + + {text} + + + + {shownText} + + + {/* Keyed by the text, so new text remounts the measurer and every width is measured again. + A retained node whose size does not change emits no new layout event. */} + + + {text} + + {candidates.map((candidate, index) => ( + + {candidate} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + // A row, so the absolute measurer below lays its text out at full width on native too. + box: { + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + }, + sizer: { + minWidth: 0, + flexShrink: 1, + opacity: 0, + }, + shown: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, + flexDirection: "row", + alignItems: "center", + }, + shownText: { + minWidth: 0, + flexShrink: 1, + }, + measuring: { + opacity: 0, + }, + measurer: { + position: "absolute", + left: 0, + top: 0, + flexDirection: "row", + alignItems: "flex-start", + opacity: 0, + }, + measured: { + flexShrink: 0, + }, +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts b/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts new file mode 100644 index 0000000000..e65ae7e4a2 --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve"; + +const candidates = tailEllipsisCandidates("Ten second stream"); +const candidateWidths = [34, 80]; + +describe("tail ellipsis", () => { + it("cuts at word boundaries, so no whitespace sits before the ellipsis", () => { + expect(candidates).toEqual(["Ten…", "Ten second…"]); + expect(tailEllipsisCandidates(" GPT-5.1 Codex ")).toEqual(["GPT-5.1…"]); + expect(tailEllipsisCandidates("claude-opus-4-5")).toEqual([]); + }); + + it("shows the full text while it fits", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 132, fullWidth: 132.3, candidateWidths }, + }), + ).toEqual({ kind: "full" }); + }); + + it("keeps the most words that fit with the ellipsis", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 98, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "truncated", text: "Ten second…" }); + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 60, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "truncated", text: "Ten…" }); + }); + + it("lets the platform cut inside a first word too long for the box", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 20, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "clipped" }); + expect( + resolveTailEllipsis({ + candidates: [], + measurements: { boxWidth: 60, fullWidth: 132, candidateWidths: [] }, + }), + ).toEqual({ kind: "clipped" }); + }); + + it("decides nothing until the widths it needs are measured", () => { + expect(resolveTailEllipsis({ candidates, measurements: { candidateWidths } })).toEqual({ + kind: "measuring", + }); + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 98, fullWidth: 132, candidateWidths: [34] }, + }), + ).toEqual({ kind: "measuring" }); + }); +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts b/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts new file mode 100644 index 0000000000..1e7bb61d1c --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts @@ -0,0 +1,47 @@ +const ELLIPSIS = "…"; + +/** + * The word-boundary truncations of `text`, shortest first: "Ten second stream" gives "Ten…" and + * "Ten second…". Cutting at a word keeps whitespace out from before the ellipsis. + */ +export function tailEllipsisCandidates(text: string): string[] { + const words = text.trim().split(/\s+/); + return words.slice(1).map((_, index) => `${words.slice(0, index + 1).join(" ")}${ELLIPSIS}`); +} + +export type TailEllipsis = + | { kind: "measuring" } + | { kind: "full" } + | { kind: "truncated"; text: string } + /** Not even the first word fits: the platform ellipsis cuts inside it, where no space can sit. */ + | { kind: "clipped" }; + +/** Widths in points, measured from the rendered text. */ +export interface TailEllipsisMeasurements { + /** The space the text may take, up to its full width. */ + boxWidth?: number; + fullWidth?: number; + /** One per `tailEllipsisCandidates` entry, in the same order. */ + candidateWidths: readonly (number | undefined)[]; +} + +/** Sub-point slack for widths the layout rounds differently from the text it measures. */ +const ROUNDING_SLACK = 0.5; + +export function resolveTailEllipsis(input: { + candidates: readonly string[]; + measurements: TailEllipsisMeasurements; +}): TailEllipsis { + const { boxWidth, fullWidth, candidateWidths } = input.measurements; + if (boxWidth === undefined || fullWidth === undefined) return { kind: "measuring" }; + if (fullWidth <= boxWidth + ROUNDING_SLACK) return { kind: "full" }; + if (input.candidates.some((_, index) => candidateWidths[index] === undefined)) { + return { kind: "measuring" }; + } + for (let index = input.candidates.length - 1; index >= 0; index -= 1) { + if ((candidateWidths[index] ?? Infinity) <= boxWidth) { + return { kind: "truncated", text: input.candidates[index] }; + } + } + return { kind: "clipped" }; +} diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index a858c64f71..c93640206a 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import type { AgentControlIcon } from "@/agent-controls/icons"; @@ -42,10 +42,13 @@ export const AgentControlTrigger = forwardRef( }, ref, ) { - const { glyphSize } = useComposerControlLayout(); + const { glyphs } = useComposerControlLayout(); + const glyphRole = showToolbarLabel ? "label" : "icon"; + const glyph = glyphs[glyphRole]; const isSheet = surface === "sheet"; - const resolvedGlyphSize = isSheet ? 16 : glyphSize; - const resolvedIconColor = iconColor ?? styles.iconColor.color; + const toolbarIconColor = + glyph.tone === "foreground" ? styles.iconColorForeground.color : styles.iconColor.color; + const resolvedIconColor = iconColor ?? (isSheet ? styles.iconColor.color : toolbarIconColor); const showValue = isSheet || showToolbarLabel; const triggerStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType) => [ @@ -73,11 +76,11 @@ export const AgentControlTrigger = forwardRef( > {isSheet ? ( - + ) : ( - - + + )} {isSheet ? ( @@ -97,18 +100,17 @@ export const AgentControlTrigger = forwardRef( const styles = StyleSheet.create((theme) => ({ toolbarControl: { - height: TOOLBAR_CONTROL_HEIGHT, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, toolbarIconOnly: { - width: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(TOUCH_ICON_SIZE.md), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", @@ -160,7 +162,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, pressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, disabled: { opacity: 0.5, @@ -168,4 +170,7 @@ const styles = StyleSheet.create((theme) => ({ iconColor: { color: theme.colors.foregroundMuted, }, + iconColorForeground: { + color: theme.colors.foreground, + }, })); diff --git a/packages/app/src/composer/agent-controls/glyph.tsx b/packages/app/src/composer/agent-controls/glyph.tsx index f7d68d38c0..7763d1c2b5 100644 --- a/packages/app/src/composer/agent-controls/glyph.tsx +++ b/packages/app/src/composer/agent-controls/glyph.tsx @@ -1,10 +1,22 @@ import type { ReactNode } from "react"; -import { StyleSheet, View } from "react-native"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { TOOLBAR_GLYPH_SIZES, type ToolbarGlyphs } from "@/components/ui/control-geometry"; -export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) { +/** + * The slot a composer toolbar control's glyph sits in; see `toolbarGlyphsFor`. `label` when the + * glyph sits beside a label, `icon` when the glyph is the whole control. + */ +export function ComposerToolbarGlyph({ + role, + children, +}: { + role: keyof ToolbarGlyphs; + children: ReactNode; +}) { return ( = 20 ? styles.touch : styles.pointer} + style={role === "label" ? styles.labelSlot : styles.iconSlot} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" pointerEvents="none" @@ -14,19 +26,9 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; ); } +const slot = { flexShrink: 0, alignItems: "center", justifyContent: "center" } as const; + const styles = StyleSheet.create({ - pointer: { - width: 16, - height: 16, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, - touch: { - width: 20, - height: 20, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, + iconSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.icon, height: TOOLBAR_GLYPH_SIZES.icon }, + labelSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.label, height: TOOLBAR_GLYPH_SIZES.label }, }); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 63111538e3..f86721fb98 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -61,7 +61,16 @@ import { resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { + controlSizingForLayout, + TOOLBAR_CONTROL_GAP, + bySizing, + toolbarGlyphColor, + type ToolbarGlyphs, + TOUCH_ICON_SIZE, + toolbarContentSlot, + toolbarSlot, +} from "@/components/ui/control-geometry"; import { DensityIcon } from "@/components/ui/density-icon"; import { readMeasuredWidth } from "@/hooks/use-container-width"; import { useToast } from "@/contexts/toast-context"; @@ -79,7 +88,10 @@ import { type ComposerControlDensity, type ComposerControlPresentation, } from "@/composer/agent-controls/layout"; -import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; +import { + ComposerControlLayoutProvider, + useComposerControlLayout, +} from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; @@ -219,10 +231,10 @@ function getFeatureIconColor( green: { 400: string }; yellow: { 400: string }; }, - foregroundMuted: string, + restColor: string, ): string { if (!isFeatureActive(feature)) { - return foregroundMuted; + return restColor; } switch (getFeatureHighlightColor(feature.id)) { @@ -233,7 +245,7 @@ function getFeatureIconColor( case "yellow": return palette.yellow[400]; default: - return foregroundMuted; + return restColor; } } @@ -585,7 +597,7 @@ function ControlledAgentControls({ const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); const layoutContextValue = useMemo( () => ({ - glyphSize: toolbarGeometry.glyphSize, + glyphs: toolbarGeometry.glyphs, presentation, }), [presentation, toolbarGeometry], @@ -799,7 +811,7 @@ function ControlledAgentControls({ renderThinkingOption={renderThinkingOption} modeControl={modeControl} presentation={presentation} - glyphSize={layoutContextValue.glyphSize} + glyphs={layoutContextValue.glyphs} activeSheet={activeSheet} handleOpenSheet={handleOpenSheet} handleCloseSheet={handleCloseSheet} @@ -838,7 +850,7 @@ function ControlledAgentControls({ handleOpenChange={handleSheetOpenChange} renderThinkingOption={renderThinkingOption} modeControl={modeControl} - glyphSize={layoutContextValue.glyphSize} + glyphs={layoutContextValue.glyphs} showLabels={presentation.showModelLabel} modelSelectorServerId={modelSelectorServerId} canSwitchProvider={Boolean(onSelectProviderAndModel)} @@ -900,7 +912,7 @@ interface DesktopAgentControlsContentProps { }) => ReactElement; modeControl?: AgentModeControlValue | null; presentation: ComposerControlPresentation; - glyphSize: number; + glyphs: ToolbarGlyphs; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; @@ -957,15 +969,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { renderThinkingOption, modeControl, presentation, - glyphSize, + glyphs, activeSheet, handleOpenSheet, handleCloseSheet, modelSelectorServerId, } = props; const modelToolbar = useMemo( - () => ({ glyphSize, showCaret: presentation.showCarets }), - [glyphSize, presentation.showCarets], + () => ({ glyph: glyphs.label, showCaret: presentation.showCarets }), + [glyphs, presentation.showCarets], ); const featuresSheetHeader = useMemo( () => ({ title: t("agentControls.features.title") }), @@ -1086,8 +1098,12 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { accessibilityLabel={t("agentControls.features.open")} testID="agent-controls-features" > - - + + void; }) => ReactElement; modeControl?: AgentModeControlValue | null; - glyphSize: number; + glyphs: ToolbarGlyphs; showLabels: boolean; modelSelectorServerId: string | null; canSwitchProvider: boolean; @@ -1202,7 +1218,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { handleOpenChange, renderThinkingOption, modeControl, - glyphSize, + glyphs, showLabels, modelSelectorServerId, canSwitchProvider, @@ -1291,7 +1307,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { onRetryProvider={onRetryModelProvider} isRetryingProvider={isRetryingModelProvider} serverId={modelSelectorServerId} - glyphSize={glyphSize} + glyphs={glyphs} showLabels={showLabels} canSwitchProvider={canSwitchProvider} > @@ -1316,6 +1332,8 @@ function DesktopFeatureItem({ onActionComplete?: () => void; }) { const { theme } = useUnistyles(); + const { glyphs } = useComposerControlLayout(); + const restColor = toolbarGlyphColor(theme, glyphs.icon.tone); const featureSelector: AgentControlSelector = `feature-${feature.id}`; const featureAnchorRef = useRef(null); @@ -1356,11 +1374,7 @@ function DesktopFeatureItem({ ({ flexShrink: 1, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, + // Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past + // what the row spaces, so the clip reaches as far. overflow: "hidden", + paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }), + marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }), }, modeBadge: { - height: TOOLBAR_CONTROL_HEIGHT, flexDirection: "row", alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, }, modelControl: { minWidth: 0, @@ -1994,8 +2007,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, modeIconBadge: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(TOUCH_ICON_SIZE.md), alignItems: "center", justifyContent: "center", paddingHorizontal: 0, @@ -2007,7 +2019,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, modeBadgePressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, disabledBadge: { opacity: 0.5, diff --git a/packages/app/src/composer/agent-controls/layout-context.tsx b/packages/app/src/composer/agent-controls/layout-context.tsx index 75b4abac80..86a9b8cbfe 100644 --- a/packages/app/src/composer/agent-controls/layout-context.tsx +++ b/packages/app/src/composer/agent-controls/layout-context.tsx @@ -1,16 +1,17 @@ import { createContext, useContext, type ReactNode } from "react"; +import { toolbarGlyphsFor, type ToolbarGlyphs } from "@/components/ui/control-geometry"; import { resolveComposerControlPresentation, type ComposerControlPresentation, } from "@/composer/agent-controls/layout"; interface ComposerControlLayoutValue { - glyphSize: number; + glyphs: ToolbarGlyphs; presentation: ComposerControlPresentation; } const DEFAULT_LAYOUT: ComposerControlLayoutValue = { - glyphSize: 16, + glyphs: toolbarGlyphsFor("pointer"), presentation: resolveComposerControlPresentation("full"), }; diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 2135068bdc..906bc0c48f 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -3,6 +3,7 @@ import { composerToolbarGeometry, resolveComposerControlDensity, resolveComposerControlPresentation, + resolveModelTriggerLabels, } from "./layout"; const POINTER = composerToolbarGeometry("pointer"); @@ -174,21 +175,29 @@ describe("composer control layout", () => { ).toBe("condensed"); }); - it("gives every toolbar control one shell and one glyph per control sizing", () => { + it("gives every toolbar control one shell and one glyph per role and control sizing", () => { + const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; expect(POINTER).toEqual({ controlSize: 28, - glyphSize: 16, + glyphs: { icon: pointerGlyph, label: pointerGlyph }, controlGap: 4, iconLabelGap: 4, labelPadding: 8, caretSize: 14, }); - expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 }); + expect(TOUCH).toMatchObject({ + controlSize: 20, + glyphs: { + icon: { size: 20, strokeWidth: 1.5, tone: "foreground" }, + label: { size: 20, strokeWidth: 1.5, tone: "foreground" }, + }, + controlGap: 20, + }); }); it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => { const input = { - availableWidth: 420, + availableWidth: 400, currentDensity: "full" as const, controls: { hasModel: true, @@ -204,8 +213,8 @@ describe("composer control layout", () => { }); describe("touch toolbar on an iPad in portrait, sidebar docked", () => { - // Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide - // with an empty draft and 224 once an attachment brings in the send button. + // Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they + // paint, 20pt apart, so icon-only thinking, mode and features need 200. const controls = { hasModel: true, hasThinking: true, @@ -217,7 +226,7 @@ describe("composer control layout", () => { it("keeps the model family label with icon-only controls on an empty draft", () => { expect( resolveComposerControlDensity({ - availableWidth: 272, + availableWidth: 280, currentDensity: "full", geometry: TOUCH, controls, @@ -225,10 +234,21 @@ describe("composer control layout", () => { ).toBe("tight"); }); - it("folds thinking, mode and features into the model control once an attachment narrows it", () => { + it("keeps the icon-only controls once an attachment brings in send", () => { expect( resolveComposerControlDensity({ - availableWidth: 224, + availableWidth: 240, + currentDensity: "tight", + geometry: TOUCH, + controls, + }), + ).toBe("tight"); + }); + + it("folds thinking, mode and features into the model control below their width", () => { + expect( + resolveComposerControlDensity({ + availableWidth: 180, currentDensity: "tight", geometry: TOUCH, controls, @@ -239,7 +259,7 @@ describe("composer control layout", () => { it("drops the model label only after the controls are folded", () => { expect( resolveComposerControlDensity({ - availableWidth: 120, + availableWidth: 64, currentDensity: "compact", geometry: TOUCH, controls, @@ -263,8 +283,58 @@ describe("composer control layout", () => { }; expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact"); - // The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits. - expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact"); - expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly"); + // The thinking label is not budgeted: it gives way before the model family label does. + expect(resolveComposerControlDensity({ ...input, availableWidth: 96 })).toBe("compact"); + expect(resolveComposerControlDensity({ ...input, availableWidth: 64 })).toBe("iconOnly"); + }); +}); + +describe("folded model control labels", () => { + it("shows the thinking label while the model label fits beside it in full", () => { + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 191, fullWidth: 190 }, + }), + ).toBe("modelAndThinking"); + }); + + it("hides the thinking label when rounded widths tie", () => { + // On the web, 203.14 of labels and a 203 frame both report 203. + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 203, fullWidth: 203 }, + }), + ).toBe("model"); + }); + + it("hides the thinking label before the model label would truncate", () => { + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 189.5, fullWidth: 190 }, + }), + ).toBe("model"); + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 90, fullWidth: 190 }, + }), + ).toBe("model"); + }); + + it("decides nothing until both widths are measured", () => { + expect(resolveModelTriggerLabels({ hasThinking: true, measurements: {} })).toBe("measuring"); + expect( + resolveModelTriggerLabels({ hasThinking: true, measurements: { availableWidth: 300 } }), + ).toBe("measuring"); + expect(resolveModelTriggerLabels({ hasThinking: true, measurements: { fullWidth: 120 } })).toBe( + "measuring", + ); + }); + + it("needs no measurement without a thinking label", () => { + expect(resolveModelTriggerLabels({ hasThinking: false, measurements: {} })).toBe("model"); }); }); diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index f14950231c..ec6a5b8f26 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -1,7 +1,9 @@ import { - iconSizeFor, + TOOLBAR_CONTROL_GAPS, TOOLBAR_CONTROL_SIZES, + toolbarGlyphsFor, type ControlSizing, + type ToolbarGlyphs, } from "@/components/ui/control-geometry"; /** @@ -39,23 +41,32 @@ export interface ComposerControlPresentation { showModelLabel: boolean; } +/** What each toolbar control takes up in the row, which is what the density budget sums. */ export interface ComposerToolbarGeometry { + /** An icon-only control's width in the row. */ controlSize: number; - glyphSize: number; + glyphs: ToolbarGlyphs; controlGap: number; iconLabelGap: number; + /** A labelled control's padding on each side, as far as it takes room in the row. */ labelPadding: number; caretSize: number; } -/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */ +/** + * The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a + * control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an + * icon-only control takes its glyph's width and a picker's padding takes no room. + */ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry { + const glyphs = toolbarGlyphsFor(sizing); + const isTouch = sizing === "touch"; return { - controlSize: TOOLBAR_CONTROL_SIZES[sizing], - glyphSize: iconSizeFor("md")[sizing], - controlGap: 4, + controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer, + glyphs, + controlGap: TOOLBAR_CONTROL_GAPS[sizing], iconLabelGap: 4, - labelPadding: 8, + labelPadding: isTouch ? 0 : 8, caretSize: 14, }; } @@ -63,8 +74,6 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG const DENSITY_HYSTERESIS = 12; /** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */ const MODEL_FAMILY_LABEL_WIDTH = 56; -/** A thinking label beside the folded model label, such as "Medium". */ -const THINKING_LABEL_WIDTH = 48; function normalizedFontScale(fontScale: number): number { return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1; @@ -83,7 +92,7 @@ function estimateLabelWidth(label: string, fontScale: number): number { /** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */ function labelledChrome(geometry: ComposerToolbarGeometry): number { - return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap; + return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap; } function resolveFeatureControlWidth( @@ -147,16 +156,16 @@ function resolveTightFloor( return sumControlWidths(widths, geometry); } -/** One model control carrying the model family label and the thinking label. */ +/** + * One model control carrying the model family label. Its thinking label is not budgeted: it is + * the first label to go when the control runs out of room; see `resolveModelTriggerLabels`. + */ function resolveCompactFloor( controls: ComposerControlPresence, geometry: ComposerToolbarGeometry, ): number { const fontScale = normalizedFontScale(controls.fontScale); - const thinkingLabel = controls.hasThinking - ? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale - : 0; - return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel; + return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale; } function resolveDensityFloor( @@ -251,3 +260,33 @@ export function resolveComposerControlPresentation( ): ComposerControlPresentation { return PRESENTATIONS[density]; } + +/** + * Which labels the folded model control shows. `measuring` until every width is known, so the + * control never paints a choice made without them. + */ +export type ModelTriggerLabels = "measuring" | "modelAndThinking" | "model"; + +/** Widths measured from the rendered model control, in points. */ +export interface ModelTriggerMeasurements { + /** The width the control may take. */ + availableWidth: number; + /** The control with its model and thinking labels side by side, untruncated. */ + fullWidth: number; +} + +/** + * The thinking label goes first: it shows only while the model label fits beside it in full, so + * the model label truncates only once the thinking label is gone. The web reports layout widths + * rounded to whole pixels, so equal widths can hide a fraction of a pixel of overflow; only a + * full width strictly below the available width is known to fit. + */ +export function resolveModelTriggerLabels(input: { + hasThinking: boolean; + measurements: Partial; +}): ModelTriggerLabels { + if (!input.hasThinking) return "model"; + const { availableWidth, fullWidth } = input.measurements; + if (availableWidth === undefined || fullWidth === undefined) return "measuring"; + return fullWidth < availableWidth ? "modelAndThinking" : "model"; +} diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 25352d8a9d..ce6b6d000a 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -85,7 +85,6 @@ export function AgentModeControl({ surface = "toolbar", onClose, }: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) { - const { theme } = useUnistyles(); const { presentation } = useComposerControlLayout(); const { t } = useTranslation(); const { isActiveComposer } = useComposerKeyboardScope(); @@ -102,7 +101,6 @@ export function AgentModeControl({ }, [modeOptions, selectedModeId]); const Icon = getAgentModeIcon(provider, selectedMode?.id ?? "", providerDefinitions); - const iconColor = theme.colors.foregroundMuted; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; const allOptions = useMemo( @@ -197,7 +195,6 @@ export function AgentModeControl({ (undefined); + const [fullWidth, setFullWidth] = useState<{ key: string; width: number } | null>(null); + + const onFrameLayout = useCallback((event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setAvailableWidth(width); + }, []); + const onFullLayout = useCallback( + (event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setFullWidth({ key: measuredKey, width }); + }, + [measuredKey], + ); + + const labels = resolveModelTriggerLabels({ + hasThinking: thinkingLabel !== null, + measurements: { + availableWidth, + fullWidth: fullWidth?.key === measuredKey ? fullWidth.width : undefined, + }, + }); + return { labels, measuredKey, onFrameLayout, onFullLayout }; +} + export function CompactModelSheet({ providers, selectedProvider, @@ -96,7 +148,7 @@ export function CompactModelSheet({ isRetryingProvider = false, disabled = false, serverId = null, - glyphSize, + glyphs, showLabels, canSwitchProvider, children, @@ -233,17 +285,6 @@ export function CompactModelSheet({ open(); }, [close, isOpen, open]); - const iconOnly = !showLabels && hasSelectedProvider; - const triggerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.trigger, - iconOnly && styles.triggerIconOnly, - hovered && styles.triggerHovered, - (pressed || isOpen) && styles.triggerPressed, - disabled && styles.triggerDisabled, - ], - [disabled, iconOnly, isOpen], - ); const mobileRootContent = useMemo( () => ( @@ -269,36 +310,19 @@ export function CompactModelSheet({ return ( <> - - {hasSelectedProvider ? ( - - - - ) : null} - {!iconOnly ? ( - - - {shortModelLabel(rootBrowser.triggerLabel)} - - {thinkingLabel ? ( - - {thinkingLabel} - - ) : null} - - ) : null} - + /> | null; + modelLabel: string; + thinkingLabel: string | null; + glyphs: ToolbarGlyphs; + iconOnly: boolean; + isOpen: boolean; + disabled: boolean; + onPress: () => void; + accessibilityLabel: string; +}) { + const glyphRole = iconOnly ? "icon" : "label"; + const glyph = glyphs[glyphRole]; + const triggerLabels = useModelTriggerLabels({ + modelLabel, + thinkingLabel: iconOnly ? null : thinkingLabel, + glyphSize: glyph.size, + }); + const providerIconColor = + glyph.tone === "foreground" ? styles.providerIconForeground.color : styles.providerIcon.color; + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.trigger, + iconOnly && styles.triggerIconOnly, + hovered && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + ], + [disabled, iconOnly, isOpen], + ); + + return ( + + + {ModelIcon ? ( + + + + ) : null} + {!iconOnly ? ( + + + {thinkingLabel && triggerLabels.labels !== "model" ? ( + + {thinkingLabel} + + ) : null} + + ) : null} + + {!iconOnly && thinkingLabel ? ( + + {ModelIcon ? {null} : null} + + + {modelLabel} + + + {thinkingLabel} + + + + ) : null} + + ); +} + const styles = StyleSheet.create((theme) => ({ trigger: { - height: TOOLBAR_CONTROL_HEIGHT, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, triggerIconOnly: { - width: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(TOUCH_ICON_SIZE.md), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", @@ -402,7 +543,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, triggerPressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, triggerDisabled: { opacity: 0.5, @@ -421,6 +562,28 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1], }, + triggerLabelsMeasuring: { + opacity: 0, + }, + /** The space the trigger may take; the trigger itself stays as wide as its content. */ + triggerFrame: { + minWidth: 0, + flexGrow: 1, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + }, + /** An invisible copy of the trigger with both labels untruncated. */ + triggerMeasurer: { + position: "absolute", + left: 0, + top: 0, + flexShrink: 0, + opacity: 0, + }, + triggerMeasuredText: { + flexShrink: 0, + }, triggerThinking: { flexShrink: 0, color: theme.colors.foregroundExtraMuted, @@ -430,6 +593,9 @@ const styles = StyleSheet.create((theme) => ({ providerIcon: { color: theme.colors.foregroundMuted, }, + providerIconForeground: { + color: theme.colors.foreground, + }, sheetBody: { paddingHorizontal: 0, paddingTop: 0, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 5401f77d73..b7dd4ee36a 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -33,10 +33,16 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { controlSizingForLayout, - iconSizeFor, - TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_CONTROL_GAP, + TOOLBAR_DISC_GLYPH_SIZES, TOOLBAR_DISC_SIZE, + TOOLBAR_RINGS, + toolbarGlyphsFor, + toolbarGlyphTone, + type ToolbarGlyph, + toolbarSlot, } from "@/components/ui/control-geometry"; +import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails"; import { usePanelStore } from "@/stores/panel-store"; import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { useShallow } from "zustand/shallow"; @@ -260,20 +266,22 @@ function resolveCheckoutRemoteUrl( return checkoutStatus?.remoteUrl ?? null; } -function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { +function buildCancelButtonStyle( + pressed: boolean, + isConnected: boolean, + isCancellingAgent: boolean, +): object[] { + const pressedStyle = pressed ? styles.discPressed : undefined; const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; - return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); + return [styles.cancelButton, pressedStyle, disabled].filter((value): value is object => + Boolean(value), + ); } -function buildRealtimeVoiceButtonStyle( - hovered: boolean | undefined, - voiceButtonDisabled: boolean, - reserveLeadingSpace: boolean, -): object[] { - const hoveredStyle = hovered ? styles.iconButtonHovered : undefined; +function buildRealtimeVoiceButtonStyle(pressed: boolean, voiceButtonDisabled: boolean): object[] { + const pressedStyle = pressed ? styles.discPressed : undefined; const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined; - const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined; - return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter( + return [styles.realtimeVoiceButton, pressedStyle, disabledStyle].filter( (value): value is object => Boolean(value), ); } @@ -1082,8 +1090,8 @@ function ComposerForgeBinding({ } interface ComposerCancelButtonProps { - buttonIconSize: number; - cancelButtonStyle: (object | undefined)[]; + discGlyphSize: number; + cancelButtonStyle: (state: PressableStateCallbackType) => object[]; handleCancelAgent: () => void; isConnected: boolean; isCancellingAgent: boolean; @@ -1092,7 +1100,7 @@ interface ComposerCancelButtonProps { } function ComposerCancelButton({ - buttonIconSize, + discGlyphSize, cancelButtonStyle, handleCancelAgent, isConnected, @@ -1106,7 +1114,7 @@ function ComposerCancelButton({ const icon = isCancellingAgent ? ( ) : ( - + ); const shortcutNode = agentInterruptKeys ? : null; return ( @@ -1131,7 +1139,7 @@ function ComposerCancelButton({ } interface ComposerVoiceModeButtonProps { - buttonIconSize: number; + glyph: ToolbarGlyph; handleToggleRealtimeVoice: () => void; isConnected: boolean; isVoiceSwitching: boolean; @@ -1142,37 +1150,25 @@ interface ComposerVoiceModeButtonProps { t: TFunction; } -interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { +function showsVoiceModeButton(input: { isVoiceModeForAgent: boolean; hasAgent: boolean; isAgentRunning: boolean; hasSendableContent: boolean; - isCompact: boolean; showVoice: boolean; -} - -function ComposerRightControlsSlot({ - isVoiceModeForAgent, - hasAgent, - isAgentRunning, - hasSendableContent, - isCompact, - showVoice, - ...voiceProps -}: ComposerRightControlsSlotProps) { - const hideVoiceForCompactInput = isCompact && hasSendableContent; - const showVoiceModeButton = - showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; - if (!showVoiceModeButton) return null; +}): boolean { + // Voice mode holds the trailing disc until there is something to send, then Send takes it. return ( - - - + input.showVoice && + !input.isVoiceModeForAgent && + input.hasAgent && + !input.isAgentRunning && + !input.hasSendableContent ); } function ComposerVoiceModeButton({ - buttonIconSize, + glyph, handleToggleRealtimeVoice, isConnected, isVoiceSwitching, @@ -1183,13 +1179,25 @@ function ComposerVoiceModeButton({ const shortcutNode = voiceToggleKeys ? : null; const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { - if (isVoiceSwitching) { - return ; - } - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; - return ; + const colorMapping = + toolbarGlyphTone(glyph, Boolean(hovered)) === "foreground" + ? iconForegroundMapping + : iconForegroundMutedMapping; + return ( + + {isVoiceSwitching ? ( + + ) : ( + + )} + + ); }, - [buttonIconSize, isVoiceSwitching], + [glyph, isVoiceSwitching], ); return ( @@ -1311,7 +1319,9 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); - const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)]; + const controlSizing = controlSizingForLayout(isCompactFormFactor); + const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon; + const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing]; const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const { finePointer: hasFinePointer } = usePointerCapabilities(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); @@ -2012,8 +2022,9 @@ function ComposerContentImpl({ [agentId, recall, replaceUserInput, serverId], ); - const cancelButtonStyle = useMemo( - () => buildCancelButtonStyle(isConnected, isCancellingAgent), + const cancelButtonStyle = useCallback( + (state: PressableStateCallbackType) => + buildCancelButtonStyle(state.pressed, isConnected, isCancellingAgent), [isConnected, isCancellingAgent], ); @@ -2021,14 +2032,14 @@ function ComposerContentImpl({ const voiceButtonDisabled = !isConnected || isVoiceSwitching; const realtimeVoiceButtonStyle = useCallback( (state: PressableStateCallbackType & { hovered?: boolean }) => - buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout), - [isCompactLayout, voiceButtonDisabled], + buildRealtimeVoiceButtonStyle(state.pressed, voiceButtonDisabled), + [voiceButtonDisabled], ); const activeActionContent = useMemo( () => ( ( - - ), + const voiceModeContent = useMemo( + () => + showsVoiceModeButton({ + isVoiceModeForAgent, + hasAgent, + isAgentRunning, + hasSendableContent, + showVoice: mode.showVoice, + }) ? ( + + + + ) : null, [ - buttonIconSize, + toolbarGlyph, handleToggleRealtimeVoice, hasAgent, hasSendableContent, isAgentRunning, isConnected, - isCompactLayout, isVoiceModeForAgent, isVoiceSwitching, mode.showVoice, @@ -2093,7 +2106,7 @@ function ComposerContentImpl({ maxTokens={agentState.contextWindowMaxTokens} usedTokens={agentState.contextWindowUsedTokens} totalCostUsd={agentState.totalCostUsd} - glyphSize={buttonIconSize} + toolbarRing={TOOLBAR_RINGS[controlSizing]} /> ) : null, @@ -2104,7 +2117,7 @@ function ComposerContentImpl({ agentState.contextWindowMaxTokens, agentState.contextWindowUsedTokens, agentState.totalCostUsd, - buttonIconSize, + controlSizing, ], ); @@ -2490,7 +2503,7 @@ function ComposerContentImpl({ disabled={isSubmitLoading} leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} - rightContent={rightContent} + voiceModeContent={voiceModeContent} activeActionContent={activeActionContent} voiceServerId={serverId} voiceAgentId={agentId} @@ -2596,13 +2609,10 @@ const styles = StyleSheet.create((theme: Theme) => ({ width: "100%", gap: theme.spacing[3], }, - // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. cancelButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, cancelButtonDisc: { width: TOOLBAR_DISC_SIZE, @@ -2615,31 +2625,34 @@ const styles = StyleSheet.create((theme: Theme) => ({ rightControls: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, }, contextWindowMeterSlot: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring), flexShrink: 0, alignItems: "center", justifyContent: "center", }, - realtimeVoiceButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + voiceModeDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, alignItems: "center", justifyContent: "center", }, - realtimeVoiceButtonCompactReserve: { - marginLeft: theme.spacing[1], + realtimeVoiceButton: { + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", }, realtimeVoiceButtonActive: { backgroundColor: theme.colors.palette.green[600], borderColor: theme.colors.palette.green[800], }, - iconButtonHovered: { - backgroundColor: theme.colors.surface2, + discPressed: { + opacity: 0.7, }, attachmentTray: { flexDirection: "row", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index be1f2c3ed0..3c4541181f 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -31,10 +31,24 @@ import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { controlSizingForLayout, - iconSizeFor, + TOOLBAR_CONTROL_GAP, TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_GLYPH_SIZES, TOOLBAR_DISC_SIZE, + toolbarGlyphsFor, + toolbarGlyphTone, + type ToolbarGlyph, + toolbarSlot, } from "@/components/ui/control-geometry"; +import { + COMPOSER_PADDING_BOTTOM, + COMPOSER_PADDING_HORIZONTAL, + COMPOSER_CORNER_RADIUS, + COMPOSER_PADDING_TOP, + COMPOSER_TOOLBAR_SHAPES, + composerToolbarOverhang, + resolveComposerLeadingEdge, +} from "@/composer/input/toolbar-rails"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; @@ -170,8 +184,8 @@ export interface MessageInputProps { leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; - /** Auxiliary content to render on the right side after the voice button. */ - rightContent?: React.ReactNode; + /** The realtime voice mode button after the dictation button, or null when the row hides it. */ + voiceModeContent?: React.ReactNode; /** Primary action to render when the agent is active and the composer has no sendable content. */ activeActionContent?: React.ReactNode; voiceServerId?: string; @@ -252,19 +266,28 @@ interface TextAreaHandle { }; } +function glyphColorMapping(glyph: ToolbarGlyph, hovered: boolean) { + return toolbarGlyphTone(glyph, hovered) === "foreground" + ? iconForegroundMapping + : iconForegroundMutedMapping; +} + function AttachButtonIcon({ hovered, onAttachButtonRef, - buttonIconSize, + glyph, }: { hovered: boolean; onAttachButtonRef: ((node: View | null) => void) | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; }) { - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ( - + ); } @@ -342,25 +365,25 @@ function VoiceButtonIcon({ hovered, isDictating, isMutedRealtime, - buttonIconSize, + glyph, + discGlyphSize, }: { hovered: boolean; isDictating: boolean; isMutedRealtime: boolean; - buttonIconSize: number; + glyph: ToolbarGlyph; + discGlyphSize: number; }) { if (isDictating) { return ( - + ); } - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; - if (isMutedRealtime) { - return ; - } - return ; + const colorMapping = glyphColorMapping(glyph, hovered); + const Icon = isMutedRealtime ? ThemedMicOff : ThemedMic; + return ; } type ShortcutChord = NonNullable["chord"]>; @@ -399,12 +422,12 @@ function SendButtonContent({ isSubmitLoading, submitIcon, submitLabel, - buttonIconSize, + glyph, }: { isSubmitLoading: boolean; submitIcon: "arrow" | "return"; submitLabel: string | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; }) { if (isSubmitLoading) { return ; @@ -413,9 +436,21 @@ function SendButtonContent({ return {submitLabel}; } if (submitIcon === "return") { - return ; + return ( + + ); } - return ; + return ( + + ); } interface DesktopKeyPressContext { @@ -808,7 +843,7 @@ function SendButtonTooltip({ submitIcon, submitLabel, submitButtonTestID, - buttonIconSize, + glyph, sendKeys, sendTooltipLabel, }: { @@ -825,7 +860,7 @@ function SendButtonTooltip({ submitIcon: "arrow" | "return"; submitLabel: string | undefined; submitButtonTestID: string | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; sendKeys: ShortcutChord | null | undefined; sendTooltipLabel: string; }) { @@ -847,7 +882,7 @@ function SendButtonTooltip({ isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} submitLabel={submitLabel} - buttonIconSize={buttonIconSize} + glyph={glyph} /> @@ -1140,7 +1175,7 @@ interface ResolvedMessageInputProps { disabled: boolean; leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; - rightContent: React.ReactNode; + voiceModeContent: React.ReactNode; activeActionContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; @@ -1188,7 +1223,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu disabled: props.disabled ?? false, leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, - rightContent: props.rightContent, + voiceModeContent: props.voiceModeContent, activeActionContent: props.activeActionContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, @@ -1216,8 +1251,12 @@ function extractErrorMessage(error: unknown): string | null { return null; } +/** + * Send that stands in for the trailing disc, in fullscreen or while there is nothing to send, is + * disabled until there is content. + */ function resolvePresentationSendDisabled(input: { - fullscreen: boolean; + standsIn: boolean; disabled: boolean; hasText: boolean; attachments: ComposerAttachment[]; @@ -1225,7 +1264,7 @@ function resolvePresentationSendDisabled(input: { allowEmpty: boolean; }): boolean { return ( - input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + input.disabled || (input.standsIn && !input.allowEmpty && !hasSendableComposerContent(input)) ); } @@ -1266,7 +1305,7 @@ export const MessageInput = forwardRef( disabled, leftContent, beforeVoiceContent, - rightContent, + voiceModeContent, activeActionContent, voiceServerId, voiceAgentId, @@ -1296,7 +1335,8 @@ export const MessageInput = forwardRef( const [renderedLines, setRenderedLines] = useState(1); const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); const controlSizing = controlSizingForLayout(isCompact); - const buttonIconSize = iconSizeFor("md")[controlSizing]; + const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon; + const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing]; const toast = useToast(); const voice = useVoiceOptional(); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); @@ -1919,18 +1959,20 @@ export const MessageInput = forwardRef( ); const attachButtonStyle = useCallback( - ({ hovered }: { hovered?: boolean }) => [ + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.attachButton, - Boolean(hovered) && styles.iconButtonHovered, + Boolean(hovered || pressed) && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ], [isConnected, disabled], ); const voiceButtonStyle = useCallback( - ({ hovered }: { hovered?: boolean }) => [ + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.voiceButton, - Boolean(hovered) && !isDictating && styles.iconButtonHovered, + isDictating && styles.voiceButtonRecording, + Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered, + Boolean(pressed) && isDictating && styles.discPressed, !isDictationStartEnabled && styles.buttonDisabled, ], [isDictating, isDictationStartEnabled], @@ -1971,16 +2013,24 @@ export const MessageInput = forwardRef( [mode.isMonospace], ); // Fullscreen always shows Send; without content it is disabled, and looks it. + // With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row + // does not shift when typing starts. + const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null); + const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind; const isPresentedSendDisabled = resolvePresentationSendDisabled({ - fullscreen: isFullscreen, + standsIn: sendStandsIn, disabled: isSendButtonDisabled, hasText: hasLiveText, attachments, hasExternalContent, allowEmpty: allowEmptySubmit, }); - const sendButtonCombinedStyle = useMemo( - () => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled], + const sendButtonCombinedStyle = useCallback( + ({ pressed }: { pressed?: boolean }) => [ + styles.sendButton, + Boolean(pressed) && styles.discPressed, + isPresentedSendDisabled && styles.buttonDisabled, + ], [isPresentedSendDisabled], ); const overlayContainerStyle = useMemo( @@ -1993,10 +2043,10 @@ export const MessageInput = forwardRef( ), - [onAttachButtonRef, buttonIconSize], + [onAttachButtonRef, toolbarGlyph], ); const renderVoiceButtonIcon = useCallback( @@ -2005,16 +2055,17 @@ export const MessageInput = forwardRef( hovered={Boolean(hovered)} isDictating={isDictating} isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)} - buttonIconSize={buttonIconSize} + glyph={toolbarGlyph} + discGlyphSize={discGlyphSize} /> ), - [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], + [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, toolbarGlyph, discGlyphSize], ); const sendButton = useMemo( () => ( ( submitIcon={submitIcon} submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} - buttonIconSize={buttonIconSize} + glyph={toolbarGlyph} sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), [ - isFullscreen, - primaryActionKind, + presentedActionKind, activeActionContent, canPressLoadingButton, onSubmitLoadingPress, @@ -2050,7 +2100,7 @@ export const MessageInput = forwardRef( submitIcon, submitLabel, submitButtonTestID, - buttonIconSize, + toolbarGlyph, sendShortcut, sendTooltipLabel, ], @@ -2124,9 +2174,25 @@ export const MessageInput = forwardRef( focusInputKeys, ], ); + const leadingEdge = resolveComposerLeadingEdge({ + showsAttach: mode.showAttachments, + showsAgentControls: leftContent != null, + }); + // The trailing control is always a disc: voice mode, then Send or Stop. + const trailingEdge = "disc"; + const buttonRowStyle = useMemo( + () => [ + styles.buttonRow, + { + marginLeft: -composerToolbarOverhang(leadingEdge, controlSizing), + marginRight: -composerToolbarOverhang(trailingEdge, controlSizing), + }, + ], + [controlSizing, leadingEdge, trailingEdge], + ); const toolbar = useMemo( () => ( - + {/* Toolbar left: attachment button + agent controls */} ( voiceMuteToggleKeys={voiceMuteToggleKeys} dictationToggleKeys={dictationToggleKeys} /> - {rightContent} + {voiceModeContent} {sendButton} ), [ + buttonRowStyle, mode.showAttachments, isConnected, disabled, @@ -2181,7 +2248,7 @@ export const MessageInput = forwardRef( isRealtimeVoiceForCurrentAgent, voiceMuteToggleKeys, dictationToggleKeys, - rightContent, + voiceModeContent, sendButton, ], ); @@ -2249,7 +2316,7 @@ export const MessageInput = forwardRef( ({ backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius["2xl"], - paddingTop: { - xs: theme.spacing[2], - md: theme.spacing[4], - }, - // The button row bleeds 6px horizontally, so match its corner inset at the bottom. - paddingBottom: { - xs: theme.spacing[2], - md: theme.spacing[3], - }, - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, + borderRadius: COMPOSER_CORNER_RADIUS, + paddingTop: COMPOSER_PADDING_TOP, + paddingBottom: COMPOSER_PADDING_BOTTOM, + paddingHorizontal: COMPOSER_PADDING_HORIZONTAL, ...(isWeb ? { transitionProperty: "border-color", @@ -2377,47 +2434,49 @@ const styles = StyleSheet.create((theme: Theme) => ({ minHeight: MIN_INPUT_HEIGHT, color: theme.colors.foregroundMuted, }, + // The row keeps a full touch target's height; its disc-sized controls centre in it. buttonRow: { + minHeight: TOOLBAR_CONTROL_HEIGHT, flexShrink: 0, flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", justifyContent: "space-between", - marginHorizontal: -6, }, leftButtonGroup: { minWidth: 0, flexShrink: 1, flexGrow: 1, flexDirection: "row", - alignItems: "flex-end", - gap: theme.spacing[0], + alignItems: "center", + gap: TOOLBAR_CONTROL_GAP, }, rightButtonGroup: { flexShrink: 0, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, }, attachButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, attachButtonAnchor: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, alignItems: "center", justifyContent: "center", }, voiceButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, + voiceButtonRecording: { + marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, + }, voiceButtonRecordingDisc: { width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE, @@ -2426,13 +2485,12 @@ const styles = StyleSheet.create((theme: Theme) => ({ alignItems: "center", justifyContent: "center", }, - // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. sendButton: { - minWidth: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + minWidth: TOOLBAR_DISC_SIZE, + marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, + height: TOOLBAR_DISC_SIZE, alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, sendButtonDisc: { width: TOOLBAR_DISC_SIZE, @@ -2459,6 +2517,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ iconButtonHovered: { backgroundColor: theme.colors.surface2, }, + discPressed: { + opacity: 0.7, + }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/input/toolbar-rails.test.ts b/packages/app/src/composer/input/toolbar-rails.test.ts new file mode 100644 index 0000000000..1b4caadd2d --- /dev/null +++ b/packages/app/src/composer/input/toolbar-rails.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { composerToolbarOverhang, resolveComposerLeadingEdge } from "./toolbar-rails"; + +describe("composer toolbar rails", () => { + it("keeps touch glyphs and labels on the text rails and nests a trailing disc into the corner", () => { + for (const edge of ["attach", "dictation", "voiceMode", "label", "none"] as const) { + expect(composerToolbarOverhang(edge, "touch")).toBe(0); + } + // A 36pt disc centred in the 44pt row sits 8 from the bottom edge, so it sits 8 from the side. + expect(composerToolbarOverhang("disc", "touch")).toBe(8); + }); + + it("keeps the pointer row's flat overhang", () => { + for (const edge of ["attach", "dictation", "voiceMode", "disc", "label", "none"] as const) { + expect(composerToolbarOverhang(edge, "pointer")).toBe(6); + } + }); + + it("starts the row on attach, else on the agent controls", () => { + expect(resolveComposerLeadingEdge({ showsAttach: true, showsAgentControls: true })).toBe( + "attach", + ); + expect(resolveComposerLeadingEdge({ showsAttach: false, showsAgentControls: true })).toBe( + "label", + ); + expect(resolveComposerLeadingEdge({ showsAttach: false, showsAgentControls: false })).toBe( + "none", + ); + }); +}); diff --git a/packages/app/src/composer/input/toolbar-rails.ts b/packages/app/src/composer/input/toolbar-rails.ts new file mode 100644 index 0000000000..1cbca21224 --- /dev/null +++ b/packages/app/src/composer/input/toolbar-rails.ts @@ -0,0 +1,85 @@ +import { + TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_SIZES, + TOOLBAR_RINGS, + toolbarGlyphsFor, + type ControlSizing, +} from "@/components/ui/control-geometry"; +import { bySizing } from "@/components/ui/control-sizing"; + +/** The control at one end of the composer toolbar, which decides how far the row hangs out. */ +export type ComposerToolbarEdge = "attach" | "label" | "dictation" | "voiceMode" | "disc" | "none"; + +/** + * The composer's padding. Its sides are the rails: the text column and the toolbar's outer + * controls both start there. + */ +const COMPOSER_PADDING = { + touch: { top: 12, horizontal: 16, bottom: 4 }, + pointer: { top: 16, horizontal: 16, bottom: 12 }, +} as const; + +export const COMPOSER_PADDING_TOP = bySizing({ + touch: COMPOSER_PADDING.touch.top, + pointer: COMPOSER_PADDING.pointer.top, +}); +export const COMPOSER_PADDING_HORIZONTAL = bySizing({ + touch: COMPOSER_PADDING.touch.horizontal, + pointer: COMPOSER_PADDING.pointer.horizontal, +}); +export const COMPOSER_PADDING_BOTTOM = bySizing({ + touch: COMPOSER_PADDING.touch.bottom, + pointer: COMPOSER_PADDING.pointer.bottom, +}); + +/** How much of Lucide's 24-unit grid each toolbar glyph's ink spans, centred in its box. */ +const GLYPH_INK_SPANS = { plus: 14, mic: 14, audioLines: 20 } as const; + +function touchGlyphInkWidth(span: number): number { + const glyph = toolbarGlyphsFor("touch").icon; + return (span * glyph.size) / 24 + glyph.strokeWidth; +} + +/** The width each toolbar control paints when touch-sized; the row spaces these. */ +export const COMPOSER_TOOLBAR_SHAPES = { + attach: touchGlyphInkWidth(GLYPH_INK_SPANS.plus), + dictation: touchGlyphInkWidth(GLYPH_INK_SPANS.mic), + voiceMode: touchGlyphInkWidth(GLYPH_INK_SPANS.audioLines), + ring: TOOLBAR_RINGS.touch.size, + disc: TOOLBAR_DISC_SIZES.touch, +} as const; + +/** Pointer-sized the row hangs a flat 6px into the padding, which puts each 16pt glyph box on the rail. */ +const POINTER_OVERHANG = 6; + +/** How far a touch-sized disc sits from the composer's bottom edge, centred in the toolbar row. */ +const DISC_EDGE_INSET = + COMPOSER_PADDING.touch.bottom + (TOOLBAR_CONTROL_SIZES.touch - TOOLBAR_DISC_SIZES.touch) / 2; + +/** + * A trailing disc nests into the composer's corner: it reaches past the rail until it sits as far + * from the right edge as from the bottom edge. + */ +const DISC_CORNER_NEST = COMPOSER_PADDING.touch.horizontal - DISC_EDGE_INSET; + +/** Touch-sized the composer's corner is concentric with the disc nested in it. */ +export const COMPOSER_CORNER_RADIUS = bySizing({ + touch: TOOLBAR_DISC_SIZES.touch / 2 + DISC_EDGE_INSET, + pointer: 16, +}); + +/** How far the composer toolbar hangs past the text column at one end. */ +export function composerToolbarOverhang(edge: ComposerToolbarEdge, sizing: ControlSizing): number { + if (sizing === "pointer") return POINTER_OVERHANG; + return edge === "disc" ? DISC_CORNER_NEST : 0; +} + +/** The control that starts the toolbar on the left. */ +export function resolveComposerLeadingEdge(input: { + showsAttach: boolean; + showsAgentControls: boolean; +}): ComposerToolbarEdge { + if (input.showsAttach) return "attach"; + if (input.showsAgentControls) return "label"; + return "none"; +} From 1a626230d96e3bbc91b207c23cf84645bff31ff3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:16:01 +0000 Subject: [PATCH 07/32] Keep clients current when parent archive detaches an unloaded child (#8903) * fix(server): retain archived parent provenance on detach * fix(server): publish stored child detach updates on parent archive Keep subscribed clients current when an unloaded child survives its parent archive. Narrow the contributor change to the missing update; archived-parent provenance remains a separate feature. Co-authored-by: luke * test(server): clean up the detach fixture static directory Keep the test daemon static directory inside the fixture root so cleanup removes all test-owned directories. * fix(server): retain runtime settings in stored agent updates Parent archive publishes stored survivors through the manager. Preserve their saved runtime model and thinking settings so the update matches stored fetches and subscription filters. --------- Co-authored-by: luke Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 64 ++++++- .../server/src/server/agent/agent-manager.ts | 7 +- .../agent-archive-detach-updates.e2e.test.ts | 162 ++++++++++++++++++ 3 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/agent-archive-detach-updates.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 6c7396d657..372074c659 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -6,7 +6,7 @@ import { } from "./agent-messages/index.js"; import { expect, test, vi, onTestFinished } from "vitest"; import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -9466,6 +9466,68 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed" expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined(); }); +test.each([ + { workspaceId: "workspace-b", open: "false" }, + { workspaceId: "workspace-a", open: "true" }, +])( + "archiveAgent publishes stored survivor detach updates ($workspaceId, open=$open)", + async ({ workspaceId, open }) => { + const artifacts = join(process.cwd(), ".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const workdir = mkdtempSync(join(artifacts, "stored-child-detach-")); + onTestFinished(() => rmSync(workdir, { recursive: true, force: true })); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const parent = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Parent" }, + undefined, + { workspaceId: "workspace-a" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Closed cross-workspace child" }, + undefined, + { + workspaceId, + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + [DESKTOP_OPEN_AGENT_TAB_LABEL]: open, + team: "ios", + }, + }, + ); + await manager.closeAgent(child.id); + // Unfiltered, as a session subscribes: a closed survivor's update must reach every client. + const emittedLabels: Array> = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === child.id) { + emittedLabels.push(event.agent.labels); + } + }, + { replayState: false }, + ); + + await manager.archiveAgent(parent.id); + await manager.flush(); + unsubscribe(); + + const expectedLabels = { team: "ios" }; + const storedChild = await storage.get(child.id); + expect(storedChild?.archivedAt).toBeUndefined(); + expect(storedChild?.labels).toEqual(expectedLabels); + expect(emittedLabels).toEqual([expectedLabels]); + + // The child is already detached; repeating detach has nothing to sever. + const detached = await manager.detachAgent(child.id); + expect(detached.previousParentAgentId).toBeNull(); + expect(detached.record.labels).toEqual(expectedLabels); + }, +); + test("archiveAgent re-reads a child before deciding whether to cascade", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-")); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fea3eed438..9f87600ed9 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2112,7 +2112,10 @@ export class AgentManager { return; } if (shouldDetachFromArchivedParent(parent, currentChild)) { - await this.detachAgentUnlocked(currentChild.id); + const detached = await this.detachAgentUnlocked(currentChild.id); + if (!detached.live) { + this.dispatchStoredAgentState(detached.record); + } } else if (this.agents.has(currentChild.id)) { await this.archiveAgentUnlocked(currentChild.id); } else { @@ -2198,7 +2201,7 @@ export class AgentManager { session: null, capabilities: STORED_AGENT_CAPABILITIES, config: buildStoredAgentConfig(record), - runtimeInfo: undefined, + runtimeInfo: record.runtimeInfo, lifecycle: "closed", createdAt: new Date(record.createdAt), updatedAt, diff --git a/packages/server/src/server/daemon-e2e/agent-archive-detach-updates.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-archive-detach-updates.e2e.test.ts new file mode 100644 index 0000000000..31e4a828dd --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-archive-detach-updates.e2e.test.ts @@ -0,0 +1,162 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { expect, test } from "vitest"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; + +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const CREATED_AT = "2026-11-04T08:00:00.000Z"; +const UPDATED_AT = "2026-11-04T09:00:00.000Z"; +const RUNTIME_INFO = { + provider: "codex", + sessionId: null, + model: "provider-default", + thinkingOptionId: "medium", +}; + +test.each([false, true])( + "parent archive preserves a stored survivor for subscribers (filtered=%s)", + async (filtered) => { + const fixture = seedFixture(); + let daemon: TestPaseoDaemon | null = null; + let observer: DaemonClient | null = null; + let actor: DaemonClient | null = null; + try { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot: fixture.paseoHomeRoot, + staticDir: fixture.staticDir, + cleanup: false, + }); + const url = `ws://127.0.0.1:${daemon.port}/ws`; + observer = new DaemonClient({ url }); + actor = new DaemonClient({ url }); + await observer.connect(); + await actor.connect(); + + const updates: AgentSnapshotPayload[] = []; + const removed: string[] = []; + observer.subscribeRawMessages((message: SessionOutboundMessage) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") { + updates.push(message.payload.agent); + } else if (message.type === "agent_update" && message.payload.kind === "remove") { + removed.push(message.payload.agentId); + } + }); + const initial = await observer.fetchAgents({ + filter: { includeBackground: true, ...(filtered ? { thinkingOptionId: "medium" } : {}) }, + subscribe: {}, + }); + const initialSurvivor = initial.entries.find( + (entry) => entry.agent.id === "child-survivor", + )?.agent; + expect(initialSurvivor?.runtimeInfo).toEqual(RUNTIME_INFO); + expect(initialSurvivor?.effectiveThinkingOptionId).toBe("medium"); + + // A child already detached by hand remains independent of the parent archive. + await actor.detachAgent("child-manual"); + await actor.archiveAgent("parent"); + + const survivor = await actor.fetchAgent({ agentId: "child-survivor" }); + expect(survivor?.agent.archivedAt).toBeNull(); + expect(survivor?.agent.labels).toEqual({}); + await expect + .poll(() => updates.findLast((update) => update.id === "child-survivor")?.labels) + .toEqual({}); + const update = updates.findLast((item) => item.id === "child-survivor"); + expect(update?.runtimeInfo).toEqual(RUNTIME_INFO); + expect(update?.effectiveThinkingOptionId).toBe("medium"); + expect(removed).not.toContain("child-survivor"); + const manual = await actor.fetchAgent({ agentId: "child-manual" }); + expect(manual?.agent.archivedAt).toBeNull(); + expect(manual?.agent.labels).toEqual({}); + } finally { + await observer?.close().catch(() => undefined); + await actor?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + for (const target of fixture.cleanupPaths) rmSync(target, { recursive: true, force: true }); + } + }, +); + +function seedFixture() { + const artifacts = path.join(process.cwd(), ".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "archive-detach-")); + const parentCwd = path.join(root, "parent"); + const childCwd = path.join(root, "child"); + const paseoHomeRoot = path.join(root, "home"); + const staticDir = path.join(root, "static"); + mkdirSync(staticDir); + mkdirSync(parentCwd); + mkdirSync(childCwd); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const projectsDir = path.join(paseoHome, "projects"); + const agentsDir = path.join(paseoHome, "agents"); + mkdirSync(projectsDir, { recursive: true }); + mkdirSync(agentsDir, { recursive: true }); + const project = (projectId: string, rootPath: string) => ({ + projectId, + rootPath, + kind: "non_git", + displayName: projectId, + customName: null, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + archivedAt: null, + }); + const workspace = (workspaceId: string, projectId: string, cwd: string) => ({ + workspaceId, + projectId, + cwd, + kind: "directory", + displayName: workspaceId, + title: null, + branch: null, + baseBranch: null, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + archivedAt: null, + }); + writeJson(path.join(projectsDir, "projects.json"), [ + project("proj-parent", parentCwd), + project("proj-child", childCwd), + ]); + writeJson(path.join(projectsDir, "workspaces.json"), [ + workspace("ws-parent", "proj-parent", parentCwd), + workspace("ws-child", "proj-child", childCwd), + ]); + const agent = (id: string, cwd: string, workspaceId: string, labels: Record) => ({ + id, + provider: "codex", + cwd, + workspaceId, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + lastActivityAt: UPDATED_AT, + lastUserMessageAt: null, + title: id, + labels, + lastStatus: "closed", + lastModeId: "full-access", + config: null, + ...(id === "child-survivor" ? { runtimeInfo: RUNTIME_INFO } : {}), + persistence: null, + }); + // Both children live in another workspace, so the parent's archive detaches rather than archives them. + writeJson(path.join(agentsDir, "parent.json"), agent("parent", parentCwd, "ws-parent", {})); + writeJson( + path.join(agentsDir, "child-survivor.json"), + agent("child-survivor", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }), + ); + writeJson( + path.join(agentsDir, "child-manual.json"), + agent("child-manual", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }), + ); + return { paseoHomeRoot, staticDir, cleanupPaths: [root] }; +} + +function writeJson(filePath: string, value: unknown): void { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} From 6c1b48d2962ec6a48d5a553e312dfcc1f94e2e7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 16:16:11 +0700 Subject: [PATCH 08/32] Archive a workspace from its sidebar row, with undo (#8904) * feat(app): archive a workspace from its sidebar row and undo it Hovering a workspace row on desktop shows an archive button next to the row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a mis-click costs one keystroke instead of a trip through History. The daemon serializes archive and undo per workspace across every connection. An undo that lands while an archive is still tearing down aborts it at the next checkpoint and restores only the agents that archive took down; deleting the worktree directory is the point of no return, after which undo recreates the worktree. * fix(server,app): close archive and undo races found in review - An archive request now claims the workspace's archive queue before looking up the workspace, so an undo sent right behind it cancels it instead of running first and losing to the archive. - A repeat archive of an already archived workspace no longer wipes the agents the earlier archive saved for undo. - Undo no longer brings back an agent that was archived before the workspace archive and only loaded to read its history. - Each local archive or undo request owns its own intent token, so a late response to an earlier request cannot release a newer request's hold on the row. An archive canceled by another client's undo puts the row back. - The undo failure toast shows translated copy; the cause goes to the log. * fix(app): keep a workspace's layout when another client restores it during the undo window The undo window purged tabs and layout whenever it closed after a finished archive. If another client restored the workspace before this client's toast closed, the restored workspace lost its saved arrangement. The archive now commits only if the workspace is still gone when the window closes. * fix(app): show the archived toast's Undo as a button * fix(app): keep a workspace's agent tabs when it is opened while its undo runs * fix(app): keep a row whose undo is running when a directory snapshot omits it --- docs/agent-lifecycle.md | 21 ++ docs/keyboard-shortcuts.md | 7 + .../browser/workspace-archive-undo.spec.ts | 186 +++++++++++ .../support/helpers/workspace-archive-undo.ts | 97 ++++++ packages/app/src/app/_layout.tsx | 2 + .../src/components/sidebar-workspace-list.tsx | 3 + .../sidebar/sidebar-status-list.tsx | 6 + .../sidebar/sidebar-workspace-menu.tsx | 48 ++- .../sidebar/sidebar-workspace-row-content.tsx | 45 ++- .../sidebar/sidebar-workspace-row.tsx | 3 + packages/app/src/components/toast-host.tsx | 81 +++-- .../components/ui/trailing-action-scrim.tsx | 28 +- .../session-workspace-upserts.test.ts | 41 +++ .../src/contexts/session-workspace-upserts.ts | 163 +++++++--- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 19 ++ .../app/src/keyboard/keyboard-shortcuts.ts | 26 ++ packages/app/src/keyboard/route-shortcut.ts | 1 + .../directory-sync/workspace-replica.test.ts | 52 +++ .../directory-sync/workspace-replica.ts | 16 + .../screens/workspace/workspace-screen.tsx | 9 +- .../app/src/workspace/archive-undo/index.ts | 8 + .../archive-undo/internal/capability.ts | 8 + .../internal/undo-shortcut-host.tsx | 22 ++ .../archive-undo/internal/undo-window.test.ts | 301 ++++++++++++++++++ .../archive-undo/internal/undo-window.tsx | 129 ++++++++ .../use-workspace-archive-undo-running.ts | 21 ++ .../internal/workspace-archived-toast.tsx | 41 +++ .../src/workspace/use-workspace-archive.ts | 22 +- .../src/workspace/workspace-archive.test.ts | 142 +++++++++ .../app/src/workspace/workspace-archive.ts | 121 +++++-- packages/client/src/daemon-client.ts | 17 + packages/protocol/src/messages.ts | 26 ++ .../server/src/server/agent/agent-manager.ts | 8 +- .../authorization/operation-permissions.ts | 2 + packages/server/src/server/session.ts | 169 ++++++++-- .../server/src/server/websocket-server.ts | 7 + .../workspace-archive-operations.test.ts | 282 ++++++++++++++++ .../server/workspace-archive-operations.ts | 133 ++++++++ .../server/workspace-archive-service.test.ts | 202 ++++++++++++ .../src/server/workspace-archive-service.ts | 127 ++++++-- .../server/workspace-archive-undo.e2e.test.ts | 275 ++++++++++++++++ 52 files changed, 2767 insertions(+), 180 deletions(-) create mode 100644 packages/app/e2e/browser/workspace-archive-undo.spec.ts create mode 100644 packages/app/e2e/support/helpers/workspace-archive-undo.ts create mode 100644 packages/app/src/workspace/archive-undo/index.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/capability.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx create mode 100644 packages/app/src/workspace/archive-undo/internal/undo-window.test.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/undo-window.tsx create mode 100644 packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts create mode 100644 packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx create mode 100644 packages/server/src/server/workspace-archive-operations.test.ts create mode 100644 packages/server/src/server/workspace-archive-operations.ts create mode 100644 packages/server/src/server/workspace-archive-undo.e2e.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 45c54ca18b..c0aad974f6 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -259,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. +Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up. +The daemon runs one archive lane per workspace, shared by every connection +(`packages/server/src/server/workspace-archive-operations.ts`): + +| A request arrives while | Result | +| ------------------------ | --------------------------------------------------------- | +| an archive runs: archive | joins the running archive | +| an archive runs: undo | aborts the archive, waits for it to settle, then restores | +| an undo runs: archive | waits for the undo, then archives | +| an undo runs: undo | joins the running undo | + +A request takes its place in the lane before its first `await`. Arrival order decides, so an undo +sent right behind its archive still cancels it. + +An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only +the agents that archive itself archived. Deleting the worktree directory is the point of no +return: an undo after it recreates the worktree through workspace recovery. The client shows or +hides the row by the latest local intent, so a late remove or upsert from a superseded request +does not flip it back. Tabs and layout are purged only once the undo window closes with the +archive committed. + History navigation opens the selected agent without changing either archive state. Workspace **Restore** recovers only the workspace; the selected archived agent stays open with its callout. The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index eac3ee8103..2c1045eb54 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -84,3 +84,10 @@ its DOM-derived scope. Escape interrupts an agent only in the message input or an unclaimed surface. A focused terminal excludes that binding. The source leaves terminal Escape alone; terminal Escape/Ctrl forwarding is a separate feature. + +## Keys shared with text editing + +A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for +workspace archive undo, declares `editable: false` and registers its handler with +`isActive` true only while it has work. An inactive handler leaves the key +unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs. diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts new file mode 100644 index 0000000000..90e47aab3c --- /dev/null +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -0,0 +1,186 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "../support/helpers/new-workspace"; +import { getServerId } from "../support/helpers/server-id"; +import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar"; +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs"; +import { + archiveFromRowButton, + createGatedTeardown, + expectDirectoryPresence, + expectHostWorkspaceSettledActive, + outlastUndoWindow, + pressUndoShortcut, + rowArchiveButton, + rowKebab, + undoButton, + workspaceRow, + workspaceTitleWidth, +} from "../support/helpers/workspace-archive-undo"; +import { + waitForSidebarHydration, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; + +test.describe("Workspace archive undo", () => { + let client: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const worktreeDirectories = new Set(); + + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("archive-undo-"); + }); + + test.afterEach(async () => { + for (const directory of worktreeDirectories) { + await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined); + } + worktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + async function seedWorktree(page: Page, slug: string) { + await openProjectViaDaemon(client, tempRepo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: tempRepo.path, + slug: `${slug}-${Date.now()}`, + }); + worktreeDirectories.add(worktree.workspaceDirectory); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: worktree.workspaceId, + }); + return worktree; + } + + test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-button-overlay"); + + await page.mouse.move(0, 0); + await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0); + const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId); + + await workspaceRow(page, worktree.workspaceId).hover(); + await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible(); + await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible(); + expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth); + + const [buttonBox, kebabBox] = await Promise.all([ + rowArchiveButton(page, worktree.workspaceId).boundingBox(), + rowKebab(page, worktree.workspaceId).boundingBox(), + ]); + expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true); + }); + + test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-undo-shortcut"); + + await archiveFromRowButton(page, worktree.workspaceId); + await pressUndoShortcut(page); + + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible(); + await expect(undoButton(page)).toHaveCount(0); + await expectHostWorkspaceSettledActive(client, worktree.workspaceId); + // Once the host settled, any remove the canceled archive sent has already arrived. + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible(); + await expectDirectoryPresence(worktree.workspaceDirectory, true); + }); + + test("Undo after the worktree was removed recreates it", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-undo-recreate"); + + await archiveFromRowButton(page, worktree.workspaceId); + // Hovering the toast holds it, and with it the undo, open while the archive finishes. + await undoButton(page).hover(); + await expectDirectoryPresence(worktree.workspaceDirectory, false); + await undoButton(page).click(); + + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 }); + await expectDirectoryPresence(worktree.workspaceDirectory, true); + }); + + test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({ + page, + }) => { + await page.clock.install({ time: Date.now() }); + const worktree = await seedWorktree(page, "archive-undo-hold"); + + await archiveFromRowButton(page, worktree.workspaceId); + await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } }); + await undoButton(page).hover(); + await outlastUndoWindow(page); + await expect(undoButton(page)).toBeVisible(); + + await page.mouse.move(0, 0); + await outlastUndoWindow(page); + await expect(undoButton(page)).toHaveCount(0); + + // The toast took the undo with it. + await pressUndoShortcut(page); + await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId); + }); + + test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => { + const teardown = createGatedTeardown(); + const repo = await createTempGitRepo("archive-undo-tabs-", { + paseoConfig: teardown.paseoConfig, + }); + const seedClient = await connectSeedClient(); + try { + await openProjectViaDaemon(client, repo.path); + const worktree = await createWorktreeViaDaemon(client, { + cwd: repo.path, + slug: `archive-undo-tabs-${Date.now()}`, + }); + worktreeDirectories.add(worktree.workspaceDirectory); + const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId }; + const agents = [ + await createMockIdleAgent(seedClient, { ...target, title: "first" }), + await createMockIdleAgent(seedClient, { ...target, title: "second" }), + ] as const; + await openWorkspaceWithAgents(page, [agents[0], agents[1]]); + const tabsBefore = await getWorkspaceTabTestIds(page); + + await archiveFromRowButton(page, worktree.workspaceId); + // The archive has taken its agents down and now parks in teardown. + await teardown.expectStarted(); + await undoButton(page).click(); + await workspaceRow(page, worktree.workspaceId).click(); + + for (const agent of agents) { + await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible(); + } + teardown.release(); + await expectHostWorkspaceSettledActive(client, worktree.workspaceId); + await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore); + } finally { + teardown.release(); + await seedClient.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); + } + }); + + test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => { + const worktree = await seedWorktree(page, "archive-undo-none"); + + await pressUndoShortcut(page); + + await expect(undoButton(page)).toHaveCount(0); + await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/support/helpers/workspace-archive-undo.ts b/packages/app/e2e/support/helpers/workspace-archive-undo.ts new file mode 100644 index 0000000000..0eadc58644 --- /dev/null +++ b/packages/app/e2e/support/helpers/workspace-archive-undo.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { expect } from "../fixtures"; +import type { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { getServerId } from "./server-id"; +import { expectWorkspaceAbsentFromSidebar } from "./sidebar"; + +type DaemonClient = Awaited>; + +const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z"; +/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */ +const UNDO_WINDOW_MS = 6000; + +export function workspaceRow(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +export function rowArchiveButton(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`); +} + +export function rowKebab(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`); +} + +export function undoButton(page: Page) { + return page.getByRole("button", { name: /^Undo/ }); +} + +export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise { + const box = await page + .getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`) + .boundingBox(); + if (!box) throw new Error(`Title of ${workspaceId} is not laid out`); + return box.width; +} + +/** Archives a workspace with its row's hover button and waits for the undo toast. */ +export async function archiveFromRowButton(page: Page, workspaceId: string): Promise { + page.once("dialog", (dialog) => void dialog.accept()); + await workspaceRow(page, workspaceId).hover(); + await rowArchiveButton(page, workspaceId).click(); + await expectWorkspaceAbsentFromSidebar(page, workspaceId); + await expect(undoButton(page)).toBeVisible(); +} + +export async function pressUndoShortcut(page: Page): Promise { + await page.keyboard.press(UNDO_SHORTCUT); +} + +/** Moves the page clock past the moment an unheld archived toast would have closed. */ +export async function outlastUndoWindow(page: Page): Promise { + await page.clock.fastForward(UNDO_WINDOW_MS + 1000); +} + +/** Waits until the host lists the workspace as active with no archive running on it. */ +export async function expectHostWorkspaceSettledActive( + client: DaemonClient, + workspaceId: string, +): Promise { + await expect + .poll( + async () => { + const { entries } = await client.fetchWorkspaces(); + const entry = entries.find((candidate) => candidate.id === workspaceId); + return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null; + }, + { timeout: 30_000 }, + ) + .toEqual({ active: true, archiving: false }); +} + +export async function expectDirectoryPresence(directory: string, present: boolean): Promise { + await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present); +} + +export interface GatedTeardown { + /** The `paseo.json` config whose worktree teardown parks until released. */ + paseoConfig: Record; + expectStarted(): Promise; + release(): void; +} + +/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */ +export function createGatedTeardown(): GatedTeardown { + const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-")); + const startedPath = path.join(gateDir, "started"); + const releasePath = path.join(gateDir, "release"); + const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`; + return { + paseoConfig: { worktree: { teardown: [teardown] } }, + expectStarted: () => expectDirectoryPresence(startedPath, true), + release: () => writeFileSync(releasePath, "release\n"), + }; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 8548b4bb76..b4ca122f77 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -55,6 +55,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; import { WorkspaceRenameHost } from "@/components/workspace-rename-host"; +import { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo"; import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; @@ -557,6 +558,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5000e1f067..d6a810e74e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -649,6 +649,7 @@ function WorkspaceRowRightGroup({ const { trailingPresentation, showKebab: showKebabInSlot, + showArchiveButton, showScrim, renderSlot, reserveSlotWidth, @@ -675,6 +676,8 @@ function WorkspaceRowRightGroup({ {onArchive ? ( {kebab.showKebab && onArchive ? ( ; const unpinLeadingIcon = ; +/** Pointer-only, so it is always the kebab's pointer size. */ +export const ARCHIVE_BUTTON_SIZE = 18; + const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); @@ -79,6 +83,33 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) { return ; } +function renderArchiveIcon({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { + return ; +} + +/** One-click archive beside the kebab, for pointer rows that reveal it on hover. */ +export function SidebarWorkspaceArchiveButton({ + workspaceKey, + onArchive, +}: { + workspaceKey: string; + onArchive: () => void; +}) { + const { t } = useTranslation(); + return ( + + {renderArchiveIcon} + + ); +} + export interface SidebarWorkspaceMenuProps { workspaceKey: string; serverId?: string; @@ -437,6 +468,12 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere return [styles.trigger, hovered && styles.triggerHovered]; } +function archiveButtonStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.archiveButton, hovered && styles.triggerHovered]; +} + const styles = StyleSheet.create((theme) => ({ trigger: { // Larger on touch so the kebab is an easy tap target. @@ -455,4 +492,13 @@ const styles = StyleSheet.create((theme) => ({ triggerHovered: { backgroundColor: theme.colors.surface2, }, + // The kebab's box without its offsets: the archive glyph fills its view box, so it needs + // none of the nudging the three dots do. The kebab's marginLeft is the gap between them. + archiveButton: { + width: ARCHIVE_BUTTON_SIZE, + height: ARCHIVE_BUTTON_SIZE, + alignItems: "center", + justifyContent: "center", + borderRadius: 4, + }, })); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 7fc904f3b3..aef846c4a3 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -29,7 +29,11 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; import { DensityIcon } from "@/components/ui/density-icon"; -import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; +import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; +import { + ARCHIVE_BUTTON_SIZE, + SidebarWorkspaceArchiveButton, +} from "@/components/sidebar/sidebar-workspace-menu"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; const needsInputColorMapping = (theme: Theme) => ({ @@ -156,7 +160,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte )} - + {workspaceLabel} {children} @@ -330,6 +338,8 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ position: "absolute", top: 0, right: 0, + flexDirection: "row", + alignItems: "flex-start", }, })); @@ -370,6 +380,7 @@ export function resolveTrailingActionVisibility({ }): { trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; + showArchiveButton: boolean; showScrim: boolean; renderSlot: boolean; reserveSlotWidth: boolean; @@ -384,6 +395,9 @@ export function resolveTrailingActionVisibility({ return { trailingPresentation, showKebab, + // One-click archive is a pointer affordance: on touch it would sit a few pixels from the + // kebab and turn a miss into an archive. + showArchiveButton: showKebab && isHovered && !isTouchPlatform, // The scrim paints the row's own hover background, so it can only be drawn on a hovered // row — over an unhovered one the gradient fades to the wrong color. That is also why // touch, which shows the kebab without ever hovering, never gets one. @@ -432,23 +446,46 @@ export function SidebarWorkspaceTrailingActionBase({ ); } +// The archive button plus the kebab's marginLeft, which is the gap between them. +const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2; + export function SidebarWorkspaceTrailingActionOverlay({ visible, scrimBackdrop, + workspaceKey, + onArchiveButtonPress, children, }: { visible: boolean; /** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */ scrimBackdrop?: SidebarSurfaceBackdrop; + workspaceKey: string; + /** + * Shows the archive button left of the kebab. It overlays the title like the kebab does, so + * the scrim widens to cover it rather than the slot reserving its width. + */ + onArchiveButtonPress?: () => void; children: ReactNode; }) { if (!visible || !children) return null; return ( <> {scrimBackdrop ? ( - + ) : null} - {children} + + {onArchiveButtonPress ? ( + + ) : null} + {children} + ); } diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 2488e7956e..d458b390ed 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -404,6 +404,7 @@ function WorkspaceRowTrailingActions({ const { trailingPresentation, showKebab: showKebabInSlot, + showArchiveButton, showScrim, renderSlot, reserveSlotWidth, @@ -430,6 +431,8 @@ function WorkspaceRowTrailingActions({ {onArchive ? ( void; } export interface ToastState { @@ -45,6 +47,7 @@ export interface ToastState { variant: ToastVariant; durationMs: number | null; testID?: string; + onDismiss?: () => void; } /** Dismisses the toast it was returned for; a no-op once another toast replaced it. */ @@ -78,40 +81,56 @@ export function useToastHost(): { dismiss: () => void; } { const { t } = useTranslation(); - const [toast, setToast] = useState(null); + const [toast, setToastState] = useState(null); + const toastRef = useRef(null); const idRef = useRef(0); - const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => { - const nativeMessage = typeof content === "string" ? content.trim() : null; - if (!content || nativeMessage === "") { - return noopDismiss; - } - - const variant = options?.variant ?? "default"; - const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs; - const nativeAndroid = options?.nativeAndroid ?? false; - - if (Platform.OS === "android" && nativeAndroid && nativeMessage) { - const duration = - durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; - ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); - return noopDismiss; - } - - idRef.current += 1; - const id = idRef.current; - setToast({ - id, - content, - nativeMessage, - icon: options?.icon, - variant, - durationMs, - testID: options?.testID, - }); - return () => setToast((current) => (current?.id === id ? null : current)); + const setToast = useCallback((next: ToastState | null) => { + const previous = toastRef.current; + if (previous === next) return; + toastRef.current = next; + setToastState(next); + previous?.onDismiss?.(); }, []); + const show = useCallback( + (content: ReactNode, options?: ToastShowOptions): DismissToast => { + const nativeMessage = typeof content === "string" ? content.trim() : null; + if (!content || nativeMessage === "") { + return noopDismiss; + } + + const variant = options?.variant ?? "default"; + const durationMs = + options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs; + const nativeAndroid = options?.nativeAndroid ?? false; + + if (Platform.OS === "android" && nativeAndroid && nativeMessage) { + const duration = + durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; + ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); + return noopDismiss; + } + + idRef.current += 1; + const id = idRef.current; + setToast({ + id, + content, + nativeMessage, + icon: options?.icon, + variant, + durationMs, + testID: options?.testID, + onDismiss: options?.onDismiss, + }); + return () => { + if (toastRef.current?.id === id) setToast(null); + }; + }, + [setToast], + ); + const api = useMemo( () => ({ show, @@ -127,7 +146,7 @@ export function useToastHost(): { const dismiss = useCallback(() => { setToast(null); - }, []); + }, [setToast]); return { api, toast, dismiss }; } diff --git a/packages/app/src/components/ui/trailing-action-scrim.tsx b/packages/app/src/components/ui/trailing-action-scrim.tsx index e106bf3ec5..0b43136bd1 100644 --- a/packages/app/src/components/ui/trailing-action-scrim.tsx +++ b/packages/app/src/components/ui/trailing-action-scrim.tsx @@ -6,9 +6,18 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; export const SCRIM_WIDTH = 48; -const SCRIM_SOLID_OFFSET = "55%"; +// The fade is a fixed length so a wider scrim only adds solid surface under its actions. +const SCRIM_FADE_WIDTH = SCRIM_WIDTH * 0.55; -function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) { +function TrailingActionScrimSvg({ + gradientId, + color, + solidOffset, +}: { + gradientId: string; + color: string; + solidOffset: string; +}) { return ( @@ -16,7 +25,7 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in some engines and leaves a grey fringe. */} - + @@ -39,17 +48,22 @@ const backdropColorMappings: Record { color: /** Fades trailing content into the surface beneath an absolutely overlaid action. */ export function TrailingActionScrim({ backdrop, + width = SCRIM_WIDTH, testID, }: { backdrop: SurfaceBackdrop; + /** Covers more than one trailing action; the fade keeps its length. */ + width?: number; testID?: string; }) { // React-generated ids contain characters that are invalid inside SVG fragment references. const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + const solidOffset = `${Math.round((SCRIM_FADE_WIDTH / width) * 100)}%`; return ( - + @@ -57,11 +71,11 @@ export function TrailingActionScrim({ } const styles = StyleSheet.create({ - scrim: { + scrim: (width: number) => ({ position: "absolute", top: 0, bottom: 0, right: 0, - width: SCRIM_WIDTH, - }, + width, + }), }); diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index c0c3bea91f..7e942f234b 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -4,7 +4,10 @@ import { clearWorkspaceArchivePending, isWorkspaceArchivePending, markWorkspaceArchivePending, + markWorkspaceArchiveUndoPending, shouldSuppressWorkspaceForLocalArchive, + settleWorkspaceArchiveIntent, + shouldSuppressWorkspaceRemovalForLocalUndo, } from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { @@ -116,3 +119,41 @@ describe("workspace archive pending suppression", () => { clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" }); }); }); + +describe("workspace archive undo intent", () => { + const target = { serverId: "server-1", workspaceId: "/repo/worktree" }; + + it("lets host upserts through and holds host removes while an undo is pending", () => { + markWorkspaceArchivePending(target); + const undo = markWorkspaceArchiveUndoPending(target); + + expect({ + upsertSuppressed: shouldSuppressWorkspaceForLocalArchive({ + serverId: target.serverId, + workspace: workspace({ archivingAt: null }), + }), + removeSuppressed: shouldSuppressWorkspaceRemovalForLocalUndo(target), + }).toEqual({ upsertSuppressed: false, removeSuppressed: true }); + + settleWorkspaceArchiveIntent(undo); + expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(false); + }); + + it("keeps a newer intent when the request behind an older one settles late", () => { + const firstArchive = markWorkspaceArchivePending(target); + const firstUndo = markWorkspaceArchiveUndoPending(target); + settleWorkspaceArchiveIntent(firstArchive); + clearWorkspaceArchivePending(target); + expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(true); + + markWorkspaceArchivePending(target); + settleWorkspaceArchiveIntent(firstUndo); + expect(isWorkspaceArchivePending(target)).toBe(true); + + const secondUndo = markWorkspaceArchiveUndoPending(target); + settleWorkspaceArchiveIntent(firstUndo); + expect(shouldSuppressWorkspaceRemovalForLocalUndo(target)).toBe(true); + + settleWorkspaceArchiveIntent(secondUndo); + }); +}); diff --git a/packages/app/src/contexts/session-workspace-upserts.ts b/packages/app/src/contexts/session-workspace-upserts.ts index 1b4279631f..2957a90b0d 100644 --- a/packages/app/src/contexts/session-workspace-upserts.ts +++ b/packages/app/src/contexts/session-workspace-upserts.ts @@ -1,77 +1,142 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -interface PendingWorkspaceArchive { - workspaceId: string; +/** + * What this client last asked the host to do with a workspace, while the host catches up. + * + * - `archive`: the row is hidden; host upserts for it are dropped until its remove arrives. + * - `undo`: the row is back; host removes for it are dropped until the undo response arrives. + * + * Each request gets its own intent. A request settles or checks only the intent it set, so a + * late response for an earlier request never touches the intent of a newer one. + */ +export interface WorkspaceArchiveIntent { + readonly kind: "archive" | "undo"; } -const pendingWorkspaceArchivesByServer = new Map>(); +const intentsByServer = new Map>(); +const intentListeners = new Set<() => void>(); +const targetByIntent = new WeakMap< + WorkspaceArchiveIntent, + { serverId: string; workspaceId: string } +>(); -function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string { - return `${input.serverId.trim()}::${input.workspaceId.trim()}`; +interface WorkspaceIntentTarget { + serverId: string; + workspaceId?: string | null; +} + +function resolveTarget( + input: WorkspaceIntentTarget, +): { serverId: string; workspaceId: string } | null { + const serverId = input.serverId.trim(); + const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); + if (!serverId || !workspaceId) return null; + return { serverId, workspaceId }; +} + +function readIntent(input: WorkspaceIntentTarget): WorkspaceArchiveIntent | null { + const target = resolveTarget(input); + if (!target) return null; + return intentsByServer.get(target.serverId)?.get(target.workspaceId) ?? null; +} + +function setIntent( + input: WorkspaceIntentTarget, + kind: WorkspaceArchiveIntent["kind"], +): WorkspaceArchiveIntent { + const intent: WorkspaceArchiveIntent = { kind }; + const target = resolveTarget(input); + if (!target) return intent; + targetByIntent.set(intent, target); + const intents = intentsByServer.get(target.serverId) ?? new Map(); + intents.set(target.workspaceId, intent); + intentsByServer.set(target.serverId, intents); + notifyIntentListeners(); + return intent; +} + +function deleteIntent(target: { serverId: string; workspaceId: string }): void { + const intents = intentsByServer.get(target.serverId); + if (!intents) return; + intents.delete(target.workspaceId); + if (intents.size === 0) intentsByServer.delete(target.serverId); + notifyIntentListeners(); +} + +function notifyIntentListeners(): void { + for (const listener of intentListeners) listener(); +} + +/** Calls `listener` whenever any workspace's archive intent changes. */ +export function subscribeWorkspaceArchiveIntents(listener: () => void): () => void { + intentListeners.add(listener); + return () => { + intentListeners.delete(listener); + }; +} + +function clearIntentOfKind( + input: WorkspaceIntentTarget, + kind: WorkspaceArchiveIntent["kind"], +): void { + const target = resolveTarget(input); + if (!target || readIntent(target)?.kind !== kind) return; + deleteIntent(target); +} + +/** True while `intent` is still the latest thing this client asked for its workspace. */ +export function isWorkspaceArchiveIntentCurrent(intent: WorkspaceArchiveIntent): boolean { + const target = targetByIntent.get(intent); + return target !== undefined && readIntent(target) === intent; +} + +/** The request behind `intent` settled. A newer request's intent stays in place. */ +export function settleWorkspaceArchiveIntent(intent: WorkspaceArchiveIntent): void { + const target = targetByIntent.get(intent); + if (target && readIntent(target) === intent) deleteIntent(target); } export function markWorkspaceArchivePending(input: { serverId: string; workspaceId: string; -}): void { - const serverId = input.serverId.trim(); - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (!serverId || !workspaceId) { - return; - } - - const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map(); - archives.set(pendingArchiveKey({ serverId, workspaceId }), { - workspaceId, - }); - pendingWorkspaceArchivesByServer.set(serverId, archives); +}): WorkspaceArchiveIntent { + return setIntent(input, "archive"); } +/** The host removed the workspace, which settles whichever archive this client has pending. */ export function clearWorkspaceArchivePending(input: { serverId: string; workspaceId: string; }): void { - const serverId = input.serverId.trim(); - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if (!serverId || !workspaceId) { - return; - } - - const archives = pendingWorkspaceArchivesByServer.get(serverId); - if (!archives) { - return; - } - archives.delete(pendingArchiveKey({ serverId, workspaceId })); - if (archives.size === 0) { - pendingWorkspaceArchivesByServer.delete(serverId); - } + clearIntentOfKind(input, "archive"); } -export function isWorkspaceArchivePending(input: { +export function isWorkspaceArchivePending(input: WorkspaceIntentTarget): boolean { + return readIntent(input)?.kind === "archive"; +} + +export function markWorkspaceArchiveUndoPending(input: { serverId: string; - workspaceId?: string | null; -}): boolean { - const serverId = input.serverId.trim(); - if (!serverId) { - return false; - } + workspaceId: string; +}): WorkspaceArchiveIntent { + return setIntent(input, "undo"); +} - const archives = pendingWorkspaceArchivesByServer.get(serverId); - if (!archives) { - return false; - } - - const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))); +export function isWorkspaceArchiveUndoPending(input: WorkspaceIntentTarget): boolean { + return readIntent(input)?.kind === "undo"; } export function shouldSuppressWorkspaceForLocalArchive(input: { serverId: string; workspace: WorkspaceDescriptor; }): boolean { - return isWorkspaceArchivePending({ - serverId: input.serverId, - workspaceId: input.workspace.id, - }); + return isWorkspaceArchivePending({ serverId: input.serverId, workspaceId: input.workspace.id }); +} + +export function shouldSuppressWorkspaceRemovalForLocalUndo(input: { + serverId: string; + workspaceId: string; +}): boolean { + return isWorkspaceArchiveUndoPending(input); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f86d81b6e9..fda62bd508 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1341,6 +1341,9 @@ export const ar: TranslationResources = { hostDisconnected: "Host غير متصل", hideFailed: "فشل في إخفاء مساحة العمل", archiveFailed: "فشل في أرشفة مساحة العمل", + archived: "تمت أرشفة مساحة العمل", + undoArchive: "تراجع", + undoArchiveFailed: "تعذّر التراجع عن الأرشفة", }, }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 103a981abc..79baa3a48c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1349,6 +1349,9 @@ export const en = { hostDisconnected: "Host is not connected", hideFailed: "Failed to hide workspace", archiveFailed: "Failed to archive workspace", + archived: "Workspace archived", + undoArchive: "Undo", + undoArchiveFailed: "Couldn't undo the archive", }, }, }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 48737ee20e..2841ec345f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1377,6 +1377,9 @@ export const es: TranslationResources = { hostDisconnected: "Hostno está conectado", hideFailed: "No se pudo ocultar el espacio de trabajo", archiveFailed: "No se pudo archivar el espacio de trabajo", + archived: "Espacio de trabajo archivado", + undoArchive: "Deshacer", + undoArchiveFailed: "No se pudo deshacer el archivado", }, }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0d2e4aabce..9324fa84c8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1371,6 +1371,9 @@ export const fr: TranslationResources = { hostDisconnected: "L’hôte n’est pas connecté", hideFailed: "Impossible de masquer l’espace de travail", archiveFailed: "Impossible d’archiver l’espace de travail", + archived: "Espace de travail archivé", + undoArchive: "Annuler", + undoArchiveFailed: "Impossible d’annuler l’archivage", }, }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ca93aa3106..58c2ec59df 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1355,6 +1355,9 @@ export const ja: TranslationResources = { hostDisconnected: "ホストが接続されていません", hideFailed: "ワークスペースの非表示に失敗しました", archiveFailed: "ワークスペースのアーカイブに失敗しました", + archived: "ワークスペースをアーカイブしました", + undoArchive: "元に戻す", + undoArchiveFailed: "アーカイブを元に戻せませんでした", }, }, }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 51ea225983..12e2909ddc 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1348,6 +1348,9 @@ export const ko: TranslationResources = { hostDisconnected: "호스트가 연결되어 있지 않습니다", hideFailed: "워크스페이스를 숨기지 못했습니다", archiveFailed: "워크스페이스를 보관하지 못했습니다.", + archived: "워크스페이스를 보관했습니다", + undoArchive: "실행 취소", + undoArchiveFailed: "보관을 취소하지 못했습니다", }, }, }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5c2695f3bf..88f400907d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1367,6 +1367,9 @@ export const ptBR: TranslationResources = { hostDisconnected: "Host não está conectado", hideFailed: "Falha ao ocultar workspace", archiveFailed: "Falha ao arquivar workspace", + archived: "Workspace arquivado", + undoArchive: "Desfazer", + undoArchiveFailed: "Não foi possível desfazer o arquivamento", }, }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ea8fb1eb25..abab6a8395 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1359,6 +1359,9 @@ export const ru: TranslationResources = { hostDisconnected: "Хост не подключён", hideFailed: "Не удалось скрыть рабочее пространство", archiveFailed: "Не удалось архивировать рабочее пространство", + archived: "Рабочее пространство архивировано", + undoArchive: "Отменить", + undoArchiveFailed: "Не удалось отменить архивацию", }, }, }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 54454d9bff..3a1c9bf29b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1330,6 +1330,9 @@ export const zhCN: TranslationResources = { hostDisconnected: "Host 未连接", hideFailed: "隐藏 workspace 失败", archiveFailed: "归档工作区失败", + archived: "工作区已归档", + undoArchive: "撤销", + undoArchiveFailed: "无法撤销归档", }, }, }, diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 1bf78d923f..ef656a40a5 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -52,6 +52,7 @@ export type KeyboardActionId = | "workspace.project.pick" | "worktree.new" | "workspace.archive" + | "workspace.archive.undo" | "workspace.pin" | "view.toggle.focus" | "theme.cycle" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index a3f2b4c515..ba27e4b354 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -51,6 +51,7 @@ export type KeyboardActionId = | "workspace.project.pick" | "worktree.new" | "workspace.archive" + | "workspace.archive.undo" | "workspace.pin" // Command-center only: no keybind, so these are absent from route-shortcut.ts. | "workspace.rename" @@ -109,6 +110,7 @@ export type KeyboardActionDefinition = | { id: "workspace.project.pick"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "workspace.archive"; scope: KeyboardActionScope } + | { id: "workspace.archive.undo"; scope: KeyboardActionScope } | { id: "workspace.pin"; scope: KeyboardActionScope } | { id: "workspace.rename"; scope: KeyboardActionScope } | { id: "workspace.setup.show"; scope: KeyboardActionScope }; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 29179901ee..9eddf4b5b5 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -408,6 +408,25 @@ describe("keyboard-shortcuts", () => { }, ); + describe("undo archive workspace", () => { + it.each([ + { isMac: true, event: { key: "z", code: "KeyZ", metaKey: true } }, + { isMac: false, event: { key: "z", code: "KeyZ", ctrlKey: true } }, + ])("routes Cmd/Ctrl+Z outside text and terminals (mac: $isMac)", ({ isMac, event }) => { + expectShortcutResolution({ event, context: { isMac }, action: "workspace.archive.undo" }); + }); + + it.each(["editable", "message-input", "terminal"] as const)( + "leaves Cmd+Z to the focused %s", + (focusScope) => { + expectNoShortcutResolution({ + event: { key: "z", code: "KeyZ", metaKey: true }, + context: { isMac: true, focusScope }, + }); + }, + ); + }); + it("leaves Escape to an editable field", () => { expectNoShortcutResolution({ event: { key: "Escape", code: "Escape" }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index b55e78fc25..d6db467da3 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -169,6 +169,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record = { "workspace.new": { id: "workspace.new", scope: "sidebar" }, "workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" }, "workspace.archive": { id: "workspace.archive", scope: "sidebar" }, + "workspace.archive.undo": { id: "workspace.archive.undo", scope: "global" }, "workspace.pin": { id: "workspace.pin", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, "workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" }, diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index 792c005100..e3aa345d74 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -9,6 +9,8 @@ import { import { clearWorkspaceArchivePending, markWorkspaceArchivePending, + markWorkspaceArchiveUndoPending, + settleWorkspaceArchiveIntent, } from "@/contexts/session-workspace-upserts"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { WorkspaceDirectoryReplica } from "./workspace-replica"; @@ -378,3 +380,53 @@ it("restores a missing workspace after an archive failure without replacing a li store.clearSession(serverId); } }); + +it("keeps a workspace whose archive is being undone when the host reports its removal", () => { + const serverId = "workspace-archive-undo"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const restored = normalizeWorkspaceDescriptor(workspace("restored")); + + const undo = markWorkspaceArchiveUndoPending({ serverId, workspaceId: restored.id }); + try { + replica.acceptWorkspaces([restored]); + + expect(replica.applyDelta({ kind: "remove", id: restored.id })).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(restored.id)).toBe(true); + + settleWorkspaceArchiveIntent(undo); + replica.applyDelta({ kind: "remove", id: restored.id }); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(restored.id)).toBe(false); + } finally { + settleWorkspaceArchiveIntent(undo); + store.clearSession(serverId); + } +}); + +it("keeps a workspace whose archive is being undone when a refresh snapshot omits it", () => { + const serverId = "workspace-archive-undo-refresh"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const restored = normalizeWorkspaceDescriptor(workspace("restored")); + const other = normalizeWorkspaceDescriptor(workspace("other")); + const workspaceIds = () => [ + ...(useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []), + ]; + + const undo = markWorkspaceArchiveUndoPending({ serverId, workspaceId: restored.id }); + try { + replica.acceptWorkspaces([restored]); + + replica.commitSnapshot({ workspaces: new Map([[other.id, other]]), projects: new Map() }, []); + expect(workspaceIds().sort()).toEqual(["other", "restored"]); + + settleWorkspaceArchiveIntent(undo); + replica.commitSnapshot({ workspaces: new Map([[other.id, other]]), projects: new Map() }, []); + expect(workspaceIds()).toEqual(["other"]); + } finally { + settleWorkspaceArchiveIntent(undo); + store.clearSession(serverId); + } +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 8f21ebd214..b5a7895467 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -14,6 +14,7 @@ import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { clearWorkspaceArchivePending, shouldSuppressWorkspaceForLocalArchive, + shouldSuppressWorkspaceRemovalForLocalUndo, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; @@ -107,6 +108,15 @@ export class WorkspaceDirectoryReplica { workspaces.set(workspaceId, workspace); } } + // A snapshot taken while an undo runs can predate the restore; the host publishes the row. + for (const [workspaceId, workspace] of this.workspaces) { + if ( + !workspaces.has(workspaceId) && + shouldSuppressWorkspaceRemovalForLocalUndo({ serverId: this.serverId, workspaceId }) + ) { + workspaces.set(workspaceId, workspace); + } + } for (const workspace of workspaces.values()) this.applySetupStatus(workspace); this.workspaces = workspaces; this.projects = new Map(snapshot.projects); @@ -173,6 +183,12 @@ export class WorkspaceDirectoryReplica { kind: "remove"; }, ): DirectoryReplicaMutation[] { + // The archive this remove reports is being undone; the host publishes the restored row. + if ( + shouldSuppressWorkspaceRemovalForLocalUndo({ serverId: this.serverId, workspaceId: delta.id }) + ) { + return []; + } this.deleteWorkspace(delta.id); const mutations: DirectoryReplicaMutation[] = [ { kind: "workspace", type: "delete", id: delta.id }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d30893078c..f4b131d041 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -137,6 +137,7 @@ import { useWorkspace } from "@/stores/session-store-hooks"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useWorkspaceArchiveUndoRunning } from "@/workspace/archive-undo"; import { useStableEvent } from "@/hooks/use-stable-event"; import { removeResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; import { createWorkspaceBrowser, useBrowserStore } from "@/desktop/browser/store"; @@ -1249,6 +1250,11 @@ function WorkspaceScreenContent({ const hasHydratedAgents = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, ); + // An undo puts the row back before the host restores its agents. Their tabs stay until it settles. + const isArchiveUndoRunning = useWorkspaceArchiveUndoRunning({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); const workspaceRouteState = useResolvedWorkspaceRouteState({ serverId: normalizedServerId, workspace: workspaceDescriptor, @@ -1549,7 +1555,7 @@ function WorkspaceScreenContent({ persistenceKey, buildWorkspaceTabSnapshot({ agentVisibility: workspaceAgentVisibility, - agentsHydrated: hasHydratedAgents, + agentsHydrated: hasHydratedAgents && !isArchiveUndoRunning, terminalsHydrated: terminalsQuery.isSuccess, knownTerminalIds, standaloneTerminalIds, @@ -1560,6 +1566,7 @@ function WorkspaceScreenContent({ ); }, [ hasHydratedAgents, + isArchiveUndoRunning, hasHydratedWorkspaceLayoutStore, pendingTerminalCreateInput, createTerminalMutation.isPending, diff --git a/packages/app/src/workspace/archive-undo/index.ts b/packages/app/src/workspace/archive-undo/index.ts new file mode 100644 index 0000000000..c6107367aa --- /dev/null +++ b/packages/app/src/workspace/archive-undo/index.ts @@ -0,0 +1,8 @@ +export { + archiveWorkspaceWithUndo, + undoLatestWorkspaceArchive, + type UndoableWorkspaceArchiveClient, +} from "./internal/undo-window"; +export { WorkspaceArchiveUndoShortcutHost } from "./internal/undo-shortcut-host"; +export { supportsWorkspaceArchiveUndo } from "./internal/capability"; +export { useWorkspaceArchiveUndoRunning } from "./internal/use-workspace-archive-undo-running"; diff --git a/packages/app/src/workspace/archive-undo/internal/capability.ts b/packages/app/src/workspace/archive-undo/internal/capability.ts new file mode 100644 index 0000000000..5f5a4a7c20 --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/capability.ts @@ -0,0 +1,8 @@ +import { useSessionStore } from "@/stores/session-store"; + +export function supportsWorkspaceArchiveUndo(serverId: string): boolean { + return ( + useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.workspaceArchiveUndo === + true + ); +} diff --git a/packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx b/packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx new file mode 100644 index 0000000000..3d2ea9c4e4 --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/undo-shortcut-host.tsx @@ -0,0 +1,22 @@ +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { hasUndoableWorkspaceArchive, undoLatestWorkspaceArchive } from "./undo-window"; + +const UNDO_ARCHIVE_ACTIONS: readonly KeyboardActionId[] = ["workspace.archive.undo"]; + +/** + * Cmd/Ctrl+Z for the archive toast. Mounted once at the app root because the row that started + * the archive unmounts as soon as its workspace hides. It claims the key only while an archive + * can be undone, so the key falls through to text undo the rest of the time. + */ +export function WorkspaceArchiveUndoShortcutHost(): null { + useKeyboardActionHandler({ + handlerId: "workspace-archive-undo-global", + actions: UNDO_ARCHIVE_ACTIONS, + enabled: true, + priority: 0, + isActive: hasUndoableWorkspaceArchive, + handle: undoLatestWorkspaceArchive, + }); + return null; +} diff --git a/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts b/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts new file mode 100644 index 0000000000..efa9a539ac --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/undo-window.test.ts @@ -0,0 +1,301 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ToastApi, ToastShowOptions } from "@/components/toast-host"; +import { clearWorkspaceArchivePending } from "@/contexts/session-workspace-upserts"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { seedSessionHosts } from "@/test/seed-session"; +import { + archiveWorkspaceWithUndo, + undoLatestWorkspaceArchive, + type UndoableWorkspaceArchiveClient, +} from "@/workspace/archive-undo"; + +const SERVER_ID = "workspace-archive-undo-window"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function workspace(id: string): WorkspaceDescriptor { + return { + createdAt: null, + lastUserMessageAt: null, + id, + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo/project", + workspaceDirectory: `/repo/project/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + status: "done", + archivingAt: null, + statusEnteredAt: null, + diffStat: null, + scripts: [], + }; +} + +/** A toast host with one slot, like the app's: a new toast replaces the shown one. */ +function createToastHost() { + let shown: { content: ReactNode; options?: ToastShowOptions; dismiss: () => void } | null = null; + const errors: string[] = []; + const api: ToastApi = { + show: (content, options) => { + const previous = shown; + const entry = { + content, + options, + dismiss: () => { + if (shown !== entry) return; + shown = null; + options?.onDismiss?.(); + }, + }; + shown = entry; + previous?.options?.onDismiss?.(); + return entry.dismiss; + }, + copied: () => {}, + error: (message) => { + errors.push(message); + api.show(message); + }, + }; + return { + api, + errors, + get shownTestID() { + return shown?.options?.testID ?? null; + }, + timeOut: () => shown?.dismiss(), + }; +} + +type ArchivePayload = Awaited>; + +function createClient() { + const archives = new Map>(); + const undos = new Map>(); + const client: UndoableWorkspaceArchiveClient = { + archiveWorkspace: (workspaceId) => { + const response = deferred(); + archives.set(workspaceId, response); + return response.promise; + }, + undoWorkspaceArchive: (workspaceId) => { + const response = deferred(); + undos.set(workspaceId, response); + return response.promise; + }, + }; + return { + client, + finishArchive: (workspaceId: string, payload: Partial = {}) => + archives.get(workspaceId)?.resolve({ error: null, ...payload }), + undoRequested: (workspaceId: string) => undos.has(workspaceId), + finishUndo: (workspaceId: string) => undos.get(workspaceId)?.resolve(), + failUndo: (workspaceId: string, error: Error) => undos.get(workspaceId)?.reject(error), + }; +} + +function storedWorkspace(id: string): WorkspaceDescriptor | undefined { + return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id); +} + +function startArchive(input: { + host: ReturnType; + toast: ReturnType; + workspaceId: string; + committed: string[]; +}) { + return archiveWorkspaceWithUndo({ + client: input.host.client, + workspace: { serverId: SERVER_ID, workspaceId: input.workspaceId }, + toast: input.toast.api, + onArchiveCommitted: () => input.committed.push(input.workspaceId), + }); +} + +beforeEach(() => { + seedSessionHosts([SERVER_ID]); + useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a"), workspace("b")]); +}); + +afterEach(() => { + undoLatestWorkspaceArchive(); + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" }); + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "b" }); + seedSessionHosts([]); + useSessionStore.setState((state) => ({ ...state, sessions: {} })); +}); + +describe("archiveWorkspaceWithUndo", () => { + it("commits the archive only once its toast is gone", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + expect(toast.shownTestID).toBe("workspace-archived-toast"); + host.finishArchive("a"); + await archive; + expect(committed).toEqual([]); + + toast.timeOut(); + expect({ committed, undone: undoLatestWorkspaceArchive() }).toEqual({ + committed: ["a"], + undone: false, + }); + }); + + it("keeps the layout when another client restores the workspace before the toast closes", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + host.finishArchive("a"); + await archive; + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "a" }); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [workspace("a")]); + toast.timeOut(); + + expect({ committed, row: storedWorkspace("a") }).toEqual({ + committed: [], + row: workspace("a"), + }); + }); + + it("commits when the archive finishes after its toast already timed out", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + toast.timeOut(); + expect(committed).toEqual([]); + host.finishArchive("a"); + await archive; + + expect(committed).toEqual(["a"]); + }); + + it("undo during a running archive brings the row back and never commits", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const archive = startArchive({ host, toast, workspaceId: "a", committed }); + expect(storedWorkspace("a")).toBeUndefined(); + + expect(undoLatestWorkspaceArchive()).toBe(true); + expect({ + row: storedWorkspace("a"), + undoRequested: host.undoRequested("a"), + toast: toast.shownTestID, + }).toEqual({ row: workspace("a"), undoRequested: true, toast: null }); + + host.finishArchive("a", { canceled: true, error: "Workspace archive was undone" }); + await archive; + host.finishUndo("a"); + toast.timeOut(); + + expect({ committed, errors: toast.errors, row: storedWorkspace("a") }).toEqual({ + committed: [], + errors: [], + row: workspace("a"), + }); + }); + + it("does not claim Cmd+Z when no archive can be undone", async () => { + const host = createClient(); + const toast = createToastHost(); + + expect(undoLatestWorkspaceArchive()).toBe(false); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + host.finishArchive("a"); + await archive; + toast.timeOut(); + + expect(undoLatestWorkspaceArchive()).toBe(false); + expect(host.undoRequested("a")).toBe(false); + }); + + it("a newer archive takes over undo and commits the older one", async () => { + const host = createClient(); + const toast = createToastHost(); + const committed: string[] = []; + + const first = startArchive({ host, toast, workspaceId: "a", committed }); + host.finishArchive("a"); + await first; + const second = startArchive({ host, toast, workspaceId: "b", committed }); + expect(committed).toEqual(["a"]); + + expect(undoLatestWorkspaceArchive()).toBe(true); + expect({ undoA: host.undoRequested("a"), undoB: host.undoRequested("b") }).toEqual({ + undoA: false, + undoB: true, + }); + host.finishArchive("b", { canceled: true }); + await second; + }); + + it("reports a failed undo and hides the row again", async () => { + const host = createClient(); + const toast = createToastHost(); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + host.finishArchive("a"); + await archive; + undoLatestWorkspaceArchive(); + host.failUndo("a", new Error("Branch is gone")); + await expect.poll(() => toast.errors).toEqual(["Couldn't undo the archive"]); + + expect(storedWorkspace("a")).toBeUndefined(); + }); + + it("stays quiet about an archive that failed after the user undid it", async () => { + const host = createClient(); + const toast = createToastHost(); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + undoLatestWorkspaceArchive(); + host.finishArchive("a", { error: "registry unavailable" }); + + await expect(archive).resolves.toBeUndefined(); + expect(toast.errors).toEqual([]); + }); + + it("rejects a failed archive, closes its toast and puts the row back", async () => { + const host = createClient(); + const toast = createToastHost(); + + const archive = startArchive({ host, toast, workspaceId: "a", committed: [] }); + host.finishArchive("a", { error: "registry unavailable" }); + + await expect(archive).rejects.toThrow("registry unavailable"); + expect({ + toast: toast.shownTestID, + undone: undoLatestWorkspaceArchive(), + row: storedWorkspace("a"), + }).toEqual({ toast: null, undone: false, row: workspace("a") }); + }); +}); diff --git a/packages/app/src/workspace/archive-undo/internal/undo-window.tsx b/packages/app/src/workspace/archive-undo/internal/undo-window.tsx new file mode 100644 index 0000000000..5dc968124e --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/undo-window.tsx @@ -0,0 +1,129 @@ +import type { DismissToast, ToastApi } from "@/components/toast-host"; +import { i18n } from "@/i18n/i18next"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + beginOptimisticWorkspaceArchive, + isWorkspaceListed, + undoWorkspaceArchiveOptimistically, + type WorkspaceArchiveTarget, +} from "@/workspace/workspace-archive"; +import { WorkspaceArchivedToast } from "./workspace-archived-toast"; + +/** How long the archived toast, and with it the chance to undo, stays up. */ +export const WORKSPACE_ARCHIVE_UNDO_WINDOW_MS = 6000; + +export interface UndoableWorkspaceArchiveClient { + archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null; canceled?: boolean }>; + undoWorkspaceArchive: (workspaceId: string) => Promise; +} + +interface UndoWindowInput { + client: UndoableWorkspaceArchiveClient; + workspace: WorkspaceArchiveTarget; + toast: ToastApi; + hidden: WorkspaceDescriptor | null; + onArchiveCommitted: () => void; +} + +let latest: UndoWindow | null = null; + +/** + * One archive's chance to be undone. It opens when the row hides and closes when its toast + * leaves the screen (timeout, replaced by another toast, or undo). + * + * - `open`: undo is offered. + * - `closed`: undo is no longer offered; the archive stands. + * - `undone`: the user took the archive back. + */ +class UndoWindow { + phase: "open" | "closed" | "undone" = "open"; + private archived = false; + private readonly dismissToast: DismissToast; + + constructor(private readonly input: UndoWindowInput) { + this.dismissToast = input.toast.show(, { + durationMs: WORKSPACE_ARCHIVE_UNDO_WINDOW_MS, + testID: "workspace-archived-toast", + onDismiss: this.close, + }); + } + + /** The host finished the archive. It commits now if undo is no longer offered. */ + archiveFinished(): void { + this.archived = true; + if (this.phase === "closed") this.commit(); + } + + /** The archive failed on the host, so there is nothing left to undo. */ + archiveFailed(): void { + this.close(); + this.dismissToast(); + } + + readonly close = (): void => { + if (this.phase !== "open") return; + this.phase = "closed"; + this.release(); + if (this.archived) this.commit(); + }; + + /** Commits unless the workspace is back by now, for example restored from another client. */ + private commit(): void { + if (isWorkspaceListed(this.input.workspace)) return; + this.input.onArchiveCommitted(); + } + + readonly undo = (): boolean => { + if (this.phase !== "open") return false; + this.phase = "undone"; + this.release(); + this.dismissToast(); + void undoWorkspaceArchiveOptimistically(this.input).catch((error: unknown) => { + console.warn("[workspace-archive-undo] undo failed", error); + this.input.toast.error(i18n.t("sidebar.workspace.toasts.undoArchiveFailed")); + }); + return true; + }; + + private release(): void { + if (latest === this) latest = null; + } +} + +/** + * Archives a workspace and offers to undo it until the toast goes away. + * + * `onArchiveCommitted` runs once the archive both finished on the host and can no longer be + * undone, and only if the workspace is still gone then. Local state the undo would want back + * (tabs, layout) survives the window, and survives a restore from another client. + * Rejects when the archive fails and was not undone; the row is back by then. + */ +export async function archiveWorkspaceWithUndo(input: { + client: UndoableWorkspaceArchiveClient; + workspace: WorkspaceArchiveTarget; + toast: ToastApi; + onArchiveCommitted: () => void; +}): Promise { + const archive = beginOptimisticWorkspaceArchive(input); + const window = new UndoWindow({ ...input, hidden: archive.hidden }); + latest = window; + + let outcome: Awaited; + try { + outcome = await archive.done; + } catch (error) { + if (window.phase === "undone") return; + window.archiveFailed(); + throw error; + } + if (outcome === "archived") window.archiveFinished(); +} + +/** Undoes the most recent archive while its toast is up. Returns false when there is none. */ +export function undoLatestWorkspaceArchive(): boolean { + return latest ? latest.undo() : false; +} + +export function hasUndoableWorkspaceArchive(): boolean { + return latest !== null; +} diff --git a/packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts b/packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts new file mode 100644 index 0000000000..31e9dd54fd --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/use-workspace-archive-undo-running.ts @@ -0,0 +1,21 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { + isWorkspaceArchiveUndoPending, + subscribeWorkspaceArchiveIntents, +} from "@/contexts/session-workspace-upserts"; + +/** + * True while this client's undo of the workspace's archive is running on the host. Until it + * settles, the workspace's agents may still read as archived even though they are coming back. + */ +export function useWorkspaceArchiveUndoRunning(input: { + serverId: string; + workspaceId: string | null; +}): boolean { + const { serverId, workspaceId } = input; + const read = useCallback( + () => isWorkspaceArchiveUndoPending({ serverId, workspaceId }), + [serverId, workspaceId], + ); + return useSyncExternalStore(subscribeWorkspaceArchiveIntents, read, read); +} diff --git a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx new file mode 100644 index 0000000000..584b71dec9 --- /dev/null +++ b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; + +export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) { + const { t } = useTranslation(); + const undoKeys = useShortcutKeys("undo-archive-workspace"); + const undoShortcut = useMemo(() => (undoKeys ? : null), [undoKeys]); + + return ( + + {t("sidebar.workspace.toasts.archived")} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + message: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 7dc86a61ae..9608cef0cd 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -10,6 +10,7 @@ import { import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { archiveWorkspaceWithUndo, supportsWorkspaceArchiveUndo } from "@/workspace/archive-undo"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void { @@ -58,17 +59,22 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; } + const workspace = { serverId, workspaceId }; onSetHiding?.(true); try { onArchiveStarted(); - await archiveWorkspaceOptimistically({ - client, - workspace: { - serverId, - workspaceId, - }, - }); - purgeArchivedWorkspaceState({ serverId, workspaceId }); + if (supportsWorkspaceArchiveUndo(serverId)) { + await archiveWorkspaceWithUndo({ + client, + workspace, + toast, + onArchiveCommitted: () => purgeArchivedWorkspaceState(workspace), + }); + } else { + // COMPAT(workspaceArchiveUndo): hosts before v0.15.3 cannot undo; remove after 2027-05-06. + await archiveWorkspaceOptimistically({ client, workspace }); + purgeArchivedWorkspaceState(workspace); + } } catch (error) { toast.error( error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"), diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index 3facdee34e..e519bcffb3 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -5,11 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearWorkspaceArchivePending, isWorkspaceArchivePending, + isWorkspaceArchiveUndoPending, } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, + beginOptimisticWorkspaceArchive, + undoWorkspaceArchiveOptimistically, type WorkspaceArchiveTarget, } from "@/workspace/workspace-archive"; @@ -21,12 +24,14 @@ type ArchiveWorkspacePayload = Awaited { expect(storedWorkspaceOn(SECOND_SERVER_ID, second.id)).toBeUndefined(); }); }); + +describe("undoWorkspaceArchiveOptimistically", () => { + it("brings the row back while the host undoes the archive it is still running", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const archiveResponse = deferred(); + const undoResponse = deferred(); + const archive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archiveResponse.promise), + workspace: target(), + }); + + const undo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => undoResponse.promise }, + workspace: target(), + hidden: archive.hidden, + }); + expect(storedWorkspace(archived.id)).toEqual(archived); + + // The host answers the canceled archive before the undo. + archiveResponse.resolve(archivePayload({ workspaceId: archived.id, canceled: true })); + await expect(archive.done).resolves.toBe("canceled"); + undoResponse.resolve(); + await undo; + + expect({ + row: storedWorkspace(archived.id), + archivePending: isWorkspaceArchivePending(target()), + undoPending: isWorkspaceArchiveUndoPending(target()), + }).toEqual({ row: archived, archivePending: false, undoPending: false }); + }); + + it("hides the row again when the host cannot undo the archive", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const archive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archivePayload({ workspaceId: archived.id })), + workspace: target(), + }); + await archive.done; + + await expect( + undoWorkspaceArchiveOptimistically({ + client: { + undoWorkspaceArchive: async () => { + throw new Error("Branch is gone"); + }, + }, + workspace: target(), + hidden: archive.hidden, + }), + ).rejects.toThrow("Branch is gone"); + + expect(storedWorkspace(archived.id)).toBeUndefined(); + expect(isWorkspaceArchiveUndoPending(target())).toBe(false); + }); + + it("leaves a re-archived row hidden when the earlier undo settles", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const undoResponse = deferred(); + const first = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archivePayload({ workspaceId: archived.id })), + workspace: target(), + }); + await first.done; + const undo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => undoResponse.promise }, + workspace: target(), + hidden: first.hidden, + }); + + const rearchive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => new Promise(() => {})), + workspace: target(), + }); + undoResponse.resolve(); + await undo; + + expect(rearchive.hidden).toEqual(archived); + expect(storedWorkspace(archived.id)).toBeUndefined(); + expect(isWorkspaceArchivePending(target())).toBe(true); + }); + it("puts the row back when another client's undo cancels this archive", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const archive = beginOptimisticWorkspaceArchive({ + client: createClient(async () => + archivePayload({ workspaceId: archived.id, canceled: true, error: "undone" }), + ), + workspace: target(), + }); + + await expect(archive.done).resolves.toBe("canceled"); + + expect({ + row: storedWorkspace(archived.id), + archivePending: isWorkspaceArchivePending(target()), + }).toEqual({ row: archived, archivePending: false }); + }); + + it("an earlier undo settling keeps holding the row for a later undo", async () => { + const archived = workspace(); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); + const firstUndoResponse = deferred(); + const secondUndoResponse = deferred(); + const first = beginOptimisticWorkspaceArchive({ + client: createClient(async () => archivePayload({ workspaceId: archived.id })), + workspace: target(), + }); + await first.done; + const firstUndo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => firstUndoResponse.promise }, + workspace: target(), + hidden: first.hidden, + }); + const second = beginOptimisticWorkspaceArchive({ + client: createClient(async () => new Promise(() => {})), + workspace: target(), + }); + const secondUndo = undoWorkspaceArchiveOptimistically({ + client: { undoWorkspaceArchive: async () => secondUndoResponse.promise }, + workspace: target(), + hidden: second.hidden, + }); + + firstUndoResponse.resolve(); + await firstUndo; + + expect({ + row: storedWorkspace(archived.id), + undoPending: isWorkspaceArchiveUndoPending(target()), + }).toEqual({ row: archived, undoPending: true }); + secondUndoResponse.resolve(); + await secondUndo; + }); +}); diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index a4e5a00e80..fd757934ef 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -1,7 +1,10 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { - clearWorkspaceArchivePending, + isWorkspaceArchiveIntentCurrent, markWorkspaceArchivePending, + markWorkspaceArchiveUndoPending, + settleWorkspaceArchiveIntent, + type WorkspaceArchiveIntent, } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; @@ -13,11 +16,25 @@ export interface WorkspaceArchiveTarget { } interface WorkspaceArchiveClient { - archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>; + archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null; canceled?: boolean }>; +} + +interface WorkspaceArchiveUndoClient { + undoWorkspaceArchive: (workspaceId: string) => Promise; +} + +/** `canceled`: an undo stopped the archive on the host before it finished. */ +export type WorkspaceArchiveOutcome = "archived" | "canceled"; + +export interface OptimisticWorkspaceArchive { + /** The row as it was before the archive hid it, for undo to put back. */ + hidden: WorkspaceDescriptor | null; + done: Promise; } interface OptimisticWorkspaceArchiveSnapshot { workspace: WorkspaceDescriptor | null; + intent: WorkspaceArchiveIntent; } export interface WorkspaceArchiveFailure { @@ -38,32 +55,42 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai ); } -function hideWorkspaceOptimistically( - workspace: WorkspaceArchiveTarget, -): OptimisticWorkspaceArchiveSnapshot { +function findListedWorkspace(workspace: WorkspaceArchiveTarget): WorkspaceDescriptor | null { const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: workspace.workspaceId, }); - const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; - markWorkspaceArchivePending({ + return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; +} + +/** True when the workspace is in this client's list, for example after another client restored it. */ +export function isWorkspaceListed(workspace: WorkspaceArchiveTarget): boolean { + return findListedWorkspace(workspace) !== null; +} + +function hideWorkspaceOptimistically( + workspace: WorkspaceArchiveTarget, +): OptimisticWorkspaceArchiveSnapshot { + const snapshot = findListedWorkspace(workspace); + const intent = markWorkspaceArchivePending({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); - return { workspace: snapshot }; + return { workspace: snapshot, intent }; } +/** + * Puts back a row whose archive did not happen. Skipped when a newer request on this client + * (an undo, or another archive) owns the row by now. + */ function restoreOptimisticallyHiddenWorkspace(input: { serverId: string; - workspaceId: string; snapshot: OptimisticWorkspaceArchiveSnapshot; }): void { - clearWorkspaceArchivePending({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); + if (!isWorkspaceArchiveIntentCurrent(input.snapshot.intent)) return; + settleWorkspaceArchiveIntent(input.snapshot.intent); if (input.snapshot.workspace) { getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } @@ -72,31 +99,75 @@ function restoreOptimisticallyHiddenWorkspace(input: { async function archiveWorkspaceOrThrow(input: { client: WorkspaceArchiveClient; workspaceId: string; -}): Promise { +}): Promise { const payload = await input.client.archiveWorkspace(input.workspaceId); + if (payload.canceled) { + return "canceled"; + } if (payload.error) { throw new Error(payload.error); } + return "archived"; +} + +/** + * Hides the row now and archives it on the host. A failed archive puts the row back, and so + * does a canceled one: an undo from another client stopped it, so the workspace stays. + */ +export function beginOptimisticWorkspaceArchive(input: { + client: WorkspaceArchiveClient; + workspace: WorkspaceArchiveTarget; +}): OptimisticWorkspaceArchive { + const snapshot = hideWorkspaceOptimistically(input.workspace); + const done = (async () => { + try { + const outcome = await archiveWorkspaceOrThrow({ + client: input.client, + workspaceId: input.workspace.workspaceId, + }); + if (outcome === "canceled") { + restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot }); + } + return outcome; + } catch (error) { + restoreOptimisticallyHiddenWorkspace({ serverId: input.workspace.serverId, snapshot }); + throw error; + } + })(); + return { hidden: snapshot.workspace, done }; } export async function archiveWorkspaceOptimistically(input: { client: WorkspaceArchiveClient; workspace: WorkspaceArchiveTarget; -}): Promise { - const snapshot = hideWorkspaceOptimistically(input.workspace); +}): Promise { + return beginOptimisticWorkspaceArchive(input).done; +} +/** + * Puts the archived row back now and asks the host to undo the archive, whether it is still + * running or already finished. A failed undo hides the row again, unless the user archived + * it again in the meantime. + */ +export async function undoWorkspaceArchiveOptimistically(input: { + client: WorkspaceArchiveUndoClient; + workspace: WorkspaceArchiveTarget; + hidden: WorkspaceDescriptor | null; +}): Promise { + const { serverId, workspaceId } = input.workspace; + const intent = markWorkspaceArchiveUndoPending({ serverId, workspaceId }); + if (input.hidden) { + getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [input.hidden]); + } try { - await archiveWorkspaceOrThrow({ - client: input.client, - workspaceId: input.workspace.workspaceId, - }); + await input.client.undoWorkspaceArchive(workspaceId); } catch (error) { - restoreOptimisticallyHiddenWorkspace({ - serverId: input.workspace.serverId, - workspaceId: input.workspace.workspaceId, - snapshot, - }); + if (isWorkspaceArchiveIntentCurrent(intent)) { + getHostRuntimeStore().removeWorkspaceSnapshot(serverId, workspaceId); + } throw error; + } finally { + settleWorkspaceArchiveIntent(intent); } } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index b29a34111c..7b235040bf 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3628,6 +3628,23 @@ export class DaemonClient { } } + /** Rejects when the host could not put the workspace back; an old host has no such RPC. */ + async undoWorkspaceArchive(workspaceId: string, requestId?: string): Promise { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.archive.undo.response">({ + requestId, + message: { + type: "workspace.archive.undo.request", + workspaceId, + }, + // Undoing a finished worktree archive recreates the worktree, like recovery does. + timeout: 150_000, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "The host could not undo the workspace archive"); + } + } + async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5436e1737d..c5fc881240 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1105,6 +1105,14 @@ export const WorkspaceRecoveryRestoreRequestSchema = z.object({ requestId: z.string(), }); +// Undoes the latest archive of a workspace: cancels it if it is still running, otherwise +// restores the workspace, then unarchives the agents that archive took down. +export const WorkspaceArchiveUndoRequestSchema = z.object({ + type: z.literal("workspace.archive.undo.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + export const SetVoiceModeMessageSchema = z.object({ type: z.literal("set_voice_mode"), enabled: z.boolean(), @@ -2249,6 +2257,16 @@ export const WorkspaceRecoveryRestoreResponseSchema = z.object({ }), }); +export const WorkspaceArchiveUndoResponseSchema = z.object({ + type: z.literal("workspace.archive.undo.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SetVoiceModeResponseMessageSchema = z.object({ type: z.literal("set_voice_mode_response"), payload: z.object({ @@ -3355,6 +3373,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceLabelDeleteInspectRequestSchema, WorkspaceRecoveryInspectRequestSchema, WorkspaceRecoveryRestoreRequestSchema, + WorkspaceArchiveUndoRequestSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, @@ -3820,6 +3839,8 @@ export const ServerInfoStatusPayloadSchema = z worktreeRestore: z.boolean().optional(), // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. workspaceRecovery: z.boolean().optional(), + // COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06. + workspaceArchiveUndo: z.boolean().optional(), // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. workspaceFileEditing: z.boolean().optional(), // COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20. @@ -4796,6 +4817,8 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({ workspaceId: z.string(), archivedAt: z.string().nullable(), error: z.string().nullable(), + // An undo stopped this archive before it finished; `error` carries the reason. + canceled: z.boolean().optional(), }), }); @@ -7231,6 +7254,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceBaseBranchSetResponseSchema, WorkspaceRecoveryInspectResponseSchema, WorkspaceRecoveryRestoreResponseSchema, + WorkspaceArchiveUndoResponseSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, @@ -7444,6 +7468,7 @@ export type WorkspaceRecoveryInspectResponse = z.infer< export type WorkspaceRecoveryRestoreResponse = z.infer< typeof WorkspaceRecoveryRestoreResponseSchema >; +export type WorkspaceArchiveUndoResponse = z.infer; export type WorkspaceCreateRequest = z.infer; export type WorkspaceCreateResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; @@ -7593,6 +7618,7 @@ export type WorkspacePinSetRequest = z.infer; export type WorkspaceRecoveryInspectRequest = z.infer; export type WorkspaceRecoveryRestoreRequest = z.infer; +export type WorkspaceArchiveUndoRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9f87600ed9..81b5495377 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2543,16 +2543,20 @@ export class AgentManager { if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); - await registry.upsert({ + const nextRecord: StoredAgentRecord = { ...record, ...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}), ...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}), archivedAt: null, updatedAt: new Date().toISOString(), - }); + }; + await registry.upsert(nextRecord); if (this.getAgent(agentId)) { this.notifyAgentState(agentId); + } else if (!nextRecord.internal) { + // Same publication archiveSnapshot makes, so a stored-only agent reappears for every client. + this.dispatchStoredAgentState(nextRecord); } return true; } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 8cc9d6d1a0..c45d9a5c32 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -219,6 +219,7 @@ const INBOUND_PERMISSION = { "workspace.base_branch.set.request": "workspace.write", "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], + "workspace.archive.undo.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", "workspace.script.start.request": "workspace.write", "workspace.script.stop.request": "workspace.write", @@ -462,6 +463,7 @@ const OUTBOUND_PERMISSION = { "workspace.base_branch.set.response": "workspace.write", "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], + "workspace.archive.undo.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", "workspace.script.start.response": "workspace.write", "workspace.script.stop.response": "workspace.write", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 339a7c17fb..c652f70908 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -278,6 +278,10 @@ import { handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage, } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; +import { + WorkspaceArchiveOperations, + type WorkspaceArchiveUndoInput, +} from "./workspace-archive-operations.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; import { SessionAuthorization, type DaemonPermission } from "./authorization/index.js"; @@ -286,6 +290,12 @@ function resolveWorkspaceSetupRuntime( ): WorkspaceSetupRuntime { return runtime ?? new WorkspaceSetupRuntime(); } + +function resolveWorkspaceArchiveOperations( + operations: WorkspaceArchiveOperations | undefined, +): WorkspaceArchiveOperations { + return operations ?? new WorkspaceArchiveOperations(); +} import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { @@ -566,6 +576,8 @@ export interface SessionOptions { scriptRuntimeStore?: WorkspaceScriptRuntimeStore; workspaceSetupObservation?: WorkspaceSetupObservation; workspaceSetupRuntime?: WorkspaceSetupRuntime; + /** Shared by every session so an undo from any connection reaches the archive it cancels. */ + workspaceArchiveOperations?: WorkspaceArchiveOperations; onBranchChanged?: ( workspaceId: string, oldBranch: string | null, @@ -837,6 +849,7 @@ export class Session { private readonly workspaceSetupObservation: WorkspaceSetupObservation; private readonly unsubscribeSetupObservation: () => void; private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; + private readonly workspaceArchiveOperations: WorkspaceArchiveOperations; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSessions: VoiceSessions; @@ -902,6 +915,7 @@ export class Session { scriptRuntimeStore, workspaceSetupObservation, workspaceSetupRuntime, + workspaceArchiveOperations, onBranchChanged, getDaemonTcpPort, getDaemonTcpHost, @@ -1249,6 +1263,7 @@ export class Session { }, ); this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime); + this.workspaceArchiveOperations = resolveWorkspaceArchiveOperations(workspaceArchiveOperations); this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpHost = getDaemonTcpHost ?? null; this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null; @@ -3306,6 +3321,8 @@ export class Session { return this.handleWorkspaceRecoveryInspectRequest(msg); case "workspace.recovery.restore.request": return this.handleWorkspaceRecoveryRestoreRequest(msg); + case "workspace.archive.undo.request": + return this.handleWorkspaceArchiveUndoRequest(msg); case "workspace.clear_attention.request": return this.handleWorkspaceClearAttentionRequest(msg); case "workspace.mark_unread.request": @@ -6180,6 +6197,10 @@ export class Session { private async restoreWorkspaceAndEmit(workspaceId: string): Promise { await this.workspaceRecovery.restore(workspaceId); + await this.publishRecoveredWorkspace(workspaceId); + } + + private async publishRecoveredWorkspace(workspaceId: string): Promise { const workspace = await this.workspaceRegistry.get(workspaceId); if (!workspace) { throw new Error(`Recovered workspace record not found: ${workspaceId}`); @@ -7786,41 +7807,62 @@ export class Session { request: Extract, ): Promise { try { - const existing = await this.workspaceRegistry.get(request.workspaceId); - if (!existing) { - throw new Error(`Workspace not found: ${request.workspaceId}`); - } - - await archiveByScope( - { - paseoHome: this.paseoHome, - paseoWorktreesBaseRoot: this.worktreesRoot, - github: this.github, - workspaceGitService: this.workspaceGitService, - agentManager: this.agentManager, - agentStorage: this.agentStorage, - findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), - getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), - listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), - emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => - this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), - markWorkspaceArchiving: (workspaceIds, archivingAt) => - this.markWorkspaceArchiving(workspaceIds, archivingAt), - clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), - assertWorkspaceAutomationAllowed: (workspaceId) => - assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), - killTerminalsForWorkspace: (workspaceId) => - this.terminalController.killTerminalsForWorkspace(workspaceId), - stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId), - sessionLogger: this.sessionLogger, - }, - { - scope: { kind: "workspace", workspaceId: existing.workspaceId }, - requestId: request.requestId, + // Claim the archive queue before any await, so an undo sent right behind this request + // queues after it and cancels it instead of running first. + const outcome = await this.workspaceArchiveOperations.archive( + request.workspaceId, + async (signal) => { + const existing = await this.workspaceRegistry.get(request.workspaceId); + if (!existing) { + throw new Error(`Workspace not found: ${request.workspaceId}`); + } + return archiveByScope( + { + paseoHome: this.paseoHome, + paseoWorktreesBaseRoot: this.worktreesRoot, + github: this.github, + workspaceGitService: this.workspaceGitService, + agentManager: this.agentManager, + agentStorage: this.agentStorage, + findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), + getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), + listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => + this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), + markWorkspaceArchiving: (workspaceIds, archivingAt) => + this.markWorkspaceArchiving(workspaceIds, archivingAt), + clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds), + assertWorkspaceAutomationAllowed: (workspaceId) => + assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), + killTerminalsForWorkspace: (workspaceId) => + this.terminalController.killTerminalsForWorkspace(workspaceId), + stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId), + sessionLogger: this.sessionLogger, + }, + { + scope: { kind: "workspace", workspaceId: existing.workspaceId }, + requestId: request.requestId, + signal, + }, + ); }, ); + if (outcome.kind === "canceled") { + this.emit({ + type: "archive_workspace_response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + archivedAt: null, + error: "Workspace archive was undone", + canceled: true, + }, + }); + return; + } + const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId); const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString(); this.emit({ @@ -7850,6 +7892,69 @@ export class Session { } } + private async handleWorkspaceArchiveUndoRequest( + request: Extract, + ): Promise { + try { + await this.workspaceArchiveOperations.undo(request.workspaceId, (input) => + this.restoreArchivedWorkspaceAndAgents(request.workspaceId, input), + ); + this.emit({ + type: "workspace.archive.undo.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + accepted: true, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, workspaceId: request.workspaceId, requestId: request.requestId }, + "session: workspace.archive.undo.request rejected", + ); + this.emit({ + type: "workspace.archive.undo.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + accepted: false, + error: getErrorMessageOr(error, "Failed to undo the workspace archive"), + }, + }); + } + } + + // Runs on the archive lane after any in-flight archive has stopped, so the record reflects how + // far it got: still active when it was canceled early, archived (directory kept or removed) + // otherwise. + private async restoreArchivedWorkspaceAndAgents( + workspaceId: string, + input: WorkspaceArchiveUndoInput, + ): Promise { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + if (workspace.archivedAt) { + await this.workspaceRecovery.restore(workspaceId); + } + const results = await Promise.allSettled( + input.agentIds.map((agentId) => + unarchiveAgentState(this.agentStorage, this.agentManager, agentId), + ), + ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + this.sessionLogger.warn( + { err: result.reason, workspaceId, agentId: input.agentIds[index] }, + "Failed to unarchive agent while undoing workspace archive", + ); + } + } + await this.publishRecoveredWorkspace(workspaceId); + } + private async handleWorkspaceClearAttentionRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7c301556c3..74188b3d7a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -65,6 +65,7 @@ import { import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; +import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; @@ -609,6 +610,9 @@ export class VoiceAssistantWebSocketServer { private readonly voiceCallerContexts = new Map(); private readonly workspaceSetupObservation = new WorkspaceSetupObservation(); private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; + // One lane per workspace across every connection, so an undo from any client can cancel an + // archive another connection started. + private readonly workspaceArchiveOperations = new WorkspaceArchiveOperations(); private readonly providerSnapshotManager: ProviderSnapshotManager; private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null; private onBranchChanged!: @@ -1697,6 +1701,7 @@ export class VoiceAssistantWebSocketServer { scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, workspaceSetupObservation: this.workspaceSetupObservation, workspaceSetupRuntime: this.workspaceSetupRuntime, + workspaceArchiveOperations: this.workspaceArchiveOperations, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpHost: this.getDaemonTcpHost ?? undefined, @@ -2120,6 +2125,8 @@ export class VoiceAssistantWebSocketServer { worktreeRestore: true, // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. workspaceRecovery: true, + // COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06. + workspaceArchiveUndo: true, // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. workspaceFileEditing: true, // COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20. diff --git a/packages/server/src/server/workspace-archive-operations.test.ts b/packages/server/src/server/workspace-archive-operations.test.ts new file mode 100644 index 0000000000..c262f58fa3 --- /dev/null +++ b/packages/server/src/server/workspace-archive-operations.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test } from "vitest"; + +import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js"; +import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function archived(newlyArchivedAgentIds: string[]): ArchiveResult { + return { + archivedAgentIds: newlyArchivedAgentIds, + newlyArchivedAgentIds, + archivedWorkspaceIds: ["ws"], + removedDirectory: false, + }; +} + +/** + * An archive that parks until released, then honours its abort signal the way archiveByScope's + * checkpoints do: an aborted run reports the agents it already took down. + */ +function parkedArchive(agentIds: string[]) { + const release = deferred(); + const started = deferred(); + let signal: AbortSignal | null = null; + return { + release: () => release.resolve(), + started: started.promise, + get aborted() { + return signal?.aborted ?? false; + }, + run: async (runSignal: AbortSignal) => { + signal = runSignal; + started.resolve(); + await release.promise; + if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false); + return archived(agentIds); + }, + }; +} + +describe("WorkspaceArchiveOperations", () => { + test("an undo during a running archive aborts it and restores the agents it took down", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive(["agent-a", "agent-b"]); + const restored: string[][] = []; + + const archiveDone = operations.archive("ws", archive.run); + await archive.started; + const undoDone = operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + + expect(archive.aborted).toBe(true); + expect(restored).toEqual([]); + archive.release(); + + await expect(archiveDone).resolves.toEqual({ kind: "canceled" }); + await undoDone; + expect(restored).toEqual([["agent-a", "agent-b"]]); + }); + + test("an undo after the archive finished restores the agents that archive took down", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + + const restored: string[][] = []; + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + + expect(restored).toEqual([["agent-a"]]); + }); + + test("restore runs only after the aborted archive has stopped", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive([]); + const order: string[] = []; + + const archiveDone = operations + .archive("ws", async (signal) => { + try { + return await archive.run(signal); + } finally { + order.push("archive stopped"); + } + }) + .catch(() => undefined); + await archive.started; + const undoDone = operations.undo("ws", async () => { + order.push("restore"); + }); + archive.release(); + await Promise.all([archiveDone, undoDone]); + + expect(order).toEqual(["archive stopped", "restore"]); + }); + + test("a second archive while one runs joins it instead of archiving twice", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive(["agent-a"]); + let runs = 0; + const run = (signal: AbortSignal) => { + runs += 1; + return archive.run(signal); + }; + + const first = operations.archive("ws", run); + const second = operations.archive("ws", run); + archive.release(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { kind: "archived", result: archived(["agent-a"]) }, + { kind: "archived", result: archived(["agent-a"]) }, + ]); + expect(runs).toBe(1); + }); + + test("an archive requested during an undo runs after the undo, and wins", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + + const restoreGate = deferred(); + const order: string[] = []; + const undoDone = operations.undo("ws", async () => { + await restoreGate.promise; + order.push("restored"); + }); + const rearchive = operations.archive("ws", async () => { + order.push("archived again"); + return archived(["agent-a"]); + }); + + restoreGate.resolve(); + await undoDone; + await expect(rearchive).resolves.toMatchObject({ kind: "archived" }); + expect(order).toEqual(["restored", "archived again"]); + }); + + test("archive, undo, archive in quick succession leaves the workspace archived", async () => { + const operations = new WorkspaceArchiveOperations(); + const first = parkedArchive(["agent-a"]); + const restored: string[][] = []; + + const firstDone = operations.archive("ws", first.run); + await first.started; + const undoDone = operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + // The re-archive must not join the archive the undo just aborted. + const secondDone = operations.archive("ws", async () => archived(["agent-a"])); + first.release(); + + await expect(firstDone).resolves.toEqual({ kind: "canceled" }); + await undoDone; + await expect(secondDone).resolves.toMatchObject({ kind: "archived" }); + expect(restored).toEqual([["agent-a"]]); + }); + + test("a second undo while one runs joins it", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + const restoreGate = deferred(); + let restores = 0; + const restore = async () => { + restores += 1; + await restoreGate.promise; + }; + + const first = operations.undo("ws", restore); + const second = operations.undo("ws", restore); + restoreGate.resolve(); + await Promise.all([first, second]); + + expect(restores).toBe(1); + }); + + test("a failed restore keeps the agents for a retry", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + + await expect( + operations.undo("ws", async () => { + throw new Error("worktree recreation failed"); + }), + ).rejects.toThrow("worktree recreation failed"); + + const restored: string[][] = []; + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + expect(restored).toEqual([["agent-a"]]); + }); + + test("an archive of an already archived workspace keeps the earlier archive's agents for undo", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + await operations.archive("ws", async () => ({ + archivedAgentIds: ["agent-a"], + newlyArchivedAgentIds: [], + archivedWorkspaceIds: [], + removedDirectory: false, + })); + const restored: string[][] = []; + + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + + expect(restored).toEqual([["agent-a"]]); + }); + + test("an undo restores the archive's agents once; a repeat undo restores none", async () => { + const operations = new WorkspaceArchiveOperations(); + await operations.archive("ws", async () => archived(["agent-a"])); + const restored: string[][] = []; + const restore = async ({ agentIds }: { agentIds: string[] }) => { + restored.push(agentIds); + }; + + await operations.undo("ws", restore); + await operations.undo("ws", restore); + + expect(restored).toEqual([["agent-a"], []]); + }); + + test("an undo after the retention window restores the workspace without agents", async () => { + let now = 0; + const operations = new WorkspaceArchiveOperations({ undoRetentionMs: 1000, now: () => now }); + await operations.archive("ws", async () => archived(["agent-a"])); + now = 1000; + + const restored: string[][] = []; + await operations.undo("ws", async ({ agentIds }) => { + restored.push(agentIds); + }); + expect(restored).toEqual([[]]); + }); + + test("workspaces have independent lanes", async () => { + const operations = new WorkspaceArchiveOperations(); + const archive = parkedArchive(["agent-a"]); + + const parked = operations.archive("ws-a", archive.run); + await archive.started; + await expect(operations.archive("ws-b", async () => archived(["agent-b"]))).resolves.toEqual({ + kind: "archived", + result: archived(["agent-b"]), + }); + await operations.undo("ws-b", async () => {}); + + expect(archive.aborted).toBe(false); + archive.release(); + await expect(parked).resolves.toMatchObject({ kind: "archived" }); + }); + + test("a failed archive frees the lane for the next request", async () => { + const operations = new WorkspaceArchiveOperations(); + await expect( + operations.archive("ws", async () => { + throw new Error("registry unavailable"); + }), + ).rejects.toThrow("registry unavailable"); + + await expect(operations.archive("ws", async () => archived([]))).resolves.toMatchObject({ + kind: "archived", + }); + }); +}); diff --git a/packages/server/src/server/workspace-archive-operations.ts b/packages/server/src/server/workspace-archive-operations.ts new file mode 100644 index 0000000000..c8bb15a9d2 --- /dev/null +++ b/packages/server/src/server/workspace-archive-operations.ts @@ -0,0 +1,133 @@ +import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js"; + +export type WorkspaceArchiveOutcome = + | { kind: "archived"; result: ArchiveResult } + | { kind: "canceled" }; + +export interface WorkspaceArchiveUndoInput { + /** Agents the undone archive took down. Empty when the daemon no longer remembers it. */ + agentIds: string[]; +} + +type LaneEntry = + | { kind: "archiving"; controller: AbortController; done: Promise } + | { kind: "undoing"; done: Promise }; + +interface UndoableArchive { + agentIds: string[]; + expiresAt: number; +} + +const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000; + +/** + * One lane per workspace for archive and its undo, shared by every session on the daemon. + * + * - An archive while one runs joins it. + * - An undo while an archive runs aborts it, waits for it to stop, then restores. + * - An archive while an undo runs waits for the undo, then archives. + * - An undo while an undo runs joins it. + * + * Each call claims the lane synchronously, so whichever request arrives last decides the + * workspace's final state no matter how the earlier work interleaves. + * + * The agents an archive took down are kept in memory for the undo window. An archive that took + * nothing down, such as a repeat archive from a stale client, leaves them alone. After a daemon + * restart, undo still restores the workspace but its agents stay archived. + */ +export class WorkspaceArchiveOperations { + private readonly lanes = new Map(); + private readonly undoable = new Map(); + private readonly undoRetentionMs: number; + private readonly now: () => number; + + constructor(options: { undoRetentionMs?: number; now?: () => number } = {}) { + this.undoRetentionMs = options.undoRetentionMs ?? DEFAULT_UNDO_RETENTION_MS; + this.now = options.now ?? Date.now; + } + + archive( + workspaceId: string, + run: (signal: AbortSignal) => Promise, + ): Promise { + const current = this.lanes.get(workspaceId); + if (current?.kind === "archiving") { + return current.done; + } + const controller = new AbortController(); + const done = (async (): Promise => { + await settled(current?.done); + try { + const result = await run(controller.signal); + if (result.archivedWorkspaceIds.length > 0 || result.newlyArchivedAgentIds.length > 0) { + this.retainUndoable(workspaceId, result.newlyArchivedAgentIds); + } + return { kind: "archived", result }; + } catch (error) { + if (!(error instanceof WorkspaceArchiveCanceledError)) throw error; + if (error.recordArchived || error.newlyArchivedAgentIds.length > 0) { + this.retainUndoable(workspaceId, error.newlyArchivedAgentIds); + } + return { kind: "canceled" }; + } + })(); + this.claim(workspaceId, { kind: "archiving", controller, done }); + return done; + } + + undo( + workspaceId: string, + restore: (input: WorkspaceArchiveUndoInput) => Promise, + ): Promise { + const current = this.lanes.get(workspaceId); + if (current?.kind === "undoing") { + return current.done; + } + current?.controller.abort(); + const done = (async () => { + await settled(current?.done); + const agentIds = this.takeUndoable(workspaceId); + try { + await restore({ agentIds }); + } catch (error) { + // Keep the agents for a retry; the archive they came from is still the latest one. + this.retainUndoable(workspaceId, agentIds); + throw error; + } + })(); + this.claim(workspaceId, { kind: "undoing", done }); + return done; + } + + private claim(workspaceId: string, entry: LaneEntry): void { + this.lanes.set(workspaceId, entry); + const release = () => { + if (this.lanes.get(workspaceId) === entry) this.lanes.delete(workspaceId); + }; + void (entry.done as Promise).then(release, release); + } + + private retainUndoable(workspaceId: string, agentIds: string[]): void { + const now = this.now(); + for (const [id, entry] of this.undoable) { + if (entry.expiresAt <= now) this.undoable.delete(id); + } + this.undoable.set(workspaceId, { agentIds, expiresAt: now + this.undoRetentionMs }); + } + + private takeUndoable(workspaceId: string): string[] { + const entry = this.undoable.get(workspaceId); + this.undoable.delete(workspaceId); + if (!entry || entry.expiresAt <= this.now()) return []; + return entry.agentIds; + } +} + +async function settled(promise: Promise | undefined): Promise { + if (!promise) return; + try { + await promise; + } catch { + // The earlier request reports its own failure; this one only waits for the lane. + } +} diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 43ba26b8ad..ecf4210ece 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -19,6 +19,7 @@ import { type ArchiveDependencies, type ArchiveResult, resolveWorkspaceIdAtPath, + WorkspaceArchiveCanceledError, } from "./workspace-archive-service.js"; import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js"; @@ -1019,6 +1020,207 @@ describe("archiveByScope", () => { }); }); +function gate(): { + wait: Promise; + open: () => void; + reached: Promise; + reach: () => void; +} { + let open!: () => void; + let reach!: () => void; + const wait = new Promise((resolve) => { + open = resolve; + }); + const reached = new Promise((resolve) => { + reach = resolve; + }); + return { wait, open, reached, reach }; +} + +async function canceledArchive(promise: Promise): Promise { + const error = await promise.then( + () => null, + (reason: unknown) => reason, + ); + if (!(error instanceof WorkspaceArchiveCanceledError)) { + throw new Error(`Expected the archive to be canceled, got ${String(error)}`); + } + return error; +} + +describe("archiveByScope cancellation", () => { + function storedAgents( + workspaceId: string, + agents: Array<{ id: string; archivedAt: string | null }>, + ) { + return { + listByWorkspace: async () => + agents.map((agent) => ({ ...agent, workspaceId })) as StoredAgentRecord[], + } as Pick; + } + + test("an abort while agents archive keeps the record and reports only the agents it took down", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-agents"); + const workspaceId = "ws-cancel-agents"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [ + { id: "agent-active", archivedAt: null }, + { id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" }, + ]); + const agentArchive = gate(); + deps.agentManager.archiveSnapshot = vi.fn(async (agentId: string) => { + agentArchive.reach(); + await agentArchive.wait; + deps.archivedSnapshotIds.push(agentId); + return {} as StoredAgentRecord; + }); + const controller = new AbortController(); + + const archive = archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-cancel-agents", + signal: controller.signal, + }); + await agentArchive.reached; + controller.abort(); + agentArchive.open(); + const error = await canceledArchive(archive); + + expect({ + newlyArchivedAgentIds: error.newlyArchivedAgentIds, + recordArchived: error.recordArchived, + activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId), + directoryExists: existsSync(worktree.worktreePath), + }).toEqual({ + newlyArchivedAgentIds: ["agent-active"], + recordArchived: false, + activeWorkspaceIds: [workspaceId], + directoryExists: true, + }); + // The archiving marker never outlives a canceled archive. + expect(deps.clearWorkspaceArchiving).toHaveBeenCalledWith([workspaceId]); + }); + + test("an abort after the record archived keeps the worktree directory", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-record"); + const workspaceId = "ws-cancel-record"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + const recordArchive = gate(); + const archiveRecord = deps.archiveWorkspaceRecord; + deps.archiveWorkspaceRecord = async (id: string) => { + recordArchive.reach(); + await recordArchive.wait; + await archiveRecord(id); + }; + const controller = new AbortController(); + + const archive = archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-cancel-record", + signal: controller.signal, + }); + await recordArchive.reached; + controller.abort(); + recordArchive.open(); + const error = await canceledArchive(archive); + + expect({ + recordArchived: error.recordArchived, + activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId), + directoryExists: existsSync(worktree.worktreePath), + }).toEqual({ recordArchived: true, activeWorkspaceIds: [], directoryExists: true }); + }); + + test("an archive aborted before it starts touches nothing", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-before"); + const workspaceId = "ws-cancel-before"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [{ id: "agent-active", archivedAt: null }]); + const controller = new AbortController(); + controller.abort(); + + const error = await canceledArchive( + archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-cancel-before", + signal: controller.signal, + }), + ); + + expect(error.newlyArchivedAgentIds).toEqual([]); + expect(deps.archivedSnapshotIds).toEqual([]); + expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled(); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("a completed archive reports the agents it newly archived", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "newly-archived"); + const workspaceId = "ws-newly-archived"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [ + { id: "agent-active", archivedAt: null }, + { id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-newly-archived", + signal: new AbortController().signal, + }); + + expect(result.archivedAgentIds.sort()).toEqual(["agent-active", "agent-already-archived"]); + expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]); + }); + + test("an archived agent loaded to read its history is not reported as newly archived", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "loaded-archived"); + const workspaceId = "ws-loaded-archived"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + deps.agentStorage = storedAgents(workspaceId, [ + { id: "agent-active", archivedAt: null }, + { id: "agent-history", archivedAt: "2026-01-01T00:00:00.000Z" }, + ]); + deps.agentManager.listAgents = () => + [ + { id: "agent-active", workspaceId }, + { id: "agent-history", workspaceId }, + ] as ManagedAgent[]; + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-loaded-archived", + signal: new AbortController().signal, + }); + + expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]); + }); +}); + describe("resolveWorkspaceIdAtPath", () => { test("prefers the worktree-kind record on an exact cwd tie", async () => { const targetPath = "/worktrees/repo/feature"; diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 78f8c0b97d..54d772add1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -67,6 +67,9 @@ export type ArchiveScope = export interface ArchiveResult { archivedAgentIds: string[]; + // Agents this request moved from active to archived. `archivedAgentIds` also lists agents that + // were already archived; undo must restore only the ones this archive took down. + newlyArchivedAgentIds: string[]; archivedWorkspaceIds: string[]; removedDirectory: boolean; // Set when the record archived but its worktree directory was left on @@ -78,6 +81,24 @@ export interface ArchiveResult { export interface ArchiveByScopeRequest { scope: ArchiveScope; requestId: string; + // Aborting cancels the archive at its next checkpoint. Removing the worktree directory is the + // point of no return: once it starts the archive completes. + signal?: AbortSignal; +} + +/** + * The archive stopped at a checkpoint because its signal aborted. Whatever ran before the + * checkpoint stays done; `newlyArchivedAgentIds` and `recordArchived` say how far it got so the + * canceller can put it back. + */ +export class WorkspaceArchiveCanceledError extends Error { + constructor( + public readonly newlyArchivedAgentIds: string[], + public readonly recordArchived: boolean, + ) { + super("Workspace archive was canceled"); + this.name = "WorkspaceArchiveCanceledError"; + } } export async function requireActiveWorkspaceForArchive( @@ -144,10 +165,22 @@ async function archiveByScopeWithPriority( dependencies: ArchiveDependencies, request: ArchiveByScopeRequest, ): Promise { + const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false }; + const checkpoint = () => { + if (request.signal?.aborted) { + throw new WorkspaceArchiveCanceledError( + Array.from(progress.newlyArchivedAgentIds), + progress.recordArchived, + ); + } + }; + + checkpoint(); const target = await resolveArchiveTarget(dependencies, request.scope); const targetWorkspaceIds = target.workspaceIds; await stopWorkspaceSetups(dependencies, target.setupWorkspaceIds, request.requestId); + checkpoint(); if (targetWorkspaceIds.length > 0) { dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString()); @@ -160,12 +193,14 @@ async function archiveByScopeWithPriority( if (targetWorkspaceIds.length > 0) { await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds); } + checkpoint(); const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( dependencies, targetWorkspaceIds, - request.requestId, + { requestId: request.requestId, checkpoint, progress }, ); + checkpoint(); if (target.backing?.mainRepoRoot) { try { @@ -184,7 +219,7 @@ async function archiveByScopeWithPriority( if (target.backing !== null) { const removal = await maybeRemoveDirectory( dependencies, - request, + { requestId: request.requestId, checkpoint }, target, archivedWorkspaceIds, ); @@ -194,6 +229,7 @@ async function archiveByScopeWithPriority( return { archivedAgentIds: Array.from(archivedAgents), + newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds), archivedWorkspaceIds, removedDirectory, ...(directoryLeftBehind ? { directoryLeftBehind } : {}), @@ -206,6 +242,11 @@ async function archiveByScopeWithPriority( } } +interface ArchiveProgress { + newlyArchivedAgentIds: Set; + recordArchived: boolean; +} + async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, @@ -352,16 +393,23 @@ async function resolveBackingDirectory( async function archiveTargetRecords( dependencies: ArchiveDependencies, targetWorkspaceIds: string[], - requestId: string, + options: { requestId: string; checkpoint: () => void; progress: ArchiveProgress }, ): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { const archivedAgents = new Set(); const archivedWorkspaceIds: string[] = []; const results = await Promise.allSettled( targetWorkspaceIds.map(async (workspaceId) => { - const agents = await archiveWorkspaceContents(dependencies, workspaceId); + const contents = await archiveWorkspaceContentsWithProgress(dependencies, workspaceId); + for (const agentId of contents.newlyArchivedAgentIds) { + options.progress.newlyArchivedAgentIds.add(agentId); + } + // Agents and terminals are down, the record is not: an abort here leaves the workspace + // listed, and the canceller restores the agents it reports. + options.checkpoint(); await dependencies.archiveWorkspaceRecord(workspaceId); - return { workspaceId, agents }; + options.progress.recordArchived = true; + return { workspaceId, agents: contents.agentIds }; }), ); @@ -371,9 +419,14 @@ async function archiveTargetRecords( for (const agentId of result.value.agents) { archivedAgents.add(agentId); } + } else if (result.reason instanceof WorkspaceArchiveCanceledError) { + throw new WorkspaceArchiveCanceledError( + Array.from(options.progress.newlyArchivedAgentIds), + options.progress.recordArchived, + ); } else { dependencies.sessionLogger?.warn( - { err: result.reason, requestId }, + { err: result.reason, requestId: options.requestId }, "archiveByScope workspace teardown failed; continuing", ); } @@ -384,7 +437,7 @@ async function archiveTargetRecords( async function maybeRemoveDirectory( dependencies: ArchiveDependencies, - request: Pick, + request: { requestId: string; checkpoint: () => void }, target: ArchiveTarget, archivedWorkspaceIds: string[], ): Promise<{ @@ -423,6 +476,7 @@ async function maybeRemoveDirectory( teardownTargets, ); for (const teardownTarget of uniqueTeardownTargets(allowedTeardownTargets)) { + request.checkpoint(); await runWorktreeTeardownCommands({ worktreePath: backing.path, teardownCwd: teardownTarget.cwd, @@ -452,6 +506,9 @@ async function maybeRemoveDirectory( return { removedDirectory: false }; } + // Last chance to keep the directory. Teardown commands already ran, so a cancel here restores + // a workspace whose teardown hooks fired; that beats deleting what the user asked to keep. + request.checkpoint(); try { await deletePaseoWorktree({ cwd: backing.mainRepoRoot, @@ -518,6 +575,13 @@ export async function archiveWorkspaceContents( dependencies: ArchiveWorkspaceContentsDependencies, workspaceId: string, ): Promise> { + return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds; +} + +async function archiveWorkspaceContentsWithProgress( + dependencies: ArchiveWorkspaceContentsDependencies, + workspaceId: string, +): Promise<{ agentIds: Set; newlyArchivedAgentIds: string[] }> { const archivedAgents = new Set(); const liveAgents = dependencies.agentManager @@ -542,29 +606,52 @@ export async function archiveWorkspaceContents( } const archivedAt = new Date().toISOString(); - const agentIdsToArchive = new Set([ - ...liveAgents.map((agent) => agent.id), - ...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id), - ]); - const archiveResults = await Promise.allSettled([ - ...[...agentIdsToArchive].map((agentId) => - dependencies.agentManager.getAgent(agentId) - ? dependencies.agentManager.archiveAgent(agentId) - : dependencies.agentManager.archiveSnapshot(agentId, archivedAt), + const agentIdsToArchive = [ + ...new Set([ + ...liveAgents.map((agent) => agent.id), + ...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id), + ]), + ]; + // Undo brings back only what this archive took down. Internal agents are retired rather than + // archived, and an agent loaded only to read its history was archived before this request. + const restorableAgentIds = new Set(agentIdsToArchive); + for (const agent of liveAgents) { + if (agent.internal) restorableAgentIds.delete(agent.id); + } + for (const record of matchingStoredRecords) { + if (record.archivedAt) restorableAgentIds.delete(record.id); + } + const [agentResults, [terminalResult]] = await Promise.all([ + Promise.allSettled( + agentIdsToArchive.map((agentId) => + dependencies.agentManager.getAgent(agentId) + ? dependencies.agentManager.archiveAgent(agentId) + : dependencies.agentManager.archiveSnapshot(agentId, archivedAt), + ), ), - dependencies.killTerminalsForWorkspace(workspaceId), + Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]), ]); - for (const result of archiveResults) { + const newlyArchivedAgentIds: string[] = []; + for (const [index, result] of agentResults.entries()) { + const agentId = agentIdsToArchive[index]!; if (result.status === "rejected") { dependencies.sessionLogger?.warn( - { err: result.reason, workspaceId }, + { err: result.reason, workspaceId, agentId }, "Workspace archive teardown step failed; continuing", ); + } else if (restorableAgentIds.has(agentId)) { + newlyArchivedAgentIds.push(agentId); } } + if (terminalResult?.status === "rejected") { + dependencies.sessionLogger?.warn( + { err: terminalResult.reason, workspaceId }, + "Workspace archive teardown step failed; continuing", + ); + } - return archivedAgents; + return { agentIds: archivedAgents, newlyArchivedAgentIds }; } // True when, after archiving diff --git a/packages/server/src/server/workspace-archive-undo.e2e.test.ts b/packages/server/src/server/workspace-archive-undo.e2e.test.ts new file mode 100644 index 0000000000..674e99415b --- /dev/null +++ b/packages/server/src/server/workspace-archive-undo.e2e.test.ts @@ -0,0 +1,275 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; + +// Undo of a workspace archive, end to end through a real daemon. The archive is parked inside a +// worktree teardown command that waits on a file, so the undo lands at a known point. + +let ctx: DaemonTestContext; +const tempRoots: string[] = []; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); + +afterEach(async () => { + await ctx.cleanup(); + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +interface GatedRepo { + repoDir: string; + teardownStartedPath: string; + releaseTeardown: () => void; +} + +function git(repoDir: string, args: string[]): void { + execFileSync("git", args, { cwd: repoDir, stdio: "pipe" }); +} + +/** A repo whose worktree teardown parks until released. */ +function createRepoWithGatedTeardown(): GatedRepo { + const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-archive-undo-")); + tempRoots.push(tempRoot); + const repoDir = path.join(tempRoot, "repo"); + const teardownStartedPath = path.join(tempRoot, "teardown-started"); + const releasePath = path.join(tempRoot, "release-teardown"); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + git(repoDir, ["config", "user.email", "test@getpaseo.local"]); + git(repoDir, ["config", "user.name", "Paseo Test"]); + const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${teardownStartedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`; + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ worktree: { teardown: [teardown] } }), + ); + git(repoDir, ["add", "paseo.json"]); + git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "gated teardown"]); + return { + repoDir, + teardownStartedPath, + releaseTeardown: () => writeFileSync(releasePath, "release\n"), + }; +} + +async function createWorktreeWorkspaceWithAgent(repoDir: string, slug: string) { + const result = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repoDir, worktreeSlug: slug, baseBranch: "main" }, + }); + const workspace = result.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(result.error ?? "Failed to create worktree workspace"); + } + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: "Undo agent", + }); + return { workspaceId: workspace.id, directory: workspace.workspaceDirectory, agentId: agent.id }; +} + +async function isWorkspaceActive(workspaceId: string): Promise { + const workspaces = await ctx.client.fetchWorkspaces(); + return workspaces.entries.some((entry) => entry.id === workspaceId); +} + +async function isAgentArchived(agentId: string): Promise { + const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } }); + const entry = agents.entries.find((candidate) => candidate.agent.id === agentId); + if (!entry) throw new Error(`Agent ${agentId} is missing`); + return entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined; +} + +test.skipIf(process.platform === "win32")( + "an undo while teardown runs cancels the archive before the worktree is deleted", + async () => { + const repo = createRepoWithGatedTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-during-teardown", + ); + + try { + const archive = ctx.client.archiveWorkspace(workspaceId); + await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true); + const undo = ctx.client.undoWorkspaceArchive(workspaceId); + repo.releaseTeardown(); + + const [archiveResponse] = await Promise.all([archive, undo]); + + expect({ + canceled: archiveResponse.canceled, + archivedAt: archiveResponse.archivedAt, + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ + canceled: true, + archivedAt: null, + workspaceActive: true, + directoryExists: true, + agentArchived: false, + }); + } finally { + repo.releaseTeardown(); + } + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "an undo after the archive finished recreates the worktree and its agents", + async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-after-archive", + ); + + const archived = await ctx.client.archiveWorkspace(workspaceId); + expect(archived.error).toBeNull(); + expect(existsSync(directory)).toBe(false); + expect(await isAgentArchived(agentId)).toBe(true); + + await ctx.client.undoWorkspaceArchive(workspaceId); + + expect({ + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false }); + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "archive, undo, archive leaves the workspace archived", + async () => { + const repo = createRepoWithGatedTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "archive-undo-archive", + ); + + try { + const first = ctx.client.archiveWorkspace(workspaceId); + await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true); + const undo = ctx.client.undoWorkspaceArchive(workspaceId); + const second = ctx.client.archiveWorkspace(workspaceId); + repo.releaseTeardown(); + + const [firstResponse, , secondResponse] = await Promise.all([first, undo, second]); + + expect({ + firstCanceled: firstResponse.canceled, + secondError: secondResponse.error, + secondCanceled: secondResponse.canceled ?? false, + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ + firstCanceled: true, + secondError: null, + secondCanceled: false, + workspaceActive: false, + directoryExists: false, + agentArchived: true, + }); + } finally { + repo.releaseTeardown(); + } + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "an undo sent right behind its archive leaves the workspace active", + async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "archive-then-undo-same-tick", + ); + + const [archiveResponse] = await Promise.all([ + ctx.client.archiveWorkspace(workspaceId), + ctx.client.undoWorkspaceArchive(workspaceId), + ]); + + expect({ + archiveCanceled: archiveResponse.canceled ?? false, + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ + archiveCanceled: true, + workspaceActive: true, + directoryExists: true, + agentArchived: false, + }); + }, + 60000, +); + +test.skipIf(process.platform === "win32")( + "the undo response arrives after the client has seen its agents restored", + async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-update-order", + ); + await ctx.client.fetchAgents({ subscribe: {} }); + // An archived agent leaves a default subscription; a restored one comes back as active. + let seenState: "active" | "archived" | "removed" = "active"; + const stop = ctx.client.on("agent_update", (message) => { + if (message.payload.kind === "remove" && message.payload.agentId === agentId) { + seenState = "removed"; + } + if (message.payload.kind === "upsert" && message.payload.agent.id === agentId) { + seenState = message.payload.agent.archivedAt ? "archived" : "active"; + } + }); + + try { + await ctx.client.archiveWorkspace(workspaceId); + await expect.poll(() => seenState, { timeout: 15000 }).not.toBe("active"); + + await ctx.client.undoWorkspaceArchive(workspaceId); + + // An app holds its agent tabs open until this response; an agent it still sees as + // archived at that point gets its tab closed. + expect(seenState).toBe("active"); + } finally { + stop(); + } + }, + 60000, +); + +test("an undo on a workspace that was never archived leaves it as it was", async () => { + const repo = createRepoWithGatedTeardown(); + repo.releaseTeardown(); + const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent( + repo.repoDir, + "undo-without-archive", + ); + + await ctx.client.undoWorkspaceArchive(workspaceId); + + expect({ + workspaceActive: await isWorkspaceActive(workspaceId), + directoryExists: existsSync(directory), + agentArchived: await isAgentArchived(agentId), + }).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false }); +}, 60000); From c8bd4717c9709255445af98ef503dc28982b4c65 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:25:42 +0000 Subject: [PATCH 09/32] test(server): wait for Git ref observation before injecting events (#8924) Zero in-flight fetches also describes observation that has not started. Wait for the first fetch to start and settle, and exercise delayed watcher setup so missing callbacks cannot silently skip the test events. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../workspace-git-service.observation.test.ts | 124 ++++++++++-------- 1 file changed, 69 insertions(+), 55 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 04604cd1b5..86a49b55ac 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -811,64 +811,78 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("known packed remote refs materialize narrowly while namespace directories are ignored", async () => { - const watcher = createWatcherHarness(); - const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ - ...createCheckoutFacts(cwd), - currentBranch: "feature", - remoteUrl: REMOTE_URL, - resolvedBaseRef: "main", - comparisonBaseRef: "origin/main", - })); - const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({ - ...current, - upstreamStatus: facts.upstreamStatus, - })); - const service = createService(watcher, { - getCheckoutSnapshotFacts, - getCheckoutRefDerivedState, - hasOriginRemote: vi.fn(async () => true), - runGitFetch: vi.fn(async (_cwd, observer) => { - observer.onRefSnapshot("before"); - observer.onRefSnapshot("after"); - return { - changes: [], - nonRemoteRefsChanged: false, - remoteRefs: new Set(["origin/main", "origin/load/00001"]), - error: null, - }; - }), - }); - const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await vi.waitFor(() => { - expect(service.getMetrics().fetchInFlightCount).toBe(0); + test.each([0, 100])( + "known packed remote refs materialize narrowly while namespace directories are ignored (watch setup delay %i ms)", + async (setupDelayMs) => { + const watcher = createWatcherHarness(); + let fetchStarted = false; + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ + ...createCheckoutFacts(cwd), + currentBranch: "feature", + remoteUrl: REMOTE_URL, + resolvedBaseRef: "main", + comparisonBaseRef: "origin/main", + })); + const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({ + ...current, + upstreamStatus: facts.upstreamStatus, + })); + const service = createService(watcher, { + subscribe: async (...args: Parameters) => { + if (args[0] === GIT_DIR && setupDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, setupDelayMs)); + } + return watcher.subscribe(...args); + }, + getCheckoutSnapshotFacts, + getCheckoutRefDerivedState, + hasOriginRemote: vi.fn(async () => true), + runGitFetch: vi.fn(async (_cwd, observer) => { + fetchStarted = true; + observer.onRefSnapshot("before"); + observer.onRefSnapshot("after"); + return { + changes: [], + nonRemoteRefsChanged: false, + remoteRefs: new Set(["origin/main", "origin/load/00001"]), + error: null, + }; + }), + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + // An idle fetch count also holds before observation setup starts. + expect(fetchStarted).toBe(true); + expect(service.getMetrics().fetchInFlightCount).toBe(0); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + }); + + const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR)!; + expect(repoWatcher).toBeDefined(); + repoWatcher.callback(null, [ + { path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" }, + { + path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"), + type: "create", + }, + ]); + await vi.advanceTimersByTimeAsync(1_000); expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); - }); + expect(getCheckoutRefDerivedState).not.toHaveBeenCalled(); - const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR); - repoWatcher?.callback(null, [ - { path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" }, - { - path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"), - type: "create", - }, - ]); - await vi.advanceTimersByTimeAsync(1_000); - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); - expect(getCheckoutRefDerivedState).not.toHaveBeenCalled(); + repoWatcher.callback(null, [ + { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1); + }); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); - repoWatcher?.callback(null, [ - { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" }, - ]); - await vi.advanceTimersByTimeAsync(1_000); - await vi.waitFor(() => { - expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1); - }); - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); - - subscription.unsubscribe(); - service.dispose(); - }); + subscription.unsubscribe(); + await service.dispose(); + }, + ); test("a second remote-ref move during a narrow refresh queues a final calculation", async () => { const watcher = createWatcherHarness(); From 77798b2e469fedf93bda02c1987c9a26adaf989b Mon Sep 17 00:00:00 2001 From: fxlelouarn <115778739+fxlelouarn@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:49:49 +0100 Subject: [PATCH 10/32] Show and stop agent background work (#6220) * feat(server): add an in-memory store for agent background work Co-Authored-By: Claude Opus 5.5 * feat(claude): report background tasks as background work Co-Authored-By: Claude Opus 5.5 * feat(server): track agent background work in the agent manager Co-Authored-By: Claude Opus 5.5 * feat(protocol): add agent background work messages Co-Authored-By: Claude Opus 5.5 * feat(server): serve and push agent background work Co-Authored-By: Claude Opus 5.5 * feat(client): expose agent background work on agent handles Co-Authored-By: Claude Opus 5.5 * docs(sdk): document agent background work Co-Authored-By: Claude Opus 5.5 * test(claude): cover background shells in background work end to end Co-Authored-By: Claude Opus 5.5 * fix(client): resync background work subscriptions on every snapshot subscribe() now lists the agent's background work each time the event observation is established, so the listener starts with the current list and catches up after a reconnect. A list answered after a newer update is dropped. Co-Authored-By: Claude Opus 5.5 * fix(server): answer background work lists without loading dormant agents Background work lives only in memory, so a stored agent that is not loaded has none. The list handler now answers [] from storage instead of resuming the agent, and still rejects unknown, internal, and archived agents. A new test pins that old apps never receive agent.background_work.update. Co-Authored-By: Claude Opus 5.5 * fix(server): read Claude background tasks defensively The user's claude binary can differ from the SDK typings, so a task's description is read as unknown and an empty or missing one becomes null. Drop the monitor kind: Claude Monitor watches report as local_bash, so kind is "shell" or "other" today. The store now copies only the wire fields of each input. Co-Authored-By: Claude Opus 5.5 * test(server): prove internal background work was emitted before checking globals Co-Authored-By: Claude Opus 5.5 * docs(sdk): describe background work resync and feature gating Co-Authored-By: Claude Opus 5.5 * test(claude): cover background work reported by a retired query The daemon stops reading a Claude query it no longer owns, so work that a retired process still had queued cannot reach the background work list. Co-Authored-By: Claude Opus 5.5 * fix(client): let background work subscribers handle a failed list read When the list read that follows each subscription snapshot failed, the error was only logged and the caller got neither the list nor a failure. subscribe() now takes an optional onError that receives it; without one the error is still logged. The read is not retried, since the next reconnect reads the list again. Co-Authored-By: Claude Opus 5.5 * feat(agents): show background work as active Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior. * fix(agents): protect background work subscriptions Keep internal work out of public updates and restore observation after a subscription failure. * feat(agents): show Codex and OpenCode background work Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent. * feat(agents): stop individual background work Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent. * refactor(agents): name background stop parameters * test(website): isolate browser tests from live metadata * test(agents): clarify background stop journeys * fix(sdk): report background work subscription failures Report setup failures so subscribers can replace an observation that the host released. * fix(app): restore background work after reconnect Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns. * fix(agents): retain background shells across rewind Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits. * fix(website): launch test seeder across platforms Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim. * feat(agents): clarify background task details and tracks Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited. * fix(agents): preserve active background work during archive Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open. * fix(codex): retain background terminals across rewind Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions. * fix(codex): scope background terminal identities to threads Prevent commands on separate threads with the same provider process ID from sharing a stop target. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Mohamed Boudra --- .../e2e/browser/composer-diff-stat.spec.ts | 92 ++- .../browser/provider-task-list.real.spec.ts | 433 +++++++++- .../app/e2e/support/helpers/seed-client.ts | 5 +- packages/app/src/agent-presentation/index.ts | 28 +- packages/app/src/background-work/host.test.ts | 97 +++ packages/app/src/background-work/host.ts | 115 +++ packages/app/src/background-work/index.tsx | 155 ++++ .../app/src/background-work/model.test.ts | 233 ++++++ packages/app/src/background-work/model.ts | 121 +++ .../src/background-work/presentation.test.ts | 20 + .../app/src/background-work/presentation.ts | 7 + packages/app/src/composer/tracks.tsx | 28 +- .../app/src/hooks/use-aggregated-agents.ts | 1 + packages/app/src/i18n/resources/ar.ts | 18 + packages/app/src/i18n/resources/en.ts | 18 + packages/app/src/i18n/resources/es.ts | 18 + packages/app/src/i18n/resources/fr.ts | 18 + packages/app/src/i18n/resources/ja.ts | 18 + packages/app/src/i18n/resources/ko.ts | 18 + packages/app/src/i18n/resources/pt-BR.ts | 18 + packages/app/src/i18n/resources/ru.ts | 18 + packages/app/src/i18n/resources/zh-CN.ts | 18 + packages/app/src/mobile-panels/gestures.ts | 12 + .../panels/agent-panel-descriptor.test.tsx | 23 + packages/app/src/panels/agent-tracks.tsx | 13 +- .../app/src/runtime/replica-cache/index.ts | 2 + packages/app/src/stores/session-store.ts | 1 + .../src/subagents/archive-finished.test.ts | 29 + .../app/src/subagents/archive-finished.ts | 11 +- .../src/subagents/archive-subagent.test.ts | 16 + .../app/src/subagents/archive-subagent.ts | 4 +- packages/app/src/subagents/select.test.ts | 3 + packages/app/src/subagents/select.ts | 2 + .../src/subagents/track-presentation.test.ts | 17 + .../app/src/subagents/track-presentation.ts | 12 +- packages/app/src/types/agent-directory.ts | 1 + packages/app/src/utils/agent-grouping.test.ts | 14 + packages/app/src/utils/agent-grouping.ts | 18 +- packages/app/src/utils/agent-snapshots.ts | 2 + .../app/src/utils/workspace-agent-activity.ts | 1 + .../src/connection/legacy-workspaces.ts | 1 + packages/client/src/daemon-client.ts | 55 ++ packages/client/src/index.test.ts | 253 ++++++ packages/client/src/index.ts | 76 ++ .../protocol/src/agent-state-bucket.test.ts | 24 + packages/protocol/src/agent-state-bucket.ts | 5 +- .../src/messages.background-work.test.ts | 103 +++ packages/protocol/src/messages.ts | 71 ++ .../src/server/agent/agent-manager.test.ts | 169 ++++ .../server/src/server/agent/agent-manager.ts | 61 ++ .../server/agent/agent-projections.test.ts | 9 + .../src/server/agent/agent-projections.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 8 + .../agent/background-work/index.test.ts | 133 +++ .../src/server/agent/background-work/index.ts | 88 ++ .../src/server/agent/provider-registry.ts | 1 + .../claude/agent.background-subagent.test.ts | 86 +- .../providers/claude/agent.real.e2e.test.ts | 40 + .../claude/agent.runtime-exit.test.ts | 238 ++++++ .../agent/providers/claude/agent.test.ts | 9 +- .../server/agent/providers/claude/agent.ts | 56 +- .../providers/claude/background-work.test.ts | 21 + .../agent/providers/claude/background-work.ts | 36 + .../providers/claude/runtime-safety.test.ts | 48 ++ .../agent/providers/claude/runtime-safety.ts | 152 +++- .../providers/claude/subagents/live-source.ts | 4 +- .../providers/codex-app-server-agent.test.ts | 94 ++- .../agent/providers/codex-app-server-agent.ts | 21 + .../codex-interrupted-turn-terminals.test.ts | 332 +++++++- .../providers/codex/background-inventory.ts | 187 +++++ .../codex/test-utils/fake-app-server.ts | 1 + .../opencode/test-utils/v2-harness.ts | 1 + .../agent/providers/opencode/v2/agent.test.ts | 770 ++++++++++++++++++ .../server/agent/providers/opencode/v2/api.ts | 2 +- .../opencode/v2/background-inventory.ts | 308 +++++++ .../agent/providers/opencode/v2/session.ts | 23 + .../authorization/operation-permissions.ts | 5 + packages/server/src/server/session.test.ts | 249 ++++++ packages/server/src/server/session.ts | 117 ++- .../src/server/test-utils/session-stubs.ts | 1 + .../server/src/server/websocket-server.ts | 4 + .../server/src/server/wire-compat.test.ts | 4 + .../src/server/workspace-directory.test.ts | 13 + .../server/src/server/workspace-directory.ts | 4 +- .../terminal-session-controller.test.ts | 21 +- packages/website/e2e/agent-icons.spec.ts | 2 +- packages/website/e2e/download-thanks.spec.ts | 2 +- packages/website/e2e/fixtures.ts | 18 + packages/website/e2e/i18n.spec.ts | 2 +- packages/website/e2e/mockups.spec.ts | 2 +- packages/website/e2e/plugins.spec.ts | 2 +- packages/website/e2e/seed-cache.mjs | 54 ++ packages/website/e2e/sponsors.spec.ts | 2 +- packages/website/playwright.config.ts | 8 +- packages/website/vite.config.ts | 7 +- public-docs/sdk/events.md | 19 + public-docs/sdk/events.zh-CN.md | 19 + public-docs/sdk/reference.md | 55 +- public-docs/sdk/reference.zh-CN.md | 55 +- 99 files changed, 5681 insertions(+), 149 deletions(-) create mode 100644 packages/app/src/background-work/host.test.ts create mode 100644 packages/app/src/background-work/host.ts create mode 100644 packages/app/src/background-work/index.tsx create mode 100644 packages/app/src/background-work/model.test.ts create mode 100644 packages/app/src/background-work/model.ts create mode 100644 packages/app/src/background-work/presentation.test.ts create mode 100644 packages/app/src/background-work/presentation.ts create mode 100644 packages/protocol/src/messages.background-work.test.ts create mode 100644 packages/server/src/server/agent/background-work/index.test.ts create mode 100644 packages/server/src/server/agent/background-work/index.ts create mode 100644 packages/server/src/server/agent/providers/claude/background-work.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/background-work.ts create mode 100644 packages/server/src/server/agent/providers/claude/runtime-safety.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/background-inventory.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts create mode 100644 packages/website/e2e/fixtures.ts create mode 100644 packages/website/e2e/seed-cache.mjs diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 4eeada2f1f..5dd2b8ae1d 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,7 +1,9 @@ -import type { Locator } from "@playwright/test"; +import type { CDPSession, Locator } from "@playwright/test"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; +import { seedSubagent } from "../support/helpers/subagents"; +import { submitMessage } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { gateNextAgentMessage } from "../support/helpers/agent-message-gate"; import { @@ -860,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) { body, ); } + +async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") { + const track = page.getByTestId("composer-track-scroll"); + const box = await track.boundingBox(); + if (!box) throw new Error("Composer tracks are not visible"); + const start = direction === "left" ? box.x + box.width - 5 : box.x + 5; + const end = direction === "left" ? box.x + 5 : box.x + box.width - 5; + await swipeTouch(client, start, end, box.y + box.height / 2); +} + +async function swipeTouch(client: CDPSession, start: number, end: number, y: number) { + await client.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: start, y }], + }); + for (let step = 1; step <= 8; step++) { + await client.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: start + ((end - start) * step) / 8, y }], + }); + } + await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); +} + +async function trackScrollOffset(track: Locator): Promise { + return track.evaluate((node) => node.scrollLeft); +} + +test.describe("touch composer tracks", () => { + test.use({ hasTouch: true, isMobile: true }); + test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({ + page, + }) => { + const workspace = await seedChangedAgent("composer-tracks-overflow-"); + const touch = await page.context().newCDPSession(page); + try { + await seedSubagent( + { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId }, + { parentId: workspace.agentId, title: "Track child" }, + ); + await page.setViewportSize({ width: 1000, height: 844 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + await submitMessage(page, "emit 1 agent stream updates"); + const tasks = page.getByTestId("agent-task-list-header"); + const subagents = page.getByTestId("subagents-track-header"); + const changes = page.getByTestId("composer-diff-stat-pill"); + await expect(tasks).toBeVisible(); + await expect(subagents).toBeVisible(); + await expect(changes).toBeVisible(); + const wideWidths = await Promise.all( + [tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width), + ); + await page.setViewportSize({ width: 200, height: 844 }); + const narrowWidths = await Promise.all( + [tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width), + ); + for (const [index, width] of wideWidths.entries()) + expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1); + const track = page.getByTestId("composer-track-scroll"); + expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan( + await track.evaluate((node) => node.clientWidth), + ); + await swipeComposerTracks(page, touch, "left"); + await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0); + await swipeComposerTracks(page, touch, "right"); + await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1); + await swipeComposerTracks(page, touch, "left"); + await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0); + await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport(); + await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport(); + await page.getByRole("button", { name: "Open menu" }).click(); + await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 }); + await page.getByTestId("sidebar-close").click(); + await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport(); + await expect(changes).toBeInViewport(); + await changes.click(); + await expect( + page.getByTestId("changes-header").filter({ visible: true }).first(), + ).toBeVisible(); + } finally { + await touch.detach(); + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/provider-task-list.real.spec.ts b/packages/app/e2e/browser/provider-task-list.real.spec.ts index b388b7d2c7..9636f7c799 100644 --- a/packages/app/e2e/browser/provider-task-list.real.spec.ts +++ b/packages/app/e2e/browser/provider-task-list.real.spec.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, realpathSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "../support/fixtures"; +import type { Locator } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; import { cleanupRewindFlow, launchAgent, @@ -14,7 +15,11 @@ import { expectTaskListEntries, openCompletedTaskList, } from "../support/helpers/task-list"; -import { submitMessage } from "../support/helpers/composer"; +import { submitMessage, submitMessageWithButton } from "../support/helpers/composer"; +import { waitForSettledPosition } from "../support/helpers/sheet-layout"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { gotoAppShell } from "../support/helpers/app"; interface TaskScenario { provider: RewindFlowProvider; @@ -114,3 +119,425 @@ test.describe("real provider composer task lists", () => { } }); }); + +async function openBackgroundInventory( + page: Page, + options: { compact: boolean; count: number; timeout: number }, +): Promise { + const pill = page.getByRole("button", { + name: options.count === 1 ? "1 background task" : `${options.count} background tasks`, + }); + await expect(pill).toBeVisible({ timeout: options.timeout }); + await pill.click(); + const panel = page.getByTestId( + options.compact ? "background-work-panel-content" : "background-work-panel", + ); + if (options.compact) await waitForSettledPosition(panel); + return panel; +} + +async function openBackgroundWork(page: Page, compact: boolean): Promise { + const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 }); + await expect(panel).toContainText(/sleep 120/); +} + +async function expectBackgroundItems( + panel: Locator, + selected: string, + sibling: string, +): Promise { + await expect(panel).toContainText(selected); + await expect(panel).toContainText(sibling); +} + +async function stopBackgroundItem(panel: Locator, selected: string): Promise { + const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click(); +} + +async function expectStoppedItemAndSibling( + panel: Locator, + selected: string, + sibling: string, +): Promise { + await expect(panel).not.toContainText(selected, { timeout: 40_000 }); + await expect(panel).toContainText(sibling); +} + +async function closeBackgroundWork(page: Page, compact: boolean): Promise { + const panel = page.getByTestId( + compact ? "background-work-panel-content" : "background-work-panel", + ); + if (compact) { + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .click({ position: { x: 12, y: 12 } }); + } else { + await page.keyboard.press("Escape"); + } + await expect(panel).toBeHidden(); +} + +async function expectBackgroundAgentRunning(page: Page): Promise { + await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0); +} + +async function expectAssistantReply(page: Page, text: string): Promise { + await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({ + timeout: 120_000, + }); +} + +async function expectBackgroundPill(page: Page): Promise { + await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible(); +} + +async function expectBackgroundWorkOnAnotherClient(page: Page): Promise { + const another = await page.context().newPage(); + try { + await another.goto(page.url()); + await openBackgroundWork(another, false); + } finally { + await another.close(); + } +} + +async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise { + const agent = await workspace.client.createAgent({ + provider: "claude", + model: "haiku", + modeId: "bypassPermissions", + cwd: workspace.workspaceDirectory, + workspaceId: workspace.workspaceId, + title: "Background work before opening", + }); + await workspace.client.sendAgentMessage( + agent.id, + "Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.", + ); + const result = await workspace.client.waitForFinish(agent.id, 120_000); + expect(result.status).toBe("idle"); +} + +async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({ + timeout: 30_000, + }); +} + +test("Claude keeps background work visible while accepting another turn", async ({ page }) => { + test.setTimeout(300_000); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" }); + await sendMessage( + handle, + "Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.", + ); + await expectBackgroundAgentRunning(page); + await openBackgroundWork(page, false); + await page.screenshot({ + path: path.resolve("../../.artifacts/phase1-claude-background.png"), + animations: "disabled", + }); + await closeBackgroundWork(page, false); + await sendMessage(handle, "Reply exactly HELLO without stopping the background command."); + await expectBackgroundPill(page); + await expectBackgroundWorkOnAnotherClient(page); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); + +test("Claude background work remains readable in dark mode", async ({ page }) => { + test.setTimeout(300_000); + await page.addInitScript(() => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" })); + }); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" }); + await sendMessage( + handle, + "Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.", + ); + await openBackgroundWork(page, false); + await page.screenshot({ + path: path.resolve("../../.artifacts/review-background-dark.png"), + animations: "disabled", + }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); + +test("Claude background work opens in a compact sheet", async ({ page }) => { + test.setTimeout(300_000); + await page.setViewportSize({ width: 390, height: 844 }); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" }); + await submitMessageWithButton( + page, + "Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.", + ); + await expectAssistantReply(page, "SPAWNED"); + await openBackgroundWork(page, true); + await page.screenshot({ + path: path.resolve("../../.artifacts/phase1-claude-compact.png"), + animations: "disabled", + }); + await closeBackgroundWork(page, true); + await submitMessageWithButton( + page, + "Reply exactly HELLO without stopping the background command.", + ); + await expectAssistantReply(page, "HELLO"); + await expectBackgroundPill(page); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); + +test("background work updates the sidebar before the agent is opened", async ({ page }) => { + test.setTimeout(300_000); + const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" }); + try { + await gotoAppShell(page); + await expect( + page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`), + ).toBeVisible(); + await startBackgroundWorkWithoutOpeningPane(workspace); + await expectWorkspaceRunning(page, workspace.workspaceId); + } finally { + await workspace.cleanup(); + } +}); + +test("Codex keeps a live command visible after its turn", async ({ page }) => { + test.setTimeout(300_000); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: "codex", + cwd, + mode: "full-access", + providerConfig: { model: "gpt-6-astra" }, + }); + await sendMessage( + handle, + "Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.", + ); + await expectBackgroundAgentRunning(page); + await openBackgroundWork(page, false); + await page.screenshot({ + path: path.resolve("../../.artifacts/phase2-codex-background.png"), + animations: "disabled", + }); + await closeBackgroundWork(page, false); + await sendMessage(handle, "Reply exactly HELLO without stopping the background command."); + await expectBackgroundPill(page); + await expectBackgroundWorkOnAnotherClient(page); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); + +test("OpenCode keeps a live command visible after its turn", async ({ page }) => { + test.setTimeout(300_000); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: "opencode", + cwd, + mode: "full-access", + providerConfig: { model: "openrouter/google/gemini-3.6-flash" }, + }); + await sendMessage( + handle, + "Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.", + ); + await expectBackgroundAgentRunning(page); + await openBackgroundWork(page, false); + await page.screenshot({ + path: path.resolve("../../.artifacts/phase2-opencode-background.png"), + animations: "disabled", + }); + await closeBackgroundWork(page, false); + await sendMessage(handle, "Reply exactly HELLO without stopping the background command."); + await expectBackgroundPill(page); + await expectBackgroundWorkOnAnotherClient(page); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); + +for (const scenario of [ + { + provider: "claude" as const, + prompt: + "Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.", + providerConfig: { model: "haiku" }, + }, + { + provider: "codex" as const, + prompt: + "Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.", + providerConfig: { model: "gpt-6-astra" }, + }, + { + provider: "opencode" as const, + prompt: + "Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.", + providerConfig: { model: "openrouter/google/gemini-3.6-flash" }, + }, +]) { + test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => { + test.setTimeout(300_000); + mkdirSync(path.resolve("../../.artifacts"), { recursive: true }); + const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, + }); + await sendMessage(handle, scenario.prompt); + const panel = await openBackgroundInventory(page, { + compact: false, + count: 2, + timeout: 60_000, + }); + await expectBackgroundItems(panel, "sleep 180", "sleep 181"); + await stopBackgroundItem(panel, "sleep 180"); + await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181"); + await page.screenshot({ + path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`), + animations: "disabled", + }); + await page.keyboard.press("Escape"); + await sendMessage(handle, "Reply exactly HELLO without stopping the background command."); + await expectBackgroundPill(page); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +} + +test("Claude stops a selected monitor in the compact sheet", async ({ page }) => { + test.setTimeout(300_000); + await page.setViewportSize({ width: 390, height: 844 }); + mkdirSync(path.resolve("../../.artifacts"), { recursive: true }); + const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: "claude", + cwd, + mode: "full-access", + providerConfig: { model: "opus" }, + }); + await submitMessageWithButton( + page, + 'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.', + ); + await expectAssistantReply(page, "SPAWNED"); + const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 }); + await expectBackgroundItems(panel, "probe monitor one", "probe monitor two"); + await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2); + await page.screenshot({ + path: path.resolve("../../.artifacts/refinement-monitor-compact.png"), + animations: "disabled", + }); + await stopBackgroundItem(panel, "probe monitor one"); + await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two"); + await page.screenshot({ + path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"), + animations: "disabled", + }); + await closeBackgroundWork(page, true); + await submitMessageWithButton( + page, + "Reply exactly HELLO without stopping the background monitor.", + ); + await expectAssistantReply(page, "HELLO"); + await expectBackgroundPill(page); + await handle.client.sendAgentMessage( + handle.agentId, + "Use Bash to run sleep 20 in the foreground, then reply WAITED.", + ); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({ + timeout: 30_000, + }); + await handle.client.sendAgentMessage( + handle.agentId, + "Reply exactly INTERRUPTED without stopping the background monitor.", + { activeTurnBehavior: "interrupt" }, + ); + expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle"); + await expectBackgroundPill(page); + await expect + .poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId)) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ description: "probe monitor two", kind: "monitor" }), + ]), + ); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); + +test("Claude shows monitor description and command in the wide track", async ({ page }) => { + test.setTimeout(300_000); + const cwd = realpathSync( + mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")), + ); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: "claude", + cwd, + mode: "full-access", + providerConfig: { model: "opus" }, + }); + await sendMessage( + handle, + 'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.', + ); + await expectBackgroundAgentRunning(page); + const workspaceRow = page.getByTestId( + `sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`, + ); + await workspaceRow.scrollIntoViewIfNeeded(); + await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible(); + const panel = await openBackgroundInventory(page, { + compact: false, + count: 1, + timeout: 30_000, + }); + await expect(panel).toContainText("Watch the build"); + await expect(panel).toContainText("sleep 150"); + await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible(); + await page.screenshot({ + path: path.resolve("../../.artifacts/refinement-monitor-wide.png"), + animations: "disabled", + }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +}); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 2debc70406..41e676473f 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -157,8 +157,11 @@ export interface SeedDaemonClient { sendAgentMessage( agentId: string, text: string, - options?: { sourceAgentId?: string }, + options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" }, ): Promise; + listAgentBackgroundWork( + agentId: string, + ): Promise; waitForFinish( agentId: string, timeout?: number, diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts index f3c3f139f7..0afb1426d4 100644 --- a/packages/app/src/agent-presentation/index.ts +++ b/packages/app/src/agent-presentation/index.ts @@ -1,4 +1,6 @@ import { useShallow } from "zustand/shallow"; +import { useTranslation } from "react-i18next"; +import { i18n } from "@/i18n/i18next"; import { useProviderIcon } from "@/components/provider-icons"; import type { PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store"; @@ -33,6 +35,7 @@ export function useAgentDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { + useTranslation(); const descriptorState = useSessionStore( useShallow((state) => { const session = state.sessions[context.serverId]; @@ -42,6 +45,7 @@ export function useAgentDescriptor( provider: agent?.provider ?? "codex", title: agent?.title ?? null, status: agent?.status ?? null, + backgroundWork: agent?.backgroundWork, pendingPermissionCount: agent?.pendingPermissions.length ?? 0, requiresAttention: agent?.requiresAttention ?? false, attentionReason: agent?.attentionReason ?? null, @@ -57,6 +61,7 @@ export function buildAgentDescriptor(input: { provider: Agent["provider"]; title: string | null; status: Agent["status"] | null; + backgroundWork?: Agent["backgroundWork"]; pendingPermissionCount: number; requiresAttention: boolean; attentionReason: Agent["attentionReason"]; @@ -65,20 +70,25 @@ export function buildAgentDescriptor(input: { }): PanelDescriptor { const provider = input.provider; const label = resolveAgentLabel(input.title); + const statusBucket = input.status + ? deriveSidebarStateBucket({ + status: input.isTurnActive ? "running" : input.status, + backgroundWork: input.backgroundWork, + pendingPermissionCount: input.pendingPermissionCount, + requiresAttention: input.requiresAttention, + attentionReason: input.attentionReason, + }) + : null; return { label: label ?? "", - subtitle: `${formatProviderLabel(provider)} agent`, + subtitle: + statusBucket === "running" && !input.isTurnActive && input.status !== "running" + ? i18n.t("message.backgroundWork.running") + : `${formatProviderLabel(provider)} agent`, tooltip: label ?? `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon: input.icon, - statusBucket: input.status - ? deriveSidebarStateBucket({ - status: input.isTurnActive ? "running" : input.status, - pendingPermissionCount: input.pendingPermissionCount, - requiresAttention: input.requiresAttention, - attentionReason: input.attentionReason, - }) - : null, + statusBucket, }; } diff --git a/packages/app/src/background-work/host.test.ts b/packages/app/src/background-work/host.test.ts new file mode 100644 index 0000000000..9142f56df4 --- /dev/null +++ b/packages/app/src/background-work/host.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "vitest"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { createHostBackgroundWorkModel } from "./host"; + +test("retries a failed observation and receives subsequent updates", async () => { + interface Observer { + snapshot(): void; + update(message: SessionOutboundMessage): void; + error(error: unknown): void; + } + const observers: Observer[] = []; + let releases = 0; + const item = { + id: "shell", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01T00:00:00Z", + }; + const client = { + getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }), + stopAgentBackgroundWork: async () => {}, + listAgentBackgroundWork: async () => [item], + observeAgentBackgroundWork: () => ({ + subscribe: (observer: Observer) => { + observers.push(observer); + }, + release: async () => { + releases++; + }, + }), + }; + const model = createHostBackgroundWorkModel("host", "agent", { + getClient: () => client, + subscribe: () => () => {}, + }); + const unsubscribe = model.subscribe(() => {}); + observers[0].snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[0].error(new Error("subscription failed")); + expect(model.getSnapshot()).toEqual({ status: "error", items: [item] }); + model.retry(); + expect(observers).toHaveLength(2); + expect(releases).toBe(1); + observers[1].snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[0].error(new Error("late error")); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[1].update({ + type: "agent.background_work.update", + payload: { agentId: "agent", items: [] }, + }); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("starts observing after the same client reconnects", async () => { + let connected = false; + let notify = () => {}; + let snapshot = () => {}; + const item = { + id: "shell", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01T00:00:00Z", + }; + const client = { + getLastServerInfoMessage: () => + connected ? { features: { agentBackgroundWork: true } } : null, + stopAgentBackgroundWork: async () => {}, + listAgentBackgroundWork: async () => [item], + observeAgentBackgroundWork: () => ({ + subscribe: (observer: { snapshot(): void }) => { + snapshot = observer.snapshot; + }, + release: async () => {}, + }), + }; + const model = createHostBackgroundWorkModel("host", "agent", { + getClient: () => client, + subscribe: (listener) => { + notify = listener; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + connected = true; + notify(); + snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + unsubscribe(); +}); diff --git a/packages/app/src/background-work/host.ts b/packages/app/src/background-work/host.ts new file mode 100644 index 0000000000..00134f18f0 --- /dev/null +++ b/packages/app/src/background-work/host.ts @@ -0,0 +1,115 @@ +import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { createBackgroundWorkModel } from "./model"; + +interface BackgroundWorkClient { + getLastServerInfoMessage(): { + features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null; + } | null; + stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise; + listAgentBackgroundWork(agentId: string): Promise; + observeAgentBackgroundWork(): { + subscribe(observer: { + snapshot(): void; + update(message: SessionOutboundMessage): void; + error(error: unknown): void; + }): unknown; + release(): Promise; + }; +} + +interface BackgroundWorkHost { + getClient(): BackgroundWorkClient | null; + subscribe(listener: () => void): () => void; +} + +function hostFor(serverId: string): BackgroundWorkHost { + const host = getHostRuntimeStore(); + return { + getClient: () => host.getSnapshot(serverId)?.client ?? null, + subscribe: (listener) => host.subscribe(serverId, listener), + }; +} + +export function createHostBackgroundWorkModel( + serverId: string, + agentId: string, + host: BackgroundWorkHost = hostFor(serverId), +) { + let client: BackgroundWorkClient | null = null; + const controls = (items: PaseoAgentBackgroundWorkItem[]) => { + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true) + return items; + return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item); + }; + return createBackgroundWorkModel({ + stop: (item) => { + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.stopAgentBackgroundWork(agentId, item); + }, + list: () => { + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.listAgentBackgroundWork(agentId).then(controls); + }, + observe: ({ snapshot, update, disconnected, failed }) => { + let release: (() => void) | undefined; + let currentObservation: + | ReturnType + | undefined; + const sync = () => { + const candidate = host.getClient(); + // The runtime keeps the client across reconnects, but clears server info until handshake. + const next = candidate?.getLastServerInfoMessage() ? candidate : null; + if (next === client) return; + const previousRelease = release; + client = next; + release = undefined; + disconnected(); + previousRelease?.(); + if (!client) return; + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) { + failed(); + return; + } + const observedClient = client; + const observation = client.observeAgentBackgroundWork(); + currentObservation = observation; + observation.subscribe({ + snapshot: () => { + if (client === observedClient && currentObservation === observation) snapshot(); + }, + update: (message) => { + if ( + client === observedClient && + currentObservation === observation && + message.type === "agent.background_work.update" && + message.payload.agentId === agentId + ) + update(controls(message.payload.items)); + }, + error: () => { + if (client === observedClient && currentObservation === observation) failed(); + }, + }); + release = () => { + if (currentObservation === observation) currentObservation = undefined; + void observation + .release() + .catch((error: unknown) => + console.error("Background work subscription cleanup failed", error), + ); + }; + }; + const unsubscribe = host.subscribe(sync); + sync(); + return () => { + unsubscribe(); + client = null; + release?.(); + }; + }, + }); +} diff --git a/packages/app/src/background-work/index.tsx b/packages/app/src/background-work/index.tsx new file mode 100644 index 0000000000..34dfbb83ff --- /dev/null +++ b/packages/app/src/background-work/index.tsx @@ -0,0 +1,155 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; +import { LiveElapsed } from "@/components/message"; +import { Button } from "@/components/ui/button"; +import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client"; +import { createHostBackgroundWorkModel } from "./host"; +import { backgroundWorkText } from "./presentation"; +export { createHostBackgroundWorkModel } from "./host"; + +function kindKey(kind: string): "shell" | "monitor" | "other" { + if (kind === "shell" || kind === "monitor") return kind; + return "other"; +} + +export function BackgroundWorkTrack({ + model, + count, +}: { + model: ReturnType; + count: number; +}) { + const { t } = useTranslation(); + const { items, status, stops } = useSyncExternalStore( + model.subscribe, + model.getSnapshot, + model.getSnapshot, + ); + const missing = Object.values(stops ?? {}).filter( + (entry) => !items?.some((item) => item.stopToken === entry.item.stopToken), + ); + const rows = [...(items ?? []), ...missing.map((entry) => entry.item)]; + if (count === 0 && rows.length === 0) return null; + const visibleCount = items?.length || count; + return ( + + {status === "loading" || status === "stale" ? ( + + {t("message.backgroundWork.waiting")} + + ) : null} + {status === "error" ? ( + <> + + {t("message.backgroundWork.error")} + + + {t("message.backgroundWork.retry")} + + + ) : null} + {rows.map((item) => ( + entry.stopToken === item.stopToken))} + ready={status === "ready"} + stopStatus={stops?.[item.stopToken ?? ""]?.status} + /> + ))} + + ); +} + +function BackgroundWorkRow({ + item, + model, + live, + ready, + stopStatus, +}: { + item: PaseoAgentBackgroundWorkItem; + model: ReturnType; + live: boolean; + ready: boolean; + stopStatus?: "pending" | "error"; +}) { + const { t } = useTranslation(); + const kind = t(`message.backgroundWork.${kindKey(item.kind)}`); + const stop = useCallback(() => { + void model.stopItem(item); + }, [model, item]); + const dismiss = useCallback( + () => model.dismissStop(item.stopToken ?? ""), + [model, item.stopToken], + ); + let detail = t("message.backgroundWork.notRunning"); + if (stopStatus === "pending") detail = t("message.backgroundWork.stopping"); + const { title, command } = backgroundWorkText(item, kind); + const action = item.canStop && ready && live; + const label = + stopStatus === "pending" + ? t("message.backgroundWork.stopping") + : t("message.backgroundWork.stop"); + return ( + + + {title} + {command ? {command} : null} + + {kind} ·{" "} + {live ? ( + + ) : ( + detail + )} + + {stopStatus === "error" ? ( + {t("message.backgroundWork.stopError")} + ) : null} + + {action ? ( + + ) : null} + {!live && stopStatus === "error" ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 }, + title: { fontSize: theme.fontSize.base, color: theme.colors.foreground }, + facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, +})); diff --git a/packages/app/src/background-work/model.test.ts b/packages/app/src/background-work/model.test.ts new file mode 100644 index 0000000000..46fcaf8e95 --- /dev/null +++ b/packages/app/src/background-work/model.test.ts @@ -0,0 +1,233 @@ +import { expect, test } from "vitest"; +import { createBackgroundWorkModel } from "./model"; + +test("keeps a newer update when an earlier list resolves after it", async () => { + let resolve!: ( + value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + let update!: ( + items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + update = callbacks.update; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + expect(model.getSnapshot().items).toBeNull(); + snapshot(); + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + update([item]); + resolve([]); + await Promise.resolve(); + expect(model.getSnapshot().items).toEqual([item]); + unsubscribe(); +}); + +test("retains the last inventory while reconnecting and replaces it after resync", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + const reads: Array<(value: (typeof item)[]) => void> = []; + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: () => new Promise((resolve) => reads.push(resolve)), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + reads[0]([item]); + await Promise.resolve(); + snapshot(); + expect(model.getSnapshot().items).toEqual([item]); + reads[1]([]); + await Promise.resolve(); + expect(model.getSnapshot().items).toEqual([]); + unsubscribe(); +}); + +test("ignores a list response from a connection that was replaced", async () => { + let snapshot!: () => void; + let disconnected!: () => void; + let resolve!: ( + items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + disconnected = callbacks.disconnected; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + disconnected(); + resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]); + await Promise.resolve(); + expect(model.getSnapshot().items).toBeNull(); + unsubscribe(); +}); + +test("retains stale items on failure and retries the inventory", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + let snapshot!: () => void; + let resolve!: (items: (typeof item)[]) => void; + let reject!: (error: Error) => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done, fail) => { + resolve = done; + reject = fail; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + resolve([item]); + await Promise.resolve(); + snapshot(); + expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] }); + reject(new Error("offline")); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "error", items: [item] }); + model.retry(); + expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] }); + resolve([]); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("reports an initial read failure without claiming an empty inventory", async () => { + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: async () => { + throw new Error("offline"); + }, + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "error", items: null }); + unsubscribe(); +}); + +test("recreates a failed observation on retry and resumes live updates", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + let observation!: Parameters[0]["observe"]>[0]; + let starts = 0; + let releases = 0; + const model = createBackgroundWorkModel({ + list: async () => [item], + observe: (callbacks) => { + observation = callbacks; + starts++; + return () => { + releases++; + }; + }, + }); + const unsubscribe = model.subscribe(() => {}); + observation.failed(); + expect(model.getSnapshot()).toEqual({ status: "error", items: null }); + model.retry(); + expect(starts).toBe(2); + expect(releases).toBe(1); + observation.snapshot(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observation.update([]); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => { + const item = { + id: "1", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01", + canStop: true, + stopToken: "generation-1", + }; + let update!: (items: (typeof item)[]) => void; + let reject!: (error: Error) => void; + const model = createBackgroundWorkModel({ + list: async () => [item], + stop: () => + new Promise((_, fail) => { + reject = fail; + }), + observe: (callbacks) => { + update = callbacks.update; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + update([item]); + const pending = model.stopItem(item); + update([]); + expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item }); + reject(new Error("connection lost")); + await pending; + expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item }); + model.dismissStop("generation-1"); + expect(model.getSnapshot().stops).toEqual({}); + unsubscribe(); +}); + +test("ignores a stop response from a disconnected generation", async () => { + const first = { + id: "1", + kind: "shell", + description: "old", + startedAt: "2026-01-01", + canStop: true, + stopToken: "first", + }; + const replacement = { ...first, description: "new", stopToken: "second" }; + let update!: (items: (typeof first)[]) => void; + let disconnect!: () => void; + let resolve!: () => void; + const model = createBackgroundWorkModel({ + list: async () => [], + stop: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + update = callbacks.update; + disconnect = callbacks.disconnected; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + update([first]); + const pending = model.stopItem(first); + disconnect(); + update([replacement]); + resolve(); + await pending; + expect(model.getSnapshot().items).toEqual([replacement]); + expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first }); + unsubscribe(); +}); diff --git a/packages/app/src/background-work/model.ts b/packages/app/src/background-work/model.ts new file mode 100644 index 0000000000..311abc92e7 --- /dev/null +++ b/packages/app/src/background-work/model.ts @@ -0,0 +1,121 @@ +import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client"; + +interface Port { + list(): Promise; + stop?(item: AgentBackgroundWorkItem): Promise; + observe(callbacks: { + snapshot(): void; + disconnected(): void; + failed(): void; + update(items: AgentBackgroundWorkItem[]): void; + }): () => void; +} + +interface BackgroundWorkState { + status: "loading" | "stale" | "ready" | "error"; + items: AgentBackgroundWorkItem[] | null; + stops?: Record; +} + +export function createBackgroundWorkModel(port: Port) { + let state: BackgroundWorkState = { status: "loading", items: null }; + let sequence = 0; + let stopGeneration = 0; + let stop: (() => void) | undefined; + let observationFailed = false; + const listeners = new Set<() => void>(); + const publish = (next: BackgroundWorkState) => { + state = + next.stops === undefined && state.stops === undefined + ? next + : { ...next, stops: next.stops ?? state.stops }; + for (const listener of listeners) listener(); + }; + const refresh = async () => { + const request = ++sequence; + publish({ status: state.items === null ? "loading" : "stale", items: state.items }); + try { + const items = await port.list(); + if (request === sequence) publish({ status: "ready", items }); + } catch { + if (request === sequence) publish({ status: "error", items: state.items }); + } + }; + const start = () => { + observationFailed = false; + stop = port.observe({ + snapshot: () => { + void refresh(); + }, + disconnected: () => { + sequence++; + stopGeneration++; + const stops = + state.stops && + Object.fromEntries( + Object.entries(state.stops).map(([id, entry]) => [ + id, + { ...entry, status: "error" as const }, + ]), + ); + publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops }); + }, + failed: () => { + observationFailed = true; + sequence++; + publish({ status: "error", items: state.items }); + }, + update: (items) => { + sequence++; + publish({ status: "ready", items }); + }, + }); + }; + return { + getSnapshot: () => state, + dismissStop: (token: string) => { + const stops = { ...state.stops }; + delete stops[token]; + publish({ ...state, stops }); + }, + stopItem: async (item: AgentBackgroundWorkItem) => { + const token = item.stopToken; + if (!port.stop || !token || state.stops?.[token]?.status === "pending") return; + if (!state.items?.some((entry) => entry.stopToken === token)) return; + const generation = stopGeneration; + publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } }); + try { + await port.stop(item); + if (generation !== stopGeneration) return; + const stops = { ...state.stops }; + delete stops[token]; + publish({ ...state, stops }); + } catch { + if (generation === stopGeneration) + publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } }); + } + }, + retry: () => { + if (observationFailed) { + stop?.(); + stop = undefined; + publish({ status: state.items === null ? "loading" : "stale", items: state.items }); + start(); + } else { + void refresh(); + } + }, + subscribe(listener: () => void) { + listeners.add(listener); + if (!stop) start(); + return () => { + listeners.delete(listener); + if (!listeners.size) { + sequence++; + stop?.(); + stop = undefined; + } + }; + }, + }; +} diff --git a/packages/app/src/background-work/presentation.test.ts b/packages/app/src/background-work/presentation.test.ts new file mode 100644 index 0000000000..738dde76ca --- /dev/null +++ b/packages/app/src/background-work/presentation.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { backgroundWorkText } from "./presentation"; + +test("shows descriptions and commands once, with useful fallbacks", () => { + expect( + backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"), + ).toEqual({ title: "Watch build", command: "tail -f build.log" }); + expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({ + title: "sleep 10", + command: null, + }); + expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({ + title: "sleep 10", + command: null, + }); + expect(backgroundWorkText({ description: null }, "Shell")).toEqual({ + title: "Shell", + command: null, + }); +}); diff --git a/packages/app/src/background-work/presentation.ts b/packages/app/src/background-work/presentation.ts new file mode 100644 index 0000000000..4913289501 --- /dev/null +++ b/packages/app/src/background-work/presentation.ts @@ -0,0 +1,7 @@ +export function backgroundWorkText( + item: { description: string | null; command?: string }, + kind: string, +): { title: string; command: string | null } { + const title = item.description ?? item.command ?? kind; + return { title, command: item.command && item.command !== title ? item.command : null }; +} diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 71ed312739..74a0b7f434 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { MENU_ITEM_HEIGHT, @@ -16,6 +17,7 @@ import { StatusRing } from "@/components/status-ring"; import { bySizing } from "@/components/ui/control-geometry"; import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry"; import { isWeb } from "@/constants/platform"; +import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -35,10 +37,23 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles"; * move the transcript. */ export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement { + const scrollGesture = usePanelAwareHorizontalScrollGesture(); return ( - - {children} + + + + + {children} + + + ); @@ -371,9 +386,14 @@ const styles = StyleSheet.create((theme) => { md: COMPOSER_PILL_CLEARANCE.wide, }, }, - track: { + trackViewport: { width: "100%", maxWidth: theme.contentMaxWidth, + minWidth: 0, + flexGrow: 0, + flexShrink: 1, + }, + track: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 5ea1eadc4d..910caf1d64 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: { serverLabel, title: agent.title ?? null, status: agent.status, + backgroundWork: agent.backgroundWork, turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index fda62bd508..b731187128 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -379,6 +379,24 @@ export const ar: TranslationResources = { answerPlaceholder: "اكتب إجابتك...", otherPlaceholder: "آخر...", }, + backgroundWork: { + pillOne: "مهمة واحدة في الخلفية", + pillMany: "{{count}} مهمة في الخلفية", + notRunning: "لم يعد قيد التشغيل", + dismiss: "إغلاق", + stop: "إيقاف", + stopping: "جارٍ الإيقاف…", + stopError: "تعذر تأكيد الإيقاف.", + label: "في الخلفية", + title: "عمل الخلفية", + waiting: "بانتظار تفاصيل عمل الخلفية", + error: "تعذر تحميل عمل الخلفية", + retry: "إعادة المحاولة", + running: "عمل الخلفية قيد التشغيل", + shell: "الصدفة", + monitor: "المراقبة", + other: "عمل آخر", + }, todo: { title: "المهام", empty: "لا توجد مهام حتى الآن.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 79baa3a48c..bd148ca12b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -376,6 +376,24 @@ export const en = { answerPlaceholder: "Type your answer...", otherPlaceholder: "Other...", }, + backgroundWork: { + pillOne: "1 background task", + pillMany: "{{count}} background tasks", + notRunning: "No longer running", + dismiss: "Dismiss", + stop: "Stop", + stopping: "Stopping…", + stopError: "Could not confirm stop.", + label: "Background", + title: "Background work", + waiting: "Waiting for background work details", + error: "Could not load background work", + retry: "Retry", + running: "Background work running", + shell: "Shell", + monitor: "Monitor", + other: "Other work", + }, todo: { title: "Tasks", empty: "No tasks yet.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2841ec345f..e662f7624e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -384,6 +384,24 @@ export const es: TranslationResources = { answerPlaceholder: "Escribe tu respuesta...", otherPlaceholder: "Otro...", }, + backgroundWork: { + pillOne: "1 tarea en segundo plano", + pillMany: "{{count}} tareas en segundo plano", + notRunning: "Ya no está en ejecución", + dismiss: "Cerrar", + stop: "Detener", + stopping: "Deteniendo…", + stopError: "No se pudo confirmar la detención.", + label: "Segundo plano", + title: "Trabajo en segundo plano", + waiting: "Esperando los detalles", + error: "No se pudo cargar el trabajo en segundo plano", + retry: "Reintentar", + running: "Trabajo en segundo plano en curso", + shell: "Consola", + monitor: "Monitor", + other: "Otro trabajo", + }, todo: { title: "Tareas", empty: "Aún no hay tareas.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9324fa84c8..3eee928d57 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -384,6 +384,24 @@ export const fr: TranslationResources = { answerPlaceholder: "Saisissez votre réponse…", otherPlaceholder: "Autre…", }, + backgroundWork: { + pillOne: "1 tâche en arrière-plan", + pillMany: "{{count}} tâches en arrière-plan", + notRunning: "N’est plus en cours", + dismiss: "Fermer", + stop: "Arrêter", + stopping: "Arrêt…", + stopError: "Impossible de confirmer l’arrêt.", + label: "Arrière-plan", + title: "Travail en arrière-plan", + waiting: "En attente des détails", + error: "Impossible de charger le travail en arrière-plan", + retry: "Réessayer", + running: "Travail en arrière-plan en cours", + shell: "Shell", + monitor: "Surveillance", + other: "Autre travail", + }, todo: { title: "Tâches", empty: "Aucune tâche pour l’instant.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 58c2ec59df..fbf3fd7ab8 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -384,6 +384,24 @@ export const ja: TranslationResources = { answerPlaceholder: "回答を入力...", otherPlaceholder: "その他...", }, + backgroundWork: { + pillOne: "バックグラウンドタスク1件", + pillMany: "バックグラウンドタスク{{count}}件", + notRunning: "実行は終了しました", + dismiss: "閉じる", + stop: "停止", + stopping: "停止中…", + stopError: "停止を確認できませんでした。", + label: "バックグラウンド", + title: "バックグラウンド作業", + waiting: "作業の詳細を待っています", + error: "バックグラウンド作業を読み込めませんでした", + retry: "再試行", + running: "バックグラウンド作業を実行中", + shell: "シェル", + monitor: "監視", + other: "その他の作業", + }, todo: { title: "タスク", empty: "タスクがまだありません。", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 12e2909ddc..94272f7143 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -380,6 +380,24 @@ export const ko: TranslationResources = { answerPlaceholder: "답변을 입력하세요...", otherPlaceholder: "기타...", }, + backgroundWork: { + pillOne: "백그라운드 작업 1개", + pillMany: "백그라운드 작업 {{count}}개", + notRunning: "더 이상 실행 중이 아님", + dismiss: "닫기", + stop: "중지", + stopping: "중지 중…", + stopError: "중지를 확인하지 못했습니다.", + label: "백그라운드", + title: "백그라운드 작업", + waiting: "작업 세부 정보를 기다리는 중", + error: "백그라운드 작업을 불러오지 못했습니다", + retry: "다시 시도", + running: "백그라운드 작업 실행 중", + shell: "셸", + monitor: "모니터", + other: "기타 작업", + }, todo: { title: "작업", empty: "아직 작업이 없습니다.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 88f400907d..e8d3b166b8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -383,6 +383,24 @@ export const ptBR: TranslationResources = { answerPlaceholder: "Digite sua resposta...", otherPlaceholder: "Outro...", }, + backgroundWork: { + pillOne: "1 tarefa em segundo plano", + pillMany: "{{count}} tarefas em segundo plano", + notRunning: "Não está mais em execução", + dismiss: "Dispensar", + stop: "Parar", + stopping: "Parando…", + stopError: "Não foi possível confirmar a parada.", + label: "Segundo plano", + title: "Trabalho em segundo plano", + waiting: "Aguardando os detalhes", + error: "Não foi possível carregar o trabalho em segundo plano", + retry: "Tentar novamente", + running: "Trabalho em segundo plano em andamento", + shell: "Shell", + monitor: "Monitor", + other: "Outro trabalho", + }, todo: { title: "Tarefas", empty: "Nenhuma tarefa ainda.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index abab6a8395..f9eda82e87 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -383,6 +383,24 @@ export const ru: TranslationResources = { answerPlaceholder: "Введите ответ...", otherPlaceholder: "Другой ответ...", }, + backgroundWork: { + pillOne: "1 фоновая задача", + pillMany: "Фоновые задачи: {{count}}", + notRunning: "Больше не выполняется", + dismiss: "Закрыть", + stop: "Остановить", + stopping: "Остановка…", + stopError: "Не удалось подтвердить остановку.", + label: "В фоне", + title: "Фоновые задачи", + waiting: "Ожидание сведений о задачах", + error: "Не удалось загрузить фоновые задачи", + retry: "Повторить", + running: "Фоновые задачи выполняются", + shell: "Оболочка", + monitor: "Монитор", + other: "Другая задача", + }, todo: { title: "Задачи", empty: "Задач пока нет.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3a1c9bf29b..16b5e2cfa9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -379,6 +379,24 @@ export const zhCN: TranslationResources = { answerPlaceholder: "输入你的回答...", otherPlaceholder: "其他...", }, + backgroundWork: { + pillOne: "1 个后台任务", + pillMany: "{{count}} 个后台任务", + notRunning: "已不再运行", + dismiss: "关闭", + stop: "停止", + stopping: "正在停止…", + stopError: "无法确认已停止。", + label: "后台", + title: "后台任务", + waiting: "正在等待后台任务详情", + error: "无法加载后台任务", + retry: "重试", + running: "后台任务运行中", + shell: "Shell", + monitor: "监控", + other: "其他任务", + }, todo: { title: "任务", empty: "还没有任务。", diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index c95c69aba4..c5261b298f 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -11,6 +11,18 @@ import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model import { useMobilePanelsRuntime } from "./provider"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; +/** A horizontal surface takes the current touch before either panel opens. */ +export function usePanelAwareHorizontalScrollGesture() { + const { leftOpenGestureRef, rightOpenGestureRef } = useMobilePanelsRuntime(); + return useMemo( + () => + Gesture.Native() + .blocksExternalGesture(leftOpenGestureRef, rightOpenGestureRef) + .disallowInterruption(true), + [leftOpenGestureRef, rightOpenGestureRef], + ); +} + function isCurrentSelection(startedRevision: number): boolean { return usePanelStore.getState().mobilePanel.revision === startedRevision; } diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx index ac57778111..eb5aba735f 100644 --- a/packages/app/src/panels/agent-panel-descriptor.test.tsx +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -105,6 +105,29 @@ describe("shared agent descriptor", () => { expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running"); }); + it("shows background work as running without making the turn active", () => { + expect(describeAgent({ backgroundWork: { count: 1 } })).toMatchObject({ + statusBucket: "running", + subtitle: "Background work running", + }); + expect( + describeAgent({ + backgroundWork: { count: 1 }, + attentionReason: "error", + requiresAttention: true, + }).statusBucket, + ).toBe("failed"); + expect(describeAgent({ backgroundWork: { count: 1 }, status: "error" }).statusBucket).toBe( + "running", + ); + }); + + it("localizes the background work detail", async () => { + await i18n.changeLanguage("zh-CN"); + expect(describeAgent({ backgroundWork: { count: 1 } }).subtitle).toBe("后台任务运行中"); + await i18n.changeLanguage("en"); + }); + it("surfaces pending permissions even while the turn is running", () => { expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe( "needs_input", diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index ee1926565f..c4abe7a3fd 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,5 +1,6 @@ import type { AgentAddress } from "@getpaseo/protocol/addresses"; -import { memo, useCallback, type ReactElement } from "react"; +import { memo, useCallback, useMemo, type ReactElement } from "react"; +import { BackgroundWorkTrack, createHostBackgroundWorkModel } from "@/background-work"; import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; @@ -51,6 +52,15 @@ export const AgentTracks = memo(function AgentTracks({ rows: subagentRows, }); const { tabId } = usePaneContext(); + const backgroundWorkCount = useSessionStore((state) => { + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return agent?.backgroundWork?.count ?? 0; + }); + const backgroundModel = useMemo( + () => createHostBackgroundWorkModel(serverId, agentId), + [serverId, agentId], + ); const isCompact = useIsCompactFormFactor(); const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); @@ -111,6 +121,7 @@ export const AgentTracks = memo(function AgentTracks({ return ( + {parentAgentId ? : null} { expect(descriptors.get("running")).toBe(running); }); }); + +it("excludes live background work and rechecks it before each archive", async () => { + const first = deferred(); + const active = { ...paseo("active"), backgroundWork: { count: 1 } }; + const current = new Map([ + ["first", managed("first")], + ["later", managed("later")], + ["active", managed("active", "idle", { backgroundWork: { count: 1 } })], + ]); + const calls: string[] = []; + const archive = createArchiveFinishedSubagents([paseo("first"), paseo("later"), active], { + parentAgentId: "parent", + getManagedSubagent: ({ agentId }) => current.get(agentId), + archiveManagedSubagent: ({ agentId }) => { + calls.push(agentId); + return first.promise; + }, + dismissProviderSubagents: () => undefined, + }); + expect(archive.getState().eligibleCount).toBe(2); + const operation = archive.archiveFinished(); + current.set("later", managed("later", "idle", { backgroundWork: { count: 1 } })); + first.resolve(); + expect(await operation).toMatchObject({ + archivedPaseoIds: ["first"], + skippedPaseoIds: ["later"], + }); + expect(calls).toEqual(["first"]); +}); diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts index c79c3af6f9..1ad7a1bacd 100644 --- a/packages/app/src/subagents/archive-finished.ts +++ b/packages/app/src/subagents/archive-finished.ts @@ -19,7 +19,10 @@ export interface ArchiveFinishedOutcome { failures: Array<{ id: string; error: unknown }>; } -export type ManagedSubagentSnapshot = Pick; +export type ManagedSubagentSnapshot = Pick< + Agent, + "id" | "status" | "parentAgentId" | "archivedAt" | "backgroundWork" +>; export interface ArchiveFinishedSubagentsDeps { parentAgentId: string; @@ -36,7 +39,10 @@ export interface ArchiveFinishedSubagents { } export function isFinishedSubagent(row: SubagentRow): boolean { - if (row.kind === "paseo") return row.status === "idle" || row.status === "error"; + if (row.kind === "paseo") + return ( + (row.status === "idle" || row.status === "error") && (row.backgroundWork?.count ?? 0) === 0 + ); return row.status === "completed" || row.status === "failed" || row.status === "canceled"; } @@ -48,6 +54,7 @@ function canArchiveManagedSubagent( agent && !agent.archivedAt && agent.parentAgentId === parentAgentId && + (agent.backgroundWork?.count ?? 0) === 0 && (agent.status === "idle" || agent.status === "error"), ); } diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts index 9314961b16..78a9463fea 100644 --- a/packages/app/src/subagents/archive-subagent.test.ts +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -203,3 +203,19 @@ describe("requestArchiveSubagent", () => { expect(env.recordedErrors).toEqual([error]); }); }); + +it("warns before archiving an idle subagent with live background work", async () => { + const env = createFakeEnv({ + initialSubagents: [ + { + id: "child", + snapshot: { title: "Review branch", status: "idle", backgroundWork: { count: 1 } }, + }, + ], + }); + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child" }, env.deps); + expect(env.recordedConfirmInputs[0]).toMatchObject({ + title: "Archive running subagent?", + message: expect.stringContaining("will stop"), + }); +}); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts index 653dcb1cb2..aead4a41b7 100644 --- a/packages/app/src/subagents/archive-subagent.ts +++ b/packages/app/src/subagents/archive-subagent.ts @@ -4,6 +4,7 @@ import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; export interface ResolveArchiveSubagentDialogInput { title: Agent["title"] | null | undefined; status: Agent["status"] | null | undefined; + backgroundWork?: Agent["backgroundWork"]; } function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { @@ -24,7 +25,7 @@ export function resolveArchiveSubagentDialog( input: ResolveArchiveSubagentDialogInput, ): ConfirmDialogInput { const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; - const isRunning = input.status === "running"; + const isRunning = input.status === "running" || (input.backgroundWork?.count ?? 0) > 0; return { title: isRunning ? "Archive running subagent?" : "Archive subagent?", @@ -58,6 +59,7 @@ export async function requestArchiveSubagent( resolveArchiveSubagentDialog({ title: subagent?.title, status: subagent?.status, + backgroundWork: subagent?.backgroundWork, }), ); if (!confirmed) { diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 9fd84a19e7..a9d8ce4152 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -354,6 +354,7 @@ describe("selectSubagentsForParent", () => { provider: "claude", title: "Review child", status: "running", + backgroundWork: { count: 1 }, requiresAttention: true, createdAt, model: "should-not-leak", @@ -380,6 +381,7 @@ describe("selectSubagentsForParent", () => { description: null, subtitle: null, status: "running", + backgroundWork: { count: 1 }, turn: { phase: "idle", cancellationRequestId: null }, requiresAttention: true, createdAt, @@ -387,6 +389,7 @@ describe("selectSubagentsForParent", () => { ]); expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ "address", + "backgroundWork", "createdAt", "description", "id", diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 58a883f1c3..33255e2e4e 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -18,6 +18,7 @@ export interface PaseoSubagentRow { description: null; subtitle: null; status: Agent["status"]; + backgroundWork?: Agent["backgroundWork"]; turn: Agent["turn"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; @@ -65,6 +66,7 @@ function toSubagentRow(agent: Agent): SubagentRow { description: null, subtitle: null, status: agent.status, + backgroundWork: agent.backgroundWork, turn: agent.turn, requiresAttention: agent.requiresAttention, createdAt: agent.createdAt, diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 0397795541..7f1ae379df 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -26,6 +26,7 @@ function row( ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } : { phase: "idle", cancellationRequestId: null }), requiresAttention: overrides.requiresAttention ?? false, + backgroundWork: overrides.backgroundWork, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), }; } @@ -193,6 +194,22 @@ describe("buildSubagentRowPresentationData", () => { ); }); + it("shows a child with background work as running after its foreground turn ends", () => { + expect( + buildSubagentRowPresentationData( + row({ id: "a", status: "idle", backgroundWork: { count: 1 } }), + ).statusBucket, + ).toBe("running"); + }); + + it("keeps a failed child marked failed while it has background work", () => { + expect( + buildSubagentRowPresentationData( + row({ id: "a", status: "error", backgroundWork: { count: 1 } }), + ).statusBucket, + ).toBe("failed"); + }); + it("maps an idle row to the done status bucket so callers render the static provider icon", () => { expect(buildSubagentRowPresentationData(row({ id: "a", status: "idle" })).statusBucket).toBe( "done", diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 5a1d671339..0bb5ef8dd5 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -19,9 +19,12 @@ function presentationStatus(row: SubagentRow) { * A child's mark reports what it is doing, not whether anyone read it: the track is where a * failed child stays visible after the parent's own attention is cleared. */ -export function deriveSubagentStatusBucket(status: AgentLifecycleStatus): SidebarStateBucket { +export function deriveSubagentStatusBucket( + status: AgentLifecycleStatus, + backgroundWork?: { count: number } | null, +): SidebarStateBucket { if (status === "error") return "failed"; - if (status === "running") return "running"; + if (status === "running" || (backgroundWork?.count ?? 0) > 0) return "running"; return "done"; } @@ -49,7 +52,10 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP label: label ?? "", subtitle: subtitle ?? "", titleState: label ? "ready" : "loading", - statusBucket: deriveSubagentStatusBucket(status), + statusBucket: deriveSubagentStatusBucket( + status, + row.kind === "paseo" ? row.backgroundWork : undefined, + ), }; } diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 8ac1870a6f..9021c10786 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick< | "serverId" | "title" | "status" + | "backgroundWork" | "turn" | "lastActivityAt" | "cwd" diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index f380dc1118..fdda9f2fda 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -10,6 +10,7 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server", title: overrides.title ?? null, status: overrides.status ?? ("running" as AggregatedAgent["status"]), + backgroundWork: overrides.backgroundWork, turn: overrides.turn ?? ({ @@ -71,6 +72,19 @@ describe("deriveProjectDisplayName", () => { }); describe("groupAgents", () => { + it("keeps an idle agent with long-running background work in the active group", () => { + const agent = makeAgent({ + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + lastActivityAt: new Date("2020-01-01T00:00:00.000Z"), + backgroundWork: { count: 1 }, + }); + expect( + groupAgents([agent]) + .activeGroups.flatMap((group) => group.agents) + .map((item) => item.id), + ).toEqual([agent.id]); + }); it("uses turn liveness instead of protocol status for active grouping", () => { const old = new Date("2020-01-01T00:00:00.000Z"); const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old }); diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index eaca2d1511..698ab6b5a4 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -1,4 +1,5 @@ import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; /** * Derives the project key for grouping agents. @@ -262,12 +263,19 @@ const MAX_INACTIVE_PER_PROJECT = 5; * - All truly active agents (running/needs input/requires attention) are always shown * - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT */ +function activityStatus(agent: AggregatedAgent): AggregatedAgent["status"] { + if (agent.turn.phase === "open") return "running"; + return agent.status === "running" ? "idle" : agent.status; +} + function isAgentTrulyActive(agent: AggregatedAgent): boolean { - return ( - agent.turn.phase === "open" || - agent.requiresAttention || - (agent.pendingPermissionCount ?? 0) > 0 - ); + return isSidebarActiveAgent({ + status: activityStatus(agent), + backgroundWork: agent.backgroundWork, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + pendingPermissionCount: agent.pendingPermissionCount, + }); } function partitionAgentsByActivity( diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index df08cdddb7..11640d800b 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -70,6 +70,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, + backgroundWork: agent.backgroundWork, ...projectActiveTurn(agent), capabilities: agent.capabilities, currentModeId: agent.currentModeId, @@ -111,6 +112,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: id: snapshot.id, provider: snapshot.provider, status: snapshot.status, + backgroundWork: snapshot.backgroundWork, turn, createdAt, updatedAt, diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index d42ddafbba..d3cfe77678 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -40,6 +40,7 @@ export function buildWorkspaceAgentActivityIndex( const status = deriveSidebarStateBucket({ status: workspaceAgentStatus(agent), + backgroundWork: agent.backgroundWork, pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, diff --git a/packages/client/src/connection/legacy-workspaces.ts b/packages/client/src/connection/legacy-workspaces.ts index 2cf90423b5..ce83f145a1 100644 --- a/packages/client/src/connection/legacy-workspaces.ts +++ b/packages/client/src/connection/legacy-workspaces.ts @@ -111,6 +111,7 @@ export class LegacyWorkspaces { pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, + backgroundWork: agent.backgroundWork, }); const existing = workspaces.get(id); if ( diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 7b235040bf..017c302f7d 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1049,6 +1049,9 @@ type CorrelatedResponsePayloads = { }; type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; +export type AgentBackgroundWorkListPayload = + CorrelatedResponsePayload<"agent.background_work.list.response">; +export type AgentBackgroundWorkItem = AgentBackgroundWorkListPayload["items"][number]; type PluginCatalogResponseEntry = CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number]; @@ -3869,6 +3872,58 @@ export class DaemonClient { return payload; } + async listAgentBackgroundWork( + agentId: string, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + this.assertAgentBackgroundWorkSupported(); + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"agent.background_work.list.response">({ + requestId: options.requestId, + timeout: options.timeout, + message: { type: "agent.background_work.list.request", agentId }, + }); + if (payload.error) throw new Error(payload.error); + return payload.items; + } + + async stopAgentBackgroundWork( + agentId: string, + item: AgentBackgroundWorkItem, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + if (this.lastServerInfoMessage?.features?.agentBackgroundWorkStop !== true) + throw new Error("Update the host to stop background work."); + if (!item.canStop || !item.stopToken) throw new Error("Background work cannot be stopped."); + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"agent.background_work.stop.response">({ + requestId: options.requestId, + timeout: options.timeout ?? 30000, + message: { + type: "agent.background_work.stop.request", + agentId, + itemId: item.id, + stopToken: item.stopToken, + }, + }); + if (payload.error) throw new Error(payload.error); + } + + observeAgentBackgroundWork( + options: { signal?: AbortSignal } = {}, + ): OwnedSubscription> { + this.assertAgentBackgroundWorkSupported(); + return this.observeEvents(["agent.background_work.update"], options); + } + + private assertAgentBackgroundWorkSupported(): void { + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + if (this.lastServerInfoMessage?.features?.agentBackgroundWork !== true) { + throw new Error("Update the host to see an agent's background work."); + } + } + async fetchProviderSubagentTimeline( parentAgentId: string, subagentId: string, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 5fd4d92958..63f523dc11 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1938,3 +1938,256 @@ test("agent reload reopens through the daemon RPC then returns the observed snap await client.close(); } }); + +const BACKGROUND_WORK_FEATURES = { ownedSubscriptions: true, agentBackgroundWork: true }; +const SHELL_ITEM = { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", +}; + +function answerBackgroundWorkList(ws: FakeWebSocket, items: unknown[]): void { + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" }); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { requestId: request.requestId, agentId: "agent-a", items, error: null }, + }), + ); +} + +test("agent handles list background work", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + + const pending = client.agents.ref("agent-a").backgroundWork.list(); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" }); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { + requestId: request.requestId, + agentId: "agent-a", + items: [SHELL_ITEM], + error: null, + }, + }), + ); + + await expect(pending).resolves.toEqual([SHELL_ITEM]); + await client.close(); +}); + +test("agent handles follow only their own background work", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + + const unsubscribe = client.agents + .ref("agent-a") + .backgroundWork.subscribe((items) => received.push(items)); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["agent.background_work.update"], + }); + acknowledgeObservation(ws, "background-work-sdk"); + answerBackgroundWorkList(ws, []); + await vi.waitFor(() => expect(received).toEqual([[]])); + + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-b", items: [SHELL_ITEM] }, + }), + ); + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [SHELL_ITEM] }, + }), + ); + await vi.waitFor(() => expect(received).toEqual([[], [SHELL_ITEM]])); + + unsubscribe(); + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] }, + }), + ); + expect(received).toEqual([[], [SHELL_ITEM]]); + await client.close(); +}); + +test("background work subscriptions list the current work again after a reconnect", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items)); + acknowledgeObservation(ws, "background-work-first"); + answerBackgroundWorkList(ws, [SHELL_ITEM]); + await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM]])); + + ws.readyState = 3; + ws.onclose?.({ code: 1006, reason: "Connection lost" }); + const reconnecting = client.connect(); + const next = FakeWebSocket.instances[1]; + next.open(); + next.message( + sessionMessage({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_sdk_test", + hostname: null, + version: null, + features: BACKGROUND_WORK_FEATURES, + }, + }), + ); + await reconnecting; + await vi.waitFor(() => + expect(parseSentSessionMessage(next.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + }), + ); + acknowledgeObservation(next, "background-work-second"); + answerBackgroundWorkList(next, []); + + await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM], []])); + await client.close(); +}); + +test("background work subscriptions keep an update that overtakes their list", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items)); + acknowledgeObservation(ws, "background-work-sdk"); + await vi.waitFor(() => + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "agent.background_work.list.request", + }), + ); + const listRequest = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] }, + }), + ); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { + requestId: listRequest.requestId, + agentId: "agent-a", + items: [SHELL_ITEM], + error: null, + }, + }), + ); + + await vi.waitFor(() => expect(received).toEqual([[]])); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(received).toEqual([[]]); + await client.close(); +}); + +test("background work subscriptions report a failed setup to onError", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + const errors: unknown[] = []; + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), { + onError: (error) => errors.push(error), + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "session.events.set_subscription.response", + payload: { requestId: request.requestId }, + }), + ); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect((errors[0] as Error).message).toContain("subscription ID"); + expect(received).toEqual([]); + await client.close(); +}); + +test("background work subscriptions report a failed list to onError", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + const errors: unknown[] = []; + + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), { + onError: (error) => errors.push(error), + }); + acknowledgeObservation(ws, "background-work-sdk"); + await vi.waitFor(() => + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "agent.background_work.list.request", + }), + ); + const listRequest = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { requestId: listRequest.requestId, agentId: "agent-a", items: [], error: "boom" }, + }), + ); + + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect(errors[0]).toBeInstanceOf(Error); + expect((errors[0] as Error).message).toContain("boom"); + expect(received).toEqual([]); + await client.close(); +}); + +test("background work asks for a host update when the daemon lacks it", async () => { + const { client } = await connectClient({ ownedSubscriptions: true }); + const handle = client.agents.ref("agent-a"); + + await expect(handle.backgroundWork.list()).rejects.toThrow("Update the host"); + expect(() => handle.backgroundWork.subscribe(() => {})).toThrow("Update the host"); + await client.close(); +}); + +test("background stop sends the live item token and waits for the response", async () => { + const { client, ws } = await connectClient({ + ...BACKGROUND_WORK_FEATURES, + agentBackgroundWorkStop: true, + }); + const item = { ...SHELL_ITEM, canStop: true, stopToken: "generation-1" }; + const pending = client.agents.ref("agent-a").backgroundWork.stop(item); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "agent.background_work.stop.request", + agentId: "agent-a", + itemId: item.id, + stopToken: item.stopToken, + }); + ws.message( + sessionMessage({ + type: "agent.background_work.stop.response", + payload: { + requestId: request.requestId, + agentId: "agent-a", + itemId: item.id, + error: "rejected", + }, + }), + ); + await expect(pending).rejects.toThrow("rejected"); + await client.close(); +}); + +test("background stop rejects when the host does not advertise the operation", async () => { + const { client } = await connectClient(BACKGROUND_WORK_FEATURES); + await expect( + client.agents + .ref("agent-a") + .backgroundWork.stop({ ...SHELL_ITEM, canStop: true, stopToken: "generation" }), + ).rejects.toThrow("Update the host"); + await client.close(); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ead91e719c..79a2de50a1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -54,6 +54,7 @@ export type { } from "./terminals/index.js"; import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types"; import type { + AgentBackgroundWorkItem, FetchAgentsEntry, FetchAgentsOptions, FetchAgentsPageInfo, @@ -353,6 +354,36 @@ export interface PaseoAgentTimelineHandle { subscribe(handler: (event: PaseoAgentTimelineEvent) => void): PaseoAgentTimelineSubscription; } +export type PaseoAgentBackgroundWorkItem = AgentBackgroundWorkItem; + +export interface PaseoAgentBackgroundWorkHandle { + /** Stop one item and wait for provider confirmation. */ + stop( + item: PaseoAgentBackgroundWorkItem, + options?: { requestId?: string; timeout?: number }, + ): Promise; + /** Reads the agent's current background work once. */ + list(options?: { requestId?: string; timeout?: number }): Promise; + /** + * Calls `listener` with the agent's current list once the subscription is established, again + * after each reconnect, then with the full list each time it changes. Returns the unsubscribe + * function. + * + * When reading the current list fails, `options.onError` receives the error and `listener` is + * not called; without `onError` the error is logged. The read is not retried: the next + * reconnect reads the list again. + */ + subscribe( + listener: (items: PaseoAgentBackgroundWorkItem[]) => void, + options?: PaseoAgentBackgroundWorkSubscribeOptions, + ): () => void; +} + +export interface PaseoAgentBackgroundWorkSubscribeOptions { + /** Receives the error when reading the agent's current list fails. */ + onError?: (error: unknown) => void; +} + export interface PaseoAgentHandle { readonly id: string; readonly address: AgentAddress; @@ -376,6 +407,7 @@ export interface PaseoAgentHandle { readonly runtimeInfo: NonNullable | null; readonly archivedAt: NonNullable | null; readonly timeline: PaseoAgentTimelineHandle; + readonly backgroundWork: PaseoAgentBackgroundWorkHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; /** @@ -612,6 +644,7 @@ export function createPaseoApi( daemonClient, listenAgents, (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), + () => own(() => daemonClient.observeAgentBackgroundWork()), ); const createAgent = async ( options: PaseoAgentCreateOptions, @@ -893,6 +926,7 @@ function createAgentHandleFactory( daemonClient: DaemonClient, listen: (handler: PaseoAgentUpdateHandler) => () => void, subscribeTimeline: DaemonClient["subscribeAgentTimeline"], + observeBackgroundWork: () => ReturnType, ): AgentHandleFactory { return (agent) => { const id = typeof agent === "string" ? agent : agent.id; @@ -939,6 +973,48 @@ function createAgentHandleFactory( } }), }, + backgroundWork: { + stop: (item, options) => daemonClient.stopAgentBackgroundWork(id, item, options), + list: (options) => daemonClient.listAgentBackgroundWork(id, options), + subscribe: (listener, options) => { + const observation = observeBackgroundWork(); + // Bumped by every delivery so a list answered after a newer update is dropped. + let sequence = 0; + let released = false; + const reportError = (error: unknown) => { + if (released) return; + if (options?.onError) options.onError(error); + else console.error("Background work subscription failed", error); + }; + observation.subscribe({ + error: reportError, + snapshot: () => { + const listed = ++sequence; + void daemonClient + .listAgentBackgroundWork(id) + .then((items) => { + if (!released && listed === sequence) listener(items); + return undefined; + }) + .catch(reportError); + }, + update: (message) => { + if (message.type !== "agent.background_work.update") return; + if (message.payload.agentId !== id) return; + sequence++; + listener(message.payload.items); + }, + }); + return () => { + released = true; + void observation + .release() + .catch((error) => + console.error("Background work subscription cleanup failed", error), + ); + }; + }, + }, get workspaceId() { return current?.workspaceId ?? null; }, diff --git a/packages/protocol/src/agent-state-bucket.test.ts b/packages/protocol/src/agent-state-bucket.test.ts index 89d1ef93ab..ef57394577 100644 --- a/packages/protocol/src/agent-state-bucket.test.ts +++ b/packages/protocol/src/agent-state-bucket.test.ts @@ -6,6 +6,30 @@ import { } from "./agent-state-bucket.js"; describe("deriveAgentStateBucket", () => { + it("shows idle agents with background work as running while preserving attention precedence", () => { + const backgroundWork = { count: 1 }; + expect( + deriveAgentStateBucket({ + status: "idle", + backgroundWork, + requiresAttention: true, + attentionReason: "finished", + }), + ).toBe("running"); + expect( + deriveAgentStateBucket({ status: "idle", backgroundWork, attentionReason: "permission" }), + ).toBe("needs_input"); + expect(deriveAgentStateBucket({ status: "error", backgroundWork })).toBe("running"); + expect( + deriveAgentStateBucket({ + status: "error", + backgroundWork, + requiresAttention: true, + attentionReason: "error", + }), + ).toBe("failed"); + expect(deriveAgentStateBucket({ status: "idle", backgroundWork: { count: 0 } })).toBe("done"); + }); it("prioritizes pending permissions as needs_input", () => { expect( deriveAgentStateBucket({ diff --git a/packages/protocol/src/agent-state-bucket.ts b/packages/protocol/src/agent-state-bucket.ts index 746d70a4e2..2e696c5bbe 100644 --- a/packages/protocol/src/agent-state-bucket.ts +++ b/packages/protocol/src/agent-state-bucket.ts @@ -9,6 +9,7 @@ export interface AgentStateBucketInput { pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentAttentionReason; + backgroundWork?: { count: number } | null; } const WORKSPACE_STATE_BUCKET_PRIORITY = { @@ -32,7 +33,7 @@ export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceS if (hasUnreadError(input)) { return "failed"; } - if (input.status === "running") { + if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) { return "running"; } if (input.requiresAttention) { @@ -52,7 +53,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number { if (hasUnreadError(input)) { return 1; } - if (input.status === "running") { + if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) { return 2; } if (input.status === "initializing") { diff --git a/packages/protocol/src/messages.background-work.test.ts b/packages/protocol/src/messages.background-work.test.ts new file mode 100644 index 0000000000..d8d8de8ad5 --- /dev/null +++ b/packages/protocol/src/messages.background-work.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; + +import { + ServerInfoStatusPayloadSchema, + SessionEventSubscriptionSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; + +const ITEM = { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", +}; + +describe("agent background work protocol", () => { + test("accepts a list request and its response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.background_work.list.request", + agentId: "agent-1", + requestId: "request-1", + }), + ).toMatchObject({ agentId: "agent-1", requestId: "request-1" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.list.response", + payload: { requestId: "request-1", agentId: "agent-1", items: [ITEM], error: null }, + }), + ).toMatchObject({ payload: { items: [ITEM] } }); + }); + + test("accepts an update with or without a subscription id", () => { + for (const payload of [ + { agentId: "agent-1", items: [] }, + { subscriptionId: "events-1", agentId: "agent-1", items: [ITEM] }, + ]) { + expect( + SessionOutboundMessageSchema.parse({ type: "agent.background_work.update", payload }), + ).toMatchObject({ payload }); + } + }); + + test("keeps kind an open string so new kinds parse on old clients", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.update", + payload: { agentId: "agent-1", items: [{ ...ITEM, kind: "future-kind" }] }, + }), + ).toMatchObject({ payload: { items: [{ kind: "future-kind" }] } }); + }); + + test("preserves an optional command while accepting older items", () => { + const withCommand = { ...ITEM, description: "Watch build", command: "tail -f build.log" }; + const parsed = SessionOutboundMessageSchema.parse({ + type: "agent.background_work.update", + payload: { agentId: "agent-1", items: [ITEM, withCommand] }, + }); + expect(parsed).toMatchObject({ payload: { items: [ITEM, withCommand] } }); + }); + + test("is a subscribable event", () => { + expect(SessionEventSubscriptionSchema.parse("agent.background_work.update")).toBe( + "agent.background_work.update", + ); + }); + + test("keeps the feature optional for older server info payloads", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: {}, + }).features?.agentBackgroundWork, + ).toBeUndefined(); + }); +}); + +test("stop requests carry an inventory token and preserve legacy item parsing", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.background_work.stop.request", + agentId: "a", + itemId: "x", + stopToken: "token", + requestId: "r", + }), + ).toMatchObject({ stopToken: "token" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.stop.response", + payload: { requestId: "r", agentId: "a", itemId: "x", error: null }, + }), + ).toMatchObject({ payload: { error: null } }); + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.update", + payload: { agentId: "a", items: [ITEM] }, + }), + ).toMatchObject({ payload: { items: [ITEM] } }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c5fc881240..2899bf56cb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -873,6 +873,7 @@ export const AgentSnapshotPayloadSchema = z.object({ updatedAt: z.string(), lastUserMessageAt: z.string().nullable(), status: AgentStatusSchema, + backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(), activeTurn: AgentActiveTurnPayloadSchema.nullable().optional(), capabilities: AgentCapabilityFlagsSchema, currentModeId: z.string().nullable(), @@ -907,6 +908,7 @@ export const AgentListItemPayloadSchema = z.object({ thinkingOptionId: z.string().nullable().optional(), effectiveThinkingOptionId: z.string().nullable().optional(), status: AgentStatusSchema, + backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(), cwd: z.string(), createdAt: z.string(), updatedAt: z.string(), @@ -2001,6 +2003,20 @@ export const ProviderSubagentListRequestMessageSchema = z.object({ requestId: z.string(), }); +export const AgentBackgroundWorkListRequestMessageSchema = z.object({ + type: z.literal("agent.background_work.list.request"), + agentId: z.string(), + requestId: z.string(), +}); + +export const AgentBackgroundWorkStopRequestMessageSchema = z.object({ + type: z.literal("agent.background_work.stop.request"), + agentId: z.string(), + itemId: z.string(), + stopToken: z.string(), + requestId: z.string(), +}); + export const ProviderSubagentTimelineRequestMessageSchema = z.object({ type: z.literal("agent.provider_subagents.timeline.get.request"), parentAgentId: z.string(), @@ -3287,6 +3303,7 @@ export const SessionEventSubscriptionSchema = z.enum([ "script_status_update", "workspace_setup_progress", "agent.provider_subagents.update", + "agent.background_work.update", "terminal_attention_required", "status.server_info", "status.daemon_config_changed", @@ -3436,6 +3453,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentTimelineSearchRequestMessageSchema, AgentTimelineListPromptsRequestMessageSchema, ProviderSubagentListRequestMessageSchema, + AgentBackgroundWorkListRequestMessageSchema, + AgentBackgroundWorkStopRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, SetAgentTimelineSubscriptionRequestMessageSchema, AgentForkContextRequestMessageSchema, @@ -3863,6 +3882,10 @@ export const ServerInfoStatusPayloadSchema = z agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + agentBackgroundWork: z.boolean().optional(), + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + agentBackgroundWorkStop: z.boolean().optional(), // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. projectedSubagentTimeline: z.boolean().optional(), wholeTurnTimeline: z.boolean().optional(), @@ -4953,6 +4976,51 @@ export const ProviderSubagentListResponseMessageSchema = z.object({ }), }); +export const AgentBackgroundWorkItemSchema = z.object({ + id: z.string(), + // Open string: known kinds include "shell" and "monitor". + // Clients treat unknown values as "other". + kind: z.string(), + description: z.string().nullable(), + command: z.string().optional(), + startedAt: z.string(), + canStop: z.boolean().optional(), + stopToken: z.string().optional(), +}); + +export type AgentBackgroundWorkItemPayload = z.infer; + +export const AgentBackgroundWorkListResponseMessageSchema = z.object({ + type: z.literal("agent.background_work.list.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + items: z.array(AgentBackgroundWorkItemSchema), + error: z.string().nullable(), + }), +}); + +export const AgentBackgroundWorkStopResponseMessageSchema = z.object({ + type: z.literal("agent.background_work.stop.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + itemId: z.string(), + error: z.string().nullable(), + }), +}); + +// Event push delivered through session.events.set_subscription; it has no .request/.response pair. +// Each update replaces the agent's whole list. +export const AgentBackgroundWorkUpdateMessageSchema = z.object({ + type: z.literal("agent.background_work.update"), + payload: z.object({ + subscriptionId: z.string().optional(), + agentId: z.string(), + items: z.array(AgentBackgroundWorkItemSchema), + }), +}); + export const ProviderSubagentTimelineResponseMessageSchema = z.object({ type: z.literal("agent.provider_subagents.timeline.get.response"), payload: z.object({ @@ -7212,6 +7280,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProviderSubagentListResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, ProviderSubagentUpdateMessageSchema, + AgentBackgroundWorkListResponseMessageSchema, + AgentBackgroundWorkStopResponseMessageSchema, + AgentBackgroundWorkUpdateMessageSchema, SetAgentTimelineSubscriptionResponseMessageSchema, AgentAttentionRequiredMessageSchema, AgentForkContextResponseMessageSchema, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 372074c659..5b77522473 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -13075,3 +13075,172 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime", expect(h.commands).toEqual(["/maintenance"]); expect(h.prompts).toEqual([]); }); + +test("folds provider background work and clears it when the agent closes", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + class BackgroundWorkClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + } + const manager = new AgentManager({ + clients: { codex: new BackgroundWorkClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000201", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false }); + + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "bash-1", kind: "shell", description: "sleep 20" }], + }); + + await vi.waitFor(() => + expect(manager.listAgentBackgroundWork(snapshot.id)).toEqual([ + { id: "bash-1", kind: "shell", description: "sleep 20", startedAt: expect.any(String) }, + ]), + ); + expect(events).toContainEqual({ + type: "background_work", + agentId: snapshot.id, + items: [expect.objectContaining({ id: "bash-1" })], + }); + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); + expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toEqual({ count: 1 }); + expect( + events.some((event) => event.type === "agent_stream" && event.event.type === "background_work"), + ).toBe(false); + + await manager.closeAgent(snapshot.id); + + expect(events).toContainEqual({ type: "background_work", agentId: snapshot.id, items: [] }); + expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toBeUndefined(); +}); + +test("does not deliver an internal agent's background work to global subscribers", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-internal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + class BackgroundWorkClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + } + const manager = new AgentManager({ + clients: { codex: new BackgroundWorkClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000202", + }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { replayState: false }); + const agent = await manager.createAgent( + { provider: "codex", cwd: workdir, internal: true }, + undefined, + { workspaceId: undefined }, + ); + const agentEvents: AgentManagerEvent[] = []; + manager.subscribe((event) => agentEvents.push(event), { + agentId: agent.id, + replayState: false, + }); + + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "bash-1", kind: "shell", description: null }], + }); + // The per-agent subscriber proves the event was emitted before the global one is checked. + await vi.waitFor(() => + expect(agentEvents).toContainEqual( + expect.objectContaining({ type: "background_work", agentId: agent.id }), + ), + ); + + expect(events.some((event) => event.type === "background_work")).toBe(false); + await expect( + manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "bash-1", stopToken: "token" }), + ).rejects.toThrow("Unknown agent"); +}); + +test("stops only a currently owned public background item with its current token", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-background-stop-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const stopped: string[] = []; + let activeSession: TestAgentSession | null = null; + class StoppableSession extends TestAgentSession { + async stopBackgroundWork(id: string) { + stopped.push(id); + } + } + class BackgroundWorkClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new StoppableSession(config); + return activeSession; + } + } + const manager = new AgentManager({ + clients: { codex: new BackgroundWorkClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000204", + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [ + { id: "one", kind: "shell", description: null, canStop: true }, + { id: "two", kind: "other", description: null }, + ], + }); + await vi.waitFor(() => expect(manager.listAgentBackgroundWork(agent.id)).toHaveLength(2)); + const [one] = manager.listAgentBackgroundWork(agent.id); + await expect( + manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "one", stopToken: "stale-token" }), + ).rejects.toThrow(); + await expect( + manager.stopAgentBackgroundWork({ + agentId: agent.id, + itemId: "two", + stopToken: one.stopToken!, + }), + ).rejects.toThrow(); + await manager.stopAgentBackgroundWork({ + agentId: agent.id, + itemId: "one", + stopToken: one.stopToken!, + }); + expect(stopped).toEqual(["one"]); + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "one", kind: "shell", description: null }], + }); + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "one", kind: "shell", description: null, canStop: true }], + }); + await expect( + manager.stopAgentBackgroundWork({ + agentId: agent.id, + itemId: "one", + stopToken: one.stopToken!, + }), + ).rejects.toThrow(); + expect(stopped).toEqual(["one"]); + await manager.closeAgent(agent.id); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 81b5495377..fdecc4ad1c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -98,6 +98,7 @@ import { type ProviderSubagentDescriptor, type ProviderSubagentStoreEvent, } from "./provider-subagents/store.js"; +import { AgentBackgroundWorkStore, type AgentBackgroundWorkItem } from "./background-work/index.js"; import { withTimeout } from "../../utils/promise-timeout.js"; import { extractAttention } from "../persistence-hooks.js"; @@ -171,6 +172,12 @@ async function assertUsableWorkingDirectory(cwd: string): Promise { } } +interface StopAgentBackgroundWorkOptions { + agentId: string; + itemId: string; + stopToken: string; +} + async function resolveResumePurpose( record: StoredAgentRecord | null, requested: AgentResumePurpose | undefined, @@ -255,6 +262,7 @@ export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } | { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean } | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } + | { type: "background_work"; agentId: string; items: AgentBackgroundWorkItem[] } | { type: "timeline_replacement"; agentId: string; epoch: string } | { type: "agent_stream"; @@ -794,6 +802,7 @@ export class AgentManager { private readonly agents = new Map(); private readonly timelineStore: InMemoryAgentTimelineStore; private readonly providerSubagents = new ProviderSubagentStore(); + private readonly backgroundWork = new AgentBackgroundWorkStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); private readonly steerEventBarriers = new Map(); @@ -1354,6 +1363,38 @@ export class AgentManager { return this.providerSubagents.list(parentAgentId); } + listAgentBackgroundWork(agentId: string): AgentBackgroundWorkItem[] { + this.requirePublicAgent(agentId); + return this.backgroundWork.list(agentId); + } + + async stopAgentBackgroundWork({ + agentId, + itemId, + stopToken, + }: StopAgentBackgroundWorkOptions): Promise { + this.requirePublicAgent(agentId); + // Inventory events share the session queue; drain events already received before + // authorizing the token, including a reconnect's ownership revocation. + await this.sessionEventTails.get(agentId)?.catch(() => undefined); + const agent = this.requirePublicAgent(agentId); + if (!agent.session || !agent.session.stopBackgroundWork) + throw new Error("Background work cannot be stopped"); + const item = this.backgroundWork.list(agentId).find((entry) => entry.id === itemId); + if (!item?.canStop || item.stopToken !== stopToken) + throw new Error("Background work is no longer available to stop"); + await agent.session.stopBackgroundWork(itemId); + } + + getAgentBackgroundWorkSummary(agentId: string): { count: number } | undefined { + return this.backgroundWork.summary(agentId); + } + + private clearBackgroundWork(agentId: string): void { + const items = this.backgroundWork.clear(agentId); + if (items) this.dispatch({ type: "background_work", agentId, items }); + } + listProviderSubagentActivity(): ProviderSubagentDescriptor[] { const publicParentIds = new Set( Array.from(this.agents.values()) @@ -1740,6 +1781,7 @@ export class AgentManager { await this.closeReloadedSession(existing.session, agentId); await this.drainSessionEvents(agentId); this.cancelRunningProviderSubagents(agentId); + this.clearBackgroundWork(agentId); closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); await this.persistSnapshot(closedExisting); this.assertAcceptingAgentRegistrations(); @@ -1990,6 +2032,8 @@ export class AgentManager { await agent.session.close(); await this.drainSessionEvents(agentId); this.cancelRunningProviderSubagents(agentId); + // close() drops the session's subscribers before the provider could report an empty list. + this.clearBackgroundWork(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); let persistError: unknown; @@ -4182,6 +4226,7 @@ export class AgentManager { for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } + this.clearBackgroundWork(agentId); } private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { @@ -4291,6 +4336,14 @@ export class AgentManager { this.dispatch({ type: "provider_subagent", event: update }); return; } + if (event.type === "background_work") { + const items = + event.items === null + ? this.backgroundWork.clear(agent.id) + : this.backgroundWork.apply(agent.id, event.items); + if (items) this.dispatch({ type: "background_work", agentId: agent.id, items }); + return; + } const turnId = getAgentStreamEventTurnId(event); const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId); this.logger.trace( @@ -5510,6 +5563,13 @@ export class AgentManager { ) { continue; } + if ( + subscriber.agentId && + event.type === "background_work" && + subscriber.agentId !== event.agentId + ) { + continue; + } if ( subscriber.agentId && event.type === "agent_state" && @@ -5543,6 +5603,7 @@ export class AgentManager { if (event.type === "agent_state") return event.agent.internal === true; if (event.type === "agent_removed") return event.internal; if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true; + if (event.type === "background_work") return this.agents.get(event.agentId)?.internal === true; if (event.type !== "provider_subagent") return false; const parentAgentId = event.event.type === "upsert" diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index ec4186b022..e9c6289db5 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -4,6 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js"; import { buildStoredAgentPayload, toAgentPayload, + toAgentListItemPayload, toRecentProviderSessionDescriptorPayload, toStoredAgentRecord, type ManagedAgent, @@ -539,3 +540,11 @@ describe("toRecentProviderSessionDescriptorPayload", () => { }); }); }); + +it("omits unknown background work and preserves known summary in list items", () => { + const snapshot = toAgentPayload(createManagedAgent()); + expect(toAgentListItemPayload(snapshot)).not.toHaveProperty("backgroundWork"); + expect(toAgentListItemPayload({ ...snapshot, backgroundWork: { count: 2 } })).toMatchObject({ + backgroundWork: { count: 2 }, + }); +}); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3229a70e0f..b11021a99c 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -287,6 +287,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt thinkingOptionId: agent.thinkingOptionId, effectiveThinkingOptionId: agent.effectiveThinkingOptionId, status: agent.status, + ...(agent.backgroundWork ? { backgroundWork: agent.backgroundWork } : {}), cwd: agent.cwd, createdAt: agent.createdAt, updatedAt: agent.updatedAt, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 903bbe5c99..7604f1db76 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -468,6 +468,12 @@ export type AgentStreamEvent = type: "provider_subagent"; provider: AgentProvider; event: import("./provider-subagents/store.js").ProviderSubagentInputEvent; + } + | { + /** Full replacement of the agent's live background work (shells, monitors…). */ + type: "background_work"; + provider: AgentProvider; + items: import("./background-work/index.js").AgentBackgroundWorkInput[] | null; }; export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined { @@ -705,6 +711,8 @@ export interface AgentSession { * still uncertain. */ interrupt(): Promise; + /** Stop one currently owned background item and wait for provider confirmation. */ + stopBackgroundWork?(id: string): Promise; /** True only when releasing this runtime loses no background work or pending replies. * An optional additional veto: missing methods use daemon baseline eligibility; * implemented checks that reject or return false keep the runtime resident. */ diff --git a/packages/server/src/server/agent/background-work/index.test.ts b/packages/server/src/server/agent/background-work/index.test.ts new file mode 100644 index 0000000000..3a8f28dfea --- /dev/null +++ b/packages/server/src/server/agent/background-work/index.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "vitest"; + +import { AgentBackgroundWorkStore } from "./index.js"; + +const T0 = "2026-10-06T10:00:00.000Z"; +const T1 = "2026-10-06T10:05:00.000Z"; + +describe("AgentBackgroundWorkStore", () => { + test("stamps new items and keeps their start time across replacements", () => { + const store = new AgentBackgroundWorkStore(); + + expect( + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0), + ).toEqual([{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }]); + + expect( + store.apply( + "agent-a", + [ + { id: "bash-1", kind: "shell", description: "sleep 20" }, + { id: "task-1", kind: "other", description: null }, + ], + T1, + ), + ).toEqual([ + { id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }, + { id: "task-1", kind: "other", description: null, startedAt: T1 }, + ]); + }); + + test("returns null when the replacement changes nothing", () => { + const store = new AgentBackgroundWorkStore(); + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0); + + expect( + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T1), + ).toBeNull(); + expect(store.apply("agent-b", [], T1)).toEqual([]); + expect(store.apply("agent-b", [], T1)).toBeNull(); + }); + + test("distinguishes an unknown inventory from an observed empty inventory", () => { + const store = new AgentBackgroundWorkStore(); + expect(store.summary("agent-a")).toBeUndefined(); + store.apply("agent-a", []); + expect(store.summary("agent-a")).toEqual({ count: 0 }); + store.clear("agent-a"); + expect(store.summary("agent-a")).toBeUndefined(); + }); + + test("keeps the first occurrence of a duplicated id", () => { + const store = new AgentBackgroundWorkStore(); + + expect( + store.apply( + "agent-a", + [ + { id: "bash-1", kind: "shell", description: "first" }, + { id: "bash-1", kind: "shell", description: "second" }, + ], + T0, + ), + ).toEqual([{ id: "bash-1", kind: "shell", description: "first", startedAt: T0 }]); + }); + + test("scopes lists per agent and clears one agent", () => { + const store = new AgentBackgroundWorkStore(); + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0); + store.apply("agent-b", [{ id: "bash-2", kind: "shell", description: null }], T0); + + expect(store.clear("agent-a")).toEqual([]); + expect(store.clear("agent-a")).toBeNull(); + expect(store.list("agent-a")).toEqual([]); + expect(store.list("agent-b")).toHaveLength(1); + }); + + test("keeps only the wire fields of each input", () => { + const store = new AgentBackgroundWorkStore(); + const input = { id: "bash-1", kind: "shell", description: null, pid: 4242 }; + + expect(store.apply("agent-a", [input], T0)).toEqual([ + { id: "bash-1", kind: "shell", description: null, startedAt: T0 }, + ]); + }); + + test("list returns a copy", () => { + const store = new AgentBackgroundWorkStore(); + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0); + + store.list("agent-a").pop(); + + expect(store.list("agent-a")).toHaveLength(1); + }); +}); + +test("stop tokens follow a live item and change when an id is reused", () => { + const store = new AgentBackgroundWorkStore(); + const input = { id: "1", kind: "shell", description: null, canStop: true }; + const first = store.apply("agent", [input], T0)![0]; + expect(first.stopToken).toBeTruthy(); + expect(store.apply("agent", [input], T1)).toBeNull(); + store.apply("agent", []); + const second = store.apply("agent", [input], T0)![0]; + expect(second.stopToken).not.toBe(first.stopToken); +}); + +test("revokes a stop token while inventory ownership is being re-established", () => { + const store = new AgentBackgroundWorkStore(); + const item = { id: "1", kind: "shell", description: null, canStop: true }; + const first = store.apply("agent", [item])![0]; + const stale = store.apply("agent", [{ ...item, canStop: false }])![0]; + expect(stale.stopToken).toBeUndefined(); + expect(store.apply("agent", [item])![0].stopToken).not.toBe(first.stopToken); +}); + +test("publishes command changes without replacing a live item's identity", () => { + const store = new AgentBackgroundWorkStore(); + const item = { + id: "1", + kind: "monitor", + description: "Watch build", + command: "tail -f build.log", + canStop: true, + }; + const first = store.apply("agent", [item], T0)![0]; + expect(first.command).toBe("tail -f build.log"); + const changed = store.apply("agent", [{ ...item, command: "tail -f other.log" }], T1)![0]; + expect(changed).toMatchObject({ + command: "tail -f other.log", + startedAt: T0, + stopToken: first.stopToken, + }); +}); diff --git a/packages/server/src/server/agent/background-work/index.ts b/packages/server/src/server/agent/background-work/index.ts new file mode 100644 index 0000000000..3290f13503 --- /dev/null +++ b/packages/server/src/server/agent/background-work/index.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; + +export interface AgentBackgroundWorkInput { + id: string; + /** Provider-neutral kind; consumers treat unknown kinds as other work. */ + kind: string; + description: string | null; + command?: string; + canStop?: boolean; +} + +export interface AgentBackgroundWorkItem extends AgentBackgroundWorkInput { + startedAt: string; + stopToken?: string; +} + +/** + * Live background work per agent. Providers report the whole list on every change, so `apply` + * replaces it. `startedAt` is stamped the first time an id is seen and kept while it stays listed. + * Nothing is persisted; providers determine when their live inventory is no longer available. + */ +export class AgentBackgroundWorkStore { + private readonly items = new Map(); + + apply( + agentId: string, + inputs: readonly AgentBackgroundWorkInput[], + timestamp: string = new Date().toISOString(), + ): AgentBackgroundWorkItem[] | null { + const known = this.items.has(agentId); + const previous = this.items.get(agentId) ?? []; + const previousById = new Map(previous.map((item) => [item.id, item])); + const seen = new Set(); + const next: AgentBackgroundWorkItem[] = []; + for (const input of inputs) { + if (seen.has(input.id)) continue; + seen.add(input.id); + next.push({ + id: input.id, + kind: input.kind, + description: input.description, + ...(input.command ? { command: input.command } : {}), + ...(input.canStop + ? { canStop: true, stopToken: previousById.get(input.id)?.stopToken ?? randomUUID() } + : {}), + startedAt: previousById.get(input.id)?.startedAt ?? timestamp, + }); + } + if (known && sameItems(previous, next)) return null; + this.items.set(agentId, next); + return [...next]; + } + + list(agentId: string): AgentBackgroundWorkItem[] { + return [...(this.items.get(agentId) ?? [])]; + } + + summary(agentId: string): { count: number } | undefined { + const items = this.items.get(agentId); + return items ? { count: items.length } : undefined; + } + + clear(agentId: string): AgentBackgroundWorkItem[] | null { + if (!this.items.delete(agentId)) return null; + return []; + } +} + +function sameItems( + left: readonly AgentBackgroundWorkItem[], + right: readonly AgentBackgroundWorkItem[], +): boolean { + return ( + left.length === right.length && + left.every((item, index) => { + const other = right[index]; + return ( + item.id === other.id && + item.kind === other.kind && + item.description === other.description && + item.command === other.command && + item.canStop === other.canStop && + item.stopToken === other.stopToken && + item.startedAt === other.startedAt + ); + }) + ); +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 0f3c39868a..8616c5ea52 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response), describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), interrupt: () => inner.interrupt(), + stopBackgroundWork: inner.stopBackgroundWork?.bind(inner), close: () => inner.close(), canStopRuntime: inner.canStopRuntime?.bind(inner), listCommands: inner.listCommands?.bind(inner), diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 8aa15581ae..5c70f0a1fe 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -712,19 +712,36 @@ describe("background Claude subagents", () => { test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => { const safety = new ClaudeRuntimeSafety(); const observe = (message: unknown) => safety.observe(message as SDKMessage); - observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" }); + expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]); expect(safety.canStopRuntime()).toBe(true); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, + { task_id: "monitor", task_type: "local_bash", description: "watch build" }, + { task_id: "native-child", task_type: "local_agent", description: "research" }, + ], + }), + ).toEqual([ + { id: "quiet-shell", kind: "shell", description: "sleep 7200", canStop: true }, + { id: "monitor", kind: "shell", description: "watch build", canStop: true }, + ]); + expect(safety.canStopRuntime()).toBe(false); observe({ type: "system", - subtype: "background_tasks_changed", - tasks: [ - { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, - { task_id: "monitor", task_type: "local_bash", description: "watch build" }, - { task_id: "native-child", task_type: "local_agent", description: "research" }, - ], + subtype: "task_started", + task_id: "monitor", + tool_use_id: "tool-monitor", }); - expect(safety.canStopRuntime()).toBe(false); - observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect( + observe({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "tool-monitor", name: "Monitor" }] }, + })?.find((item) => item.id === "monitor")?.kind, + ).toBe("monitor"); + expect(observe({ type: "system", subtype: "background_tasks_changed", tasks: [] })).toEqual([]); expect(safety.canStopRuntime()).toBe(false); for (const task_id of ["quiet-shell", "monitor"]) observe({ type: "system", subtype: "task_notification", task_id }); @@ -736,6 +753,57 @@ test("Claude runtime safety protects silent shells, monitors and pending complet expect(safety.canStopRuntime()).toBe(false); observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); expect(safety.canStopRuntime()).toBe(true); + safety.retire(); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "late", task_type: "local_bash" }], + }), + ).toBeUndefined(); + safety.reset(); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "new", task_type: "local_bash" }], + }), + ).toEqual([{ id: "new", kind: "shell", description: null, canStop: true }]); +}); + +test("emits inventory only when relevant observations change it", () => { + const safety = new ClaudeRuntimeSafety(); + const observe = (message: unknown) => safety.observe(message as SDKMessage); + expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "m", task_type: "local_bash" }], + }), + ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "m", task_type: "local_bash" }], + }), + ).toBeUndefined(); + expect( + observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "tool" }), + ).toBeUndefined(); + expect( + observe({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "tool", name: "Monitor" }] }, + }), + ).toEqual([{ id: "m", kind: "monitor", description: null, canStop: true }]); + expect( + observe({ type: "assistant", message: { content: [{ type: "text", text: "streamed" }] } }), + ).toBeUndefined(); + expect( + observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "other" }), + ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); }); test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts index 74ff780aac..eaf6a68fbc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts @@ -143,6 +143,13 @@ function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelin .find((item) => item.status === "completed" && item.name.toLowerCase() === "bash"); } +function hasBackgroundShell(events: AgentStreamEvent[]): boolean { + return events.some( + (event) => + event.type === "background_work" && event.items?.some((item) => item.kind === "shell"), + ); +} + function getInternalQuery(session: AgentSession): unknown { return (session as AgentSession & { query?: unknown }).query ?? null; } @@ -419,6 +426,39 @@ describe("ClaudeAgentSession integration", () => { } }, 180_000); + test("lists a background shell as background work until it exits", async () => { + const handle = await createSession({ cwdPrefix: "claude-agent-background-work-" }); + try { + const liveEventsPromise = collectSubscribedUntil( + handle.session, + (event, events) => + event.type === "background_work" && + event.items?.length === 0 && + hasBackgroundShell(events), + 90_000, + ); + + await collectUntilTerminal( + streamSession( + handle.session, + [ + "Use the Bash tool with run_in_background.", + "Run exactly: sleep 8", + "Do not wait for the task result.", + "Reply immediately with exactly: SPAWNED", + ].join(" "), + ), + { timeoutMs: 90_000 }, + ); + + const liveEvents = await liveEventsPromise; + expect(hasBackgroundShell(liveEvents)).toBe(true); + expect(liveEvents.at(-1)).toMatchObject({ type: "background_work", items: [] }); + } finally { + await cleanupSession(handle); + } + }, 180_000); + test("surfaces permission requests and resumes after approval", async () => { const handle = await createSession({ cwdPrefix: "claude-agent-permission-", diff --git a/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts b/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts index 85bf450ab3..d70d068717 100644 --- a/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts @@ -118,6 +118,22 @@ const RUNNING_WORKFLOW_TURN_EVENTS = [ COMPLETED_TURN_EVENTS[2], ]; +const BACKGROUND_SHELL_TURN_EVENTS = [ + COMPLETED_TURN_EVENTS[0], + { + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "bash-1", task_type: "local_bash", description: "sleep 20" }, + { task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" }, + ], + uuid: "00000000-0000-4000-8000-000000000001", + session_id: "claude-runtime-exit-session", + }, + COMPLETED_TURN_EVENTS[1], + COMPLETED_TURN_EVENTS[2], +]; + const MISSING_RESUMED_CONVERSATION_RESULT = { type: "result", subtype: "error_during_execution", @@ -272,6 +288,155 @@ describe("Claude runtime exit", () => { } }); + test("reports background tasks except subagents as background work", async () => { + const queryFactory = vi.fn(() => createQueryMock(BACKGROUND_SHELL_TURN_EVENTS)); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub()); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.run("start a background shell"); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "background_work", + provider: "claude", + items: [{ id: "bash-1", kind: "shell", description: "sleep 20", canStop: true }], + }), + ); + } finally { + await session.close(); + } + }); + + test("empties background work when the process dies while idle", async () => { + let capturedOptions: Options | undefined; + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS); + }); + const child = createChildProcessStub(); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + await session.run("start a background shell"); + capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + child.emit("exit", 1, null); + + expect(events).toContainEqual( + expect.objectContaining({ type: "background_work", provider: "claude", items: null }), + ); + } finally { + await session.close(); + } + }); + + test("empties background work when a query restart retires the process", async () => { + let capturedOptions: Options | undefined; + const child = createChildProcessStub(); + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { + tail: new Promise(() => undefined), + onReturn: () => child.emit("exit", 0, null), + }); + }); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + await session.run("start a background shell"); + capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.setThinkingOption(null); + await session.listCommands(); + + expect(events).toContainEqual( + expect.objectContaining({ type: "background_work", provider: "claude", items: null }), + ); + } finally { + await session.close(); + } + }); + + test("ignores background work a retired query reports after its restart", async () => { + let capturedOptions: Options | undefined; + let deliverLateEvent: ((event: unknown) => void) | undefined; + let retiredQuery: Query | undefined; + const child = createChildProcessStub(); + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + if (retiredQuery) { + return createQueryMock([], { tail: new Promise(() => undefined) }); + } + retiredQuery = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { + tail: new Promise((resolve) => { + deliverLateEvent = resolve; + }), + onReturn: () => child.emit("exit", 0, null), + }); + return retiredQuery; + }); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + await session.run("start a background shell"); + capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.setThinkingOption(null); + await session.listCommands(); + expect(queryFactory).toHaveBeenCalledTimes(2); + + // The retired query still had this queued when the restart emptied the list. + deliverLateEvent?.(BACKGROUND_SHELL_TURN_EVENTS[1]); + // One next() per scripted event and one for the late event; the pump stops + // reading a query it no longer owns instead of routing that event. + await vi.waitFor(() => + expect(retiredQuery?.next).toHaveBeenCalledTimes(BACKGROUND_SHELL_TURN_EVENTS.length + 1), + ); + // Let a routed late event reach subscribers before checking the list. + await new Promise((resolve) => setImmediate(resolve)); + + const backgroundWork = events.filter((event) => event.type === "background_work"); + expect(backgroundWork.at(-1)).toEqual( + expect.objectContaining({ type: "background_work", provider: "claude", items: null }), + ); + } finally { + await session.close(); + } + }); + test("tree-kills the retired process when the resumed conversation is gone", async () => { let capturedOptions: Options | undefined; let deliverMissingConversation: ((event: unknown) => void) | undefined; @@ -348,4 +513,77 @@ describe("Claude runtime exit", () => { await session.close(); } }); + test("waits for a stopped task notification after the stop request is accepted", async () => { + let deliver!: (message: unknown) => void; + const tail = new Promise((resolve) => { + deliver = resolve; + }); + const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail }); + query.stopTask = async () => {}; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: () => query, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + try { + await session.run("start a background shell"); + let settled = false; + const pending = session.stopBackgroundWork!("bash-1").then(() => { + settled = true; + return undefined; + }); + await Promise.resolve(); + expect(settled).toBe(false); + deliver({ + type: "system", + subtype: "task_notification", + task_id: "bash-1", + status: "stopped", + output_file: "", + summary: "Stopped", + uuid: "00000000-0000-4000-8000-000000000002", + session_id: "claude-runtime-exit-session", + }); + await pending; + expect(settled).toBe(true); + await expect(session.stopBackgroundWork!("wf-1")).rejects.toThrow(); + } finally { + await session.close(); + } + }); + test.each(["completed", "failed"])( + "reports the task's %s outcome when it races a stop", + async (status) => { + let deliver!: (message: unknown) => void; + const tail = new Promise((resolve) => { + deliver = resolve; + }); + const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail }); + query.stopTask = async () => {}; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: () => query, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + try { + await session.run("start a background shell"); + const result = session.stopBackgroundWork!("bash-1"); + deliver({ + type: "system", + subtype: "task_notification", + task_id: "bash-1", + status, + output_file: "", + summary: "Finished", + uuid: "00000000-0000-4000-8000-000000000003", + session_id: "claude-runtime-exit-session", + }); + await expect(result).rejects.toThrow(status); + } finally { + await session.close(); + } + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index a5d15bdb24..a9f709ab1a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -2908,8 +2908,13 @@ describe("ClaudeAgentSession context window usage", () => { await session.startTurn("turn", { clientMessageId: "client-message-1" }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); - expect(events[1]).toMatchObject({ + const started = events.findIndex((event) => event.type === "turn_started"); + const userMessage = events.findIndex( + (event) => event.type === "timeline" && event.item.type === "user_message", + ); + expect(started).toBeGreaterThanOrEqual(0); + expect(userMessage).toBeGreaterThan(started); + expect(events[userMessage]).toMatchObject({ type: "timeline", item: { type: "user_message", clientMessageId: "client-message-1" }, }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9b560747fc..bc5ab24af4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2294,6 +2294,7 @@ class ClaudeAgentSession implements AgentSession { private readonly resolveBinary: () => Promise; private query: Query | null = null; private readonly runtimeSafety = new ClaudeRuntimeSafety(); + private readonly backgroundStops = new Map void>(); private processingSdkMessages = 0; private readonly harnessEnvironment: Record; private readonly usageSessionKey = randomUUID(); @@ -2669,6 +2670,33 @@ class ClaudeAgentSession implements AgentSession { }; } + async stopBackgroundWork(id: string): Promise { + const query = this.query; + if (!query || !this.runtimeSafety.canStop(id)) + throw new Error("Background work is no longer available"); + if (this.backgroundStops.has(id)) throw new Error("Stop is already pending"); + let timer: ReturnType | undefined; + let finish!: (outcome: string) => void; + const outcome = new Promise((resolve) => { + finish = resolve; + this.backgroundStops.set(id, resolve); + }); + try { + const result = await Promise.race([ + Promise.all([query.stopTask(id), outcome]).then(([, value]) => value), + outcome.then((value) => (value === "stopped" ? new Promise(() => {}) : value)), + new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), 15000); + }), + ]); + if (result !== "stopped") throw new Error(`Background work stop: ${result}`); + if (this.query !== query) throw new Error("Background work connection changed"); + } finally { + clearTimeout(timer); + if (this.backgroundStops.get(id) === finish) this.backgroundStops.delete(id); + } + } + async interrupt(): Promise { if (this.cancelCurrentTurn) { this.cancelCurrentTurn(); @@ -3478,6 +3506,7 @@ class ClaudeAgentSession implements AgentSession { const oldQuery = this.query; if (!oldQuery) return; const oldInput = this.input; + for (const finish of this.backgroundStops.values()) finish("connection lost"); // Null out query/input BEFORE awaiting the old iterator's return so the // old pump sees this.query !== activeQuery and skips failActiveTurns. this.query = null; @@ -3532,6 +3561,7 @@ class ClaudeAgentSession implements AgentSession { this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; this.runtimeSafety.reset(); + this.dispatchEvents([{ type: "background_work", provider: "claude", items: null }]); // A fresh Claude process has no turn of its own in flight. this.mainTurnInFlight = false; this.query = claudeQuery( @@ -4127,11 +4157,15 @@ class ClaudeAgentSession implements AgentSession { } private failRunningRuntimeTasks(): void { - this.dispatchEvents( - foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map( + for (const finish of this.backgroundStops.values()) finish("connection lost"); + this.runtimeSafety.retire(); + this.dispatchEvents([ + ...foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map( (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }), ), - ); + // The SDK resets its background task set when the CLI process (re)starts. + { type: "background_work", provider: "claude", items: null }, + ]); } private startQueryPump(): void { @@ -4199,12 +4233,19 @@ class ClaudeAgentSession implements AgentSession { if (!this.ownsQuery(activeQuery)) return true; this.processingSdkMessages++; try { - this.runtimeSafety.observe(message); + const backgroundWork = this.runtimeSafety.observe(message); + if (message.type === "system" && message.subtype === "task_notification") + this.backgroundStops.get(message.task_id)?.(message.status); logRawMessage(message); consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) return true; if (!this.ownsQuery(activeQuery)) return true; await this.routeSdkMessageFromPump(message); + if (!this.ownsQuery(activeQuery)) return true; + if (backgroundWork && this.runtimeSafety.isCurrentInventory(backgroundWork)) + this.dispatchEvents([ + { type: "background_work", provider: "claude", items: backgroundWork }, + ]); return false; } finally { this.processingSdkMessages--; @@ -4841,6 +4882,13 @@ class ClaudeAgentSession implements AgentSession { this.appendTaskNotificationEvents(message, events); return; } + if (message.subtype === "background_tasks_changed") { + this.logger.debug( + { tasks: message.tasks.map(({ task_id, task_type }) => ({ task_id, task_type })) }, + "claude.background_tasks_changed", + ); + return; + } if (message.subtype === "task_progress") { return; } diff --git a/packages/server/src/server/agent/providers/claude/background-work.test.ts b/packages/server/src/server/agent/providers/claude/background-work.test.ts new file mode 100644 index 0000000000..18c1265f28 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/background-work.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; + +import { toBackgroundWorkInputs } from "./background-work.js"; + +describe("toBackgroundWorkInputs", () => { + test("maps shells, drops subagents and workflows", () => { + expect( + toBackgroundWorkInputs([ + { task_id: "bash-1", task_type: "local_bash", description: "sleep 20" }, + { task_id: "agent-1", task_type: "local_agent", description: "Explore" }, + { task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" }, + { task_id: "x-1", task_type: "remote_something", description: " " }, + { task_id: "bash-2", task_type: "local_bash" }, + ]), + ).toEqual([ + { id: "bash-1", kind: "shell", description: "sleep 20", canStop: true }, + { id: "x-1", kind: "other", description: null, canStop: false }, + { id: "bash-2", kind: "shell", description: null, canStop: true }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/background-work.ts b/packages/server/src/server/agent/providers/claude/background-work.ts new file mode 100644 index 0000000000..9727fb994d --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/background-work.ts @@ -0,0 +1,36 @@ +import type { AgentBackgroundWorkInput } from "../../background-work/index.js"; +import { CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE } from "./subagents/live-source.js"; + +export interface ClaudeBackgroundTask { + task_id: string; + task_type: string; + /** The user's `claude` binary can differ from the SDK typings, so this is read defensively. */ + description?: unknown; +} + +/** Subagents and workflows already live in the subagents track. */ +const SUBAGENT_TASK_TYPES = new Set([CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE]); + +/** Maps `background_tasks_changed.tasks` to provider-agnostic background work. */ +export function toBackgroundWorkInputs( + tasks: readonly ClaudeBackgroundTask[], +): AgentBackgroundWorkInput[] { + return tasks + .filter((task) => !SUBAGENT_TASK_TYPES.has(task.task_type)) + .map((task) => ({ + id: task.task_id, + kind: toKind(task.task_type), + canStop: task.task_type === "local_bash", + description: readDescription(task.description), + })); +} + +/** The inventory owner refines shell entries when launch metadata identifies a monitor. */ +function toKind(taskType: string): string { + if (taskType === "local_bash") return "shell"; + return "other"; +} + +function readDescription(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.test.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.test.ts new file mode 100644 index 0000000000..6651f3e909 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.test.ts @@ -0,0 +1,48 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { expect, test } from "vitest"; +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; + +const message = (value: unknown) => value as SDKMessage; + +test("enriches only live tasks with their correlated launch metadata", () => { + const runtime = new ClaudeRuntimeSafety(); + runtime.observe( + message({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tool-1", + name: "Monitor", + input: { description: "Watch build", command: "tail -f build.log" }, + }, + { type: "tool_use", id: "old", name: "Bash", input: { command: "old command" } }, + ], + }, + }), + ); + runtime.observe( + message({ type: "system", subtype: "task_started", task_id: "task-1", tool_use_id: "tool-1" }), + ); + expect( + runtime.observe( + message({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "task-1", task_type: "local_bash", description: "Watch build" }], + }), + ), + ).toEqual([ + { + id: "task-1", + kind: "monitor", + description: "Watch build", + command: "tail -f build.log", + canStop: true, + }, + ]); + expect( + runtime.observe(message({ type: "system", subtype: "background_tasks_changed", tasks: [] })), + ).toEqual([]); +}); diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts index 0c32804a57..a21bf908ea 100644 --- a/packages/server/src/server/agent/providers/claude/runtime-safety.ts +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -1,42 +1,160 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { AgentBackgroundWorkInput } from "../../background-work/index.js"; import { parseClaudeCodeVersion } from "./model-manifest.js"; +import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-work.js"; /** Process-local background ownership. Completion delivery can follow an empty roster. */ export class ClaudeRuntimeSafety { - private supported = false; - private liveTasks = new Set(); + private inventoryState: "unknown" | "supported" | "retired" = "unknown"; + private liveTasks: readonly ClaudeBackgroundTask[] = []; + private published: AgentBackgroundWorkInput[] | undefined; private readonly awaitingNotifications = new Set(); + private readonly monitorTools = new Set(); + private readonly toolInputs = new Map(); + private readonly taskTools = new Map(); reset(): void { - this.supported = false; - this.liveTasks.clear(); + this.inventoryState = "unknown"; + this.liveTasks = []; + this.published = undefined; this.awaitingNotifications.clear(); + this.monitorTools.clear(); + this.toolInputs.clear(); + this.taskTools.clear(); } - observe(message: SDKMessage): void { - if (message.type !== "system") return; + retire(): void { + this.reset(); + this.inventoryState = "retired"; + } + + /** Returns a full replacement only when the visible inventory changes. */ + observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined { + if (this.inventoryState === "retired") return undefined; + const changed = this.observeTools(message); + const systemChanged = message.type === "system" ? this.observeSystem(message) : false; + if ((!changed && !systemChanged) || this.inventoryState !== "supported") return undefined; + const items = this.buildInventory(); + if (this.published && sameInventory(this.published, items)) return undefined; + this.published = items; + return items; + } + + private observeTools(message: SDKMessage): boolean { + if (message.type === "assistant") { + let relevant = false; + for (const block of message.message.content) { + if (block.type === "tool_use" && (block.name === "Monitor" || block.name === "Bash")) { + if (block.name === "Monitor") relevant = this.observeMonitor(block.id) || relevant; + relevant = this.observeInput(block.id, block.input) || relevant; + } + } + return relevant; + } + if (message.type === "stream_event" && message.event.type === "content_block_start") { + const block = message.event.content_block; + if (block.type === "tool_use" && block.name === "Monitor") + return this.observeMonitor(block.id); + } + return false; + } + + private observeInput(id: string, value: unknown): boolean { + const input = value as Record | null; + const description = typeof input?.description === "string" ? input.description.trim() : ""; + const command = typeof input?.command === "string" ? input.command.trim() : ""; + const previous = this.toolInputs.get(id); + if (description === (previous?.description ?? "") && command === (previous?.command ?? "")) + return false; + this.toolInputs.set(id, { + ...(description ? { description } : {}), + ...(command ? { command } : {}), + }); + return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id); + } + + private observeMonitor(id: string): boolean { + if (this.monitorTools.has(id)) return false; + this.monitorTools.add(id); + return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id); + } + + private observeSystem(message: Extract): boolean { + if (message.subtype === "task_started" && message.tool_use_id) { + const previous = this.taskTools.get(message.task_id); + this.taskTools.set(message.task_id, message.tool_use_id); + return ( + previous !== message.tool_use_id && + (this.monitorTools.has(message.tool_use_id) || + this.toolInputs.has(message.tool_use_id) || + (previous !== undefined && + (this.monitorTools.has(previous) || this.toolInputs.has(previous)))) && + this.liveTasks.some((task) => task.task_id === message.task_id) + ); + } if (message.subtype === "init") { - // 2.1.292 is a known roster-capable release. Older binaries can demonstrate - // support by sending a roster; a fresh process starts with an empty roster. const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); if ( version && (version[0] > 2 || (version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292)))) - ) - this.supported = true; + ) { + const changed = this.inventoryState !== "supported"; + this.inventoryState = "supported"; + return changed; + } } if (message.subtype === "background_tasks_changed") { - this.supported = true; - this.liveTasks = new Set(message.tasks.map((task) => task.task_id)); - for (const id of this.liveTasks) this.awaitingNotifications.add(id); - } - if (message.subtype === "task_notification") { - this.awaitingNotifications.delete(message.task_id); + this.inventoryState = "supported"; + this.liveTasks = message.tasks; + for (const task of this.liveTasks) this.awaitingNotifications.add(task.task_id); + return true; } + if (message.subtype === "task_notification") this.awaitingNotifications.delete(message.task_id); + return false; } canStopRuntime(): boolean { - return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0; + return ( + this.inventoryState === "supported" && + this.liveTasks.length === 0 && + this.awaitingNotifications.size === 0 + ); + } + + canStop(id: string): boolean { + return this.buildInventory().some((item) => item.id === id && item.canStop); + } + + isCurrentInventory(items: AgentBackgroundWorkInput[]): boolean { + return this.inventoryState === "supported" && this.published === items; + } + + private buildInventory(): AgentBackgroundWorkInput[] { + return toBackgroundWorkInputs(this.liveTasks).map((item) => { + const tool = this.taskTools.get(item.id) ?? ""; + if (this.monitorTools.has(tool)) item.kind = "monitor"; + const input = this.toolInputs.get(tool); + if (input?.description) item.description = input.description; + if (input?.command) item.command = input.command; + return item; + }); } } + +function sameInventory( + left: readonly AgentBackgroundWorkInput[], + right: readonly AgentBackgroundWorkInput[], +): boolean { + return ( + left.length === right.length && + left.every( + (item, index) => + item.id === right[index].id && + item.kind === right[index].kind && + item.description === right[index].description && + item.command === right[index].command && + item.canStop === right[index].canStop, + ) + ); +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index b7b3201897..f9fa3f8a19 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -49,9 +49,9 @@ interface TaskStartedMessage { } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ -const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent"; +export const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent"; /** Workflow executions use the same announced task lifecycle as Task-tool subagents. */ -const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; +export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; /** * Not every announced task belongs in the subagents track. Verified on the wire: diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index bf453ad651..78df159fc0 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -702,6 +702,20 @@ function markdownImageSource(markdown: string): string { return source.startsWith("file://") ? fileURLToPath(source) : source; } +function latestBackgroundWork(events: AgentStreamEvent[]) { + return events.findLast((event) => event.type === "background_work"); +} + +function hasBackgroundListRequest(appServer: FakeCodexAppServer, threadId: string) { + return appServer + .requests() + .some( + (request) => + request.method === "thread/backgroundTerminals/list" && + (request.params as { threadId: string }).threadId === threadId, + ); +} + function emitCodexUserMessage( appServer: FakeCodexAppServer, input: { id: string; text: string; threadId?: string; turnId?: string }, @@ -2172,13 +2186,16 @@ describe("Codex app-server provider", () => { expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }), expect.objectContaining({ type: "assistant_message", text: "Hi there." }), ]); - expect(codex.threadMethods(0)).toEqual([ + // Background terminal observation is independent of conversation history loading. + const historyMethods = (index: number) => + codex.threadMethods(index).filter((method) => method !== "thread/backgroundTerminals/list"); + expect(historyMethods(0)).toEqual([ "thread/loaded/list", "thread/resume", "thread/read", "thread/read", ]); - expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); + expect(historyMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected); } finally { await codex.close(); @@ -2548,6 +2565,77 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("keeps a background terminal controllable on its original thread after rewind", async () => { + let live = true; + const appServer = createFakeCodexAppServer({ + initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }), + "thread/read": () => ({ thread: { id: "thread-1", historyMode: "legacy", turns: [] } }), + "thread/backgroundTerminals/list": (params) => { + const threadId = (params as { threadId: string }).threadId; + if (threadId === "thread-1" && live) + return { data: [{ processId: "12", command: "sleep 180" }], nextCursor: null }; + if (threadId === "forked-thread") + return { data: [{ processId: "12", command: "sleep 99" }], nextCursor: null }; + return { data: [], nextCursor: null }; + }, + "thread/backgroundTerminals/terminate": (params) => { + expect(params).toMatchObject({ threadId: "thread-1", processId: "12" }); + live = false; + return { terminated: true }; + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("first"); + emitCodexUserMessage(appServer, { id: "first", text: "first", turnId: "turn-first" }); + appServer.completeTurn(); + await vi.waitFor(() => + expect(latestBackgroundWork(events)).toMatchObject({ + items: [{ command: "sleep 180" }], + }), + ); + await new Promise((resolve) => setImmediate(resolve)); + await session.startTurn("second"); + emitCodexUserMessage(appServer, { id: "second", text: "second", turnId: "turn-second" }); + appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + await session.revertConversation({ messageId: "second" }); + await session.startTurn("continue"); + appServer.startsTerminalCommand({ + threadId: "forked-thread", + turnId: "turn-next", + itemId: "new", + processId: "12", + command: "echo hello", + }); + await vi.waitFor(() => + expect(hasBackgroundListRequest(appServer, "forked-thread")).toBe(true), + ); + await vi.waitFor(() => + expect(latestBackgroundWork(events)).toMatchObject({ + items: [{ command: "sleep 180" }, { command: "sleep 99" }], + }), + ); + const items = latestBackgroundWork(events)?.items; + expect(items).toHaveLength(2); + expect(items![0].id).not.toBe(items![1].id); + await session.stopBackgroundWork(items![0].id); + expect(latestBackgroundWork(events)).toMatchObject({ + items: [{ command: "sleep 99" }], + }); + } finally { + await session.close(); + } + }); + test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ ...fakeCodexThreadHistory({ @@ -7816,7 +7904,7 @@ describe("Codex interactive resume startup", () => { unsubscribe(); const laterEvents: AgentStreamEvent[] = []; session.subscribe((event) => laterEvents.push(event)); - expect(laterEvents).toEqual([]); + expect(laterEvents.filter((event) => event.type !== "background_work")).toEqual([]); } finally { await session.close(); appServer.assertNoErrors(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bf797949c7..b031ae410e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,3 +1,4 @@ +import { CodexBackgroundInventory } from "./codex/background-inventory.js"; import { validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, @@ -3494,6 +3495,7 @@ export class CodexAppServerAgentSession implements AgentSession { cancelRequested: boolean; } | null = null; private client: CodexAppServerClient | null = null; + private readonly background = new CodexBackgroundInventory((event) => this.emitEvent(event)); private threadRollbackAvailable = true; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private startupEvents: AgentStreamEvent[] | null = null; @@ -3717,6 +3719,7 @@ export class CodexAppServerAgentSession implements AgentSession { throw this.createClosedError(); } this.connectionState = "connected"; + this.background.start({ client, thread: this.currentThreadId }); } catch (error) { try { if (this.client === client) { @@ -3768,6 +3771,7 @@ export class CodexAppServerAgentSession implements AgentSession { private handleUnexpectedTermination(error: Error): void { this.connectionState = "disconnected"; + this.background.stop(); const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null; this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn }); if (hasActiveRootTurn) { @@ -4634,6 +4638,8 @@ export class CodexAppServerAgentSession implements AgentSession { const startupEvents = this.startupEvents; this.startupEvents = null; for (const event of startupEvents ?? []) this.notifySubscribers(event); + const background = this.background.snapshot(); + if (background) callback(background); return () => { this.subscribers.delete(callback); }; @@ -5125,6 +5131,10 @@ export class CodexAppServerAgentSession implements AgentSession { } } + stopBackgroundWork(id: string): Promise { + return this.background.stopItem(id); + } + async canStopRuntime(): Promise { if ( !this.client || @@ -5286,6 +5296,7 @@ export class CodexAppServerAgentSession implements AgentSession { async close(): Promise { this.closed = true; + this.background.stop(); this.clearPendingPermissions(); this.pendingToolCallDetails.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); @@ -5316,6 +5327,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private async disposeClient(): Promise { + this.background.stop(); const client = this.client; this.connectionState = "disconnected"; this.currentTurnId = null; @@ -5714,6 +5726,14 @@ export class CodexAppServerAgentSession implements AgentSession { await this.dispatchSubAgentNotification(parsed, route.callId); return; } + if ( + parsed.kind === "turn_completed" || + parsed.kind === "exec_command_started" || + parsed.kind === "exec_command_completed" || + ((parsed.kind === "item_started" || parsed.kind === "item_completed") && + normalizeCodexThreadItemType(parsed.item.type) === "commandExecution") + ) + this.background.changed(this.currentThreadId); await this.dispatchParsedNotification(parsed); } @@ -6353,6 +6373,7 @@ export class CodexAppServerAgentSession implements AgentSession { parsed: Extract, ): void { this.currentThreadId = parsed.threadId; + this.background.changed(this.currentThreadId); this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, diff --git a/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts b/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts index 3d2ea64f9a..d089bc0445 100644 --- a/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts +++ b/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts @@ -45,6 +45,7 @@ function createHarness( } = {}, ) { const handlers: Record = { + "thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }), "turn/interrupt": () => { const completeTurn = () => appServer.completeTurn({ threadId: THREAD, status: "interrupted" }); @@ -169,7 +170,11 @@ describe("an interrupted Codex turn's commands end before the next turn", () => expect( harness.appServer .requests() - .filter((message) => String(message.method).startsWith("thread/backgroundTerminals/")) + .filter( + (message) => + String(message.method).startsWith("thread/backgroundTerminals/") && + message.method !== "thread/backgroundTerminals/list", + ) .map((message) => message.method), ).toEqual([TERMINATE, TERMINATE]); harness.appServer.assertNoErrors(); @@ -372,3 +377,328 @@ describe("an interrupted Codex turn's commands end before the next turn", () => expect(harness.terminated()).toEqual(["201", "202"]); }, 15_000); }); + +test("publishes every page of the current thread's live background terminals", async () => { + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": (params) => { + const cursor = paramsOf(params).cursor; + return cursor + ? { data: [{ itemId: "item-2", processId: "2", command: "sleep 2" }], nextCursor: null } + : { data: [{ itemId: "item-1", processId: "1", command: "sleep 1" }], nextCursor: "next" }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [ + { kind: "shell", description: null, command: "sleep 1" }, + { kind: "shell", description: null, command: "sleep 2" }, + ], + }), + ); +}); + +test("discards a terminal list overtaken by a command event", async () => { + const first = deferred(); + let lists = 0; + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => + ++lists === 1 ? first.promise : { data: [], nextCursor: null }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => expect(lists).toBe(1)); + const shown = waitForTimelineToolCall(session, "item-new"); + appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "turn", + itemId: "item-new", + processId: "new", + command: "sleep 1", + }); + await shown; + first.resolve({ + data: [{ itemId: "old", processId: "old", command: "sleep 2" }], + nextCursor: null, + }); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [], + }), + ); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }), + ); +}); + +test("does not publish a terminal response from a lost runtime", async () => { + const first = deferred(); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => first.promise, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await appServer.waitForRequest("thread/backgroundTerminals/list"); + appServer.disconnect(); + first.resolve({ + data: [{ itemId: "old", processId: "old", command: "sleep 2" }], + nextCursor: null, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }), + ); +}); + +test("accepts a slow terminal snapshot after the polling interval", async () => { + const pending = deferred(); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => pending.promise, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await appServer.waitForRequest("thread/backgroundTerminals/list"); + await new Promise((resolve) => setTimeout(resolve, 5200)); + expect( + appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"), + ).toHaveLength(1); + pending.resolve({ data: [{ processId: "slow", command: "sleep 120" }], nextCursor: null }); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 120" }], + }), + ); + expect( + appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"), + ).toHaveLength(1); +}, 12000); + +test("does not retry an unsupported inventory on later command events", async () => { + let calls = 0; + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => { + calls++; + return { __jsonRpcError: { code: -32601, message: "Method not found" } }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + await session.connect(); + await vi.waitFor(() => expect(calls).toBe(1)); + const shown = waitForTimelineToolCall(session, "unsupported-item"); + appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "turn", + itemId: "unsupported-item", + processId: "1", + command: "sleep 1", + }); + await shown; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(calls).toBe(1); +}); + +test("retries transient inventory failures and cancels retries on stop", async () => { + vi.useFakeTimers(); + try { + const { CodexBackgroundInventory } = await import("./codex/background-inventory.js"); + let calls = 0; + let fail = true; + const inventory = new CodexBackgroundInventory(() => {}); + inventory.start({ + client: { + request: async () => { + calls++; + if (fail) throw new Error("temporary"); + return { data: [{ processId: "live", command: "sleep 120" }], nextCursor: null }; + }, + }, + thread: THREAD, + }); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()).toBeNull(); + inventory.changed(THREAD); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toBe(1); + fail = false; + await vi.advanceTimersByTimeAsync(5000); + expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]); + fail = true; + await vi.advanceTimersByTimeAsync(5000); + expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]); + inventory.stop(); + const stoppedCalls = calls; + await vi.advanceTimersByTimeAsync(15000); + expect(calls).toBe(stoppedCalls); + } finally { + vi.useRealTimers(); + } +}); + +test("does not retry a failure from a stopped inventory generation", async () => { + vi.useFakeTimers(); + try { + const { CodexBackgroundInventory } = await import("./codex/background-inventory.js"); + let reject!: (error: Error) => void; + let calls = 0; + const response = new Promise((_, fail) => { + reject = fail; + }); + const inventory = new CodexBackgroundInventory(() => {}); + inventory.start({ + client: { + request: () => { + calls++; + return response; + }, + }, + thread: THREAD, + }); + inventory.stop(); + reject(new Error("late failure")); + await vi.advanceTimersByTimeAsync(15000); + expect(calls).toBe(1); + expect(inventory.snapshot()).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test("stops only the selected live background terminal and confirms the roster", async () => { + const live = new Set(["1", "2"]); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => ({ + data: [...live].map((processId) => ({ processId, command: `sleep ${processId}` })), + nextCursor: null, + }), + "thread/backgroundTerminals/terminate": (params) => ({ + terminated: live.delete(String(paramsOf(params).processId)), + }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }, { command: "sleep 2" }], + }), + ); + const item = events + .findLast((event) => event.type === "background_work") + ?.items?.find((entry) => entry.command === "sleep 1"); + expect(item).toBeDefined(); + await session.stopBackgroundWork(item!.id); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 2" }], + }); + await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow(); +}); + +test("reports a rejected background stop and keeps the item available", async () => { + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => ({ + data: [{ processId: "1", command: "sleep 1" }], + nextCursor: null, + }), + "thread/backgroundTerminals/terminate": () => ({ terminated: false }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }], + }), + ); + const item = events.findLast((event) => event.type === "background_work")?.items?.[0]; + expect(item).toBeDefined(); + await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow("not terminated"); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }], + }); +}); + +test("does not confirm a stop after the provider connection is lost", async () => { + const response = deferred(); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => ({ + data: [{ processId: "1", command: "sleep 1" }], + nextCursor: null, + }), + "thread/backgroundTerminals/terminate": () => response.promise, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }], + }), + ); + const item = events.findLast((event) => event.type === "background_work")?.items?.[0]; + expect(item).toBeDefined(); + const pending = session.stopBackgroundWork(item!.id); + await appServer.waitForRequest("thread/backgroundTerminals/terminate"); + appServer.disconnect(); + response.resolve({ terminated: true }); + await expect(pending).rejects.toThrow(); +}); diff --git a/packages/server/src/server/agent/providers/codex/background-inventory.ts b/packages/server/src/server/agent/providers/codex/background-inventory.ts new file mode 100644 index 0000000000..856634dbcb --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/background-inventory.ts @@ -0,0 +1,187 @@ +import { z } from "zod"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { CodexAppServerRpcError } from "./app-server-transport.js"; + +type InventoryEvent = Extract; +interface Client { + request(method: string, params?: unknown, timeoutMs?: number): Promise; +} + +export class CodexBackgroundInventory { + private generation = 0; + private client: Client | null = null; + private thread: string | null = null; + private readonly threads = new Set(); + private origins = new Map(); + private pending = false; + private dirty = false; + private unsupported = false; + private retrying = false; + private timer: ReturnType | null = null; + private current: InventoryEvent | null = null; + private readonly listeners = new Set<() => void>(); + + async stopItem(id: string): Promise { + const client = this.client; + const origin = this.origins.get(id); + const generation = this.generation; + if (!client || !origin || !this.current?.items?.some((item) => item.id === id)) + throw new Error("Background terminal is no longer available"); + const result = z + .object({ terminated: z.boolean() }) + .parse( + await client.request( + "thread/backgroundTerminals/terminate", + { threadId: origin.threadId, processId: origin.processId }, + 10000, + ), + ); + if (generation !== this.generation) throw new Error("Background terminal connection changed"); + if (!result.terminated) throw new Error("Background terminal was not terminated"); + await this.waitUntilAbsent(id, generation); + } + + private waitUntilAbsent(id: string, generation: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => finish(new Error("Background terminal confirmation timed out")), + 15000, + ); + const finish = (error?: Error) => { + clearTimeout(timer); + this.listeners.delete(check); + if (error) reject(error); + else resolve(); + }; + const check = () => { + if (generation !== this.generation) + finish(new Error("Background terminal connection changed")); + else if (this.current?.items && !this.current.items.some((item) => item.id === id)) + finish(); + }; + this.listeners.add(check); + this.changed(this.thread); + }); + } + private notify() { + for (const listener of this.listeners) listener(); + } + + constructor(private readonly emit: (event: InventoryEvent) => void) {} + + snapshot() { + return this.current; + } + start({ client, thread }: { client: Client; thread: string | null }) { + this.stop(); + this.client = client; + this.thread = thread; + this.unsupported = false; + this.changed(thread); + } + stop() { + this.generation++; + this.notify(); + this.client = null; + this.thread = null; + this.threads.clear(); + this.origins.clear(); + this.pending = false; + this.dirty = false; + this.retrying = false; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + if (this.current) this.emit({ type: "background_work", provider: "codex", items: null }); + this.current = null; + } + changed(thread: string | null) { + this.thread = thread; + if (thread) this.threads.add(thread); + if (!this.client || !thread || this.unsupported) return; + this.dirty = true; + if (!this.retrying) this.refresh(); + } + private refresh() { + if (this.pending || !this.client || !this.thread || this.unsupported) return; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.pending = true; + this.dirty = false; + const generation = this.generation; + const client = this.client; + const threads = [...this.threads]; + void (async () => { + let failed = false; + try { + const { items, origins, activeThreads } = await readInventories(client, threads); + if (generation !== this.generation || this.dirty) return; + this.origins = origins; + this.threads.clear(); + for (const threadId of activeThreads) this.threads.add(threadId); + if (this.thread) this.threads.add(this.thread); + this.current = { type: "background_work", provider: "codex", items }; + this.emit(this.current); + this.notify(); + } catch (error) { + failed = true; + if ( + generation === this.generation && + error instanceof CodexAppServerRpcError && + error.code === -32601 + ) + this.unsupported = true; + } finally { + if (generation === this.generation) { + this.pending = false; + if (this.dirty && !failed && !this.unsupported) this.refresh(); + else if (!this.unsupported && (failed || this.current?.items?.length)) { + this.retrying = failed; + this.timer = setTimeout(() => { + this.timer = null; + this.retrying = false; + this.refresh(); + }, 5000); + this.timer.unref(); + } + } + } + })(); + } +} + +async function readInventories(client: Client, threads: string[]) { + const items: NonNullable = []; + const origins = new Map(); + const activeThreads = new Set(); + for (const threadId of threads) { + let cursor: string | null = null; + do { + const page = z + .object({ + data: z.array(z.object({ processId: z.string(), command: z.string() })), + nextCursor: z.string().nullable(), + }) + .parse( + await client.request( + "thread/backgroundTerminals/list", + { threadId, ...(cursor ? { cursor } : {}) }, + 10_000, + ), + ); + for (const row of page.data) { + const id = JSON.stringify([threadId, row.processId]); + origins.set(id, { threadId, processId: row.processId }); + activeThreads.add(threadId); + items.push({ + id, + kind: "shell", + canStop: true, + description: null, + ...(row.command ? { command: row.command } : {}), + }); + } + cursor = page.nextCursor; + } while (cursor); + } + return { items, origins, activeThreads }; +} diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index ec14a773e4..106c734753 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -175,6 +175,7 @@ export function createFakeCodexAppServer( "skills/list": () => ({ data: [] }), "thread/start": () => ({ thread: { id: "thread-1" } }), "thread/loaded/list": () => ({ data: [] }), + "thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }), "thread/resume": () => ({}), "turn/start": () => ({}), "thread/fork": (params) => ({ diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index eb06928e63..66ee191707 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -91,6 +91,7 @@ export class V2Harness { readonly api: V2Api = { server: { info: unexpected }, shell: { + remove: unexpected, list: async () => ({ location: { directory: this.info.location.directory }, data: [] }), }, plugin: { diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index e8188730f5..6e1a45409c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1201,3 +1201,773 @@ test("OpenCode v2 runtime safety follows descendant locations before releasing t await session.close(); } }); + +test("reconstructs only promoted session-owned running shells and reconciles exits", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: { description: "Watch tests", command: "sleep ours" }, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "ours" }, + }, + }, + ], + }); + harness.api.message.list = async (input) => + input.cursor + ? { data: [...harness.history], cursor: {} } + : { data: [], cursor: { next: "second-page" } }; + let live = ["ours", "other"]; + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: live.map((id) => ({ + id, + status: "running" as const, + command: `sleep ${id}`, + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: id === "ours" ? harness.info.id : "other" }, + time: { started: 1 }, + })), + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ + items: [{ id: "ours", kind: "shell", description: "Watch tests", command: "sleep ours" }], + }); + live = []; + harness.push({ id: "reconnect", created: 2, type: "server.connected", data: {} }); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [] }); + } finally { + await session.close(); + } +}); + +test("an exit received during shell listing cannot restore an old row", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "shell" }, + }, + }, + ], + }); + const shell = { + id: "shell", + status: "running" as const, + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }; + let listCount = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + harness.api.shell.list = async () => { + listCount++; + if (listCount === 2) { + await gate; + return { location: harness.info.location, data: [shell] }; + } + return { location: harness.info.location, data: [] }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + harness.push({ id: "created", created: 2, type: "shell.created", data: { info: shell } }); + await expect.poll(() => listCount).toBe(2); + harness.push({ + id: "exited", + created: 3, + type: "shell.exited", + data: { id: "shell", status: "exited" }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + release(); + await expect.poll(() => listCount).toBeGreaterThan(2); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [] }); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ id: "shell" })] }), + ); + } finally { + release(); + await session.close(); + } +}); + +test("does not publish a shell list from a lost server generation", async () => { + const harness = new V2Harness(); + let exit!: (error: Error) => void; + Object.assign(harness.connection, { + exited: new Promise((resolve) => { + exit = resolve; + }), + }); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "old" }, + }, + }, + ], + }); + const shell = { + id: "old", + status: "running" as const, + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }; + let reads = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + harness.api.shell.list = async () => { + reads++; + if (reads === 2) { + await gate; + return { location: harness.info.location, data: [shell] }; + } + return { location: harness.info.location, data: [] }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + harness.push({ id: "created", created: 2, type: "shell.created", data: { info: shell } }); + await expect.poll(() => reads).toBe(2); + exit(new Error("server exited")); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: null }); + release(); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ id: "old" })] }), + ); + } finally { + release(); + await session.close(); + } +}); + +test("polls a live shell without rereading conversation history", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + let historyReads = 0; + let shellReads = 0; + const readHistory = harness.api.message.list; + harness.api.message.list = async (...args) => { + historyReads++; + return readHistory(...args); + }; + harness.api.shell.list = async () => { + shellReads++; + return { + location: harness.info.location, + data: [ + { + id: "live", + status: "running", + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ], + }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "live" }] }); + const initialHistoryReads = historyReads; + const initialShellReads = shellReads; + await expect.poll(() => shellReads, { timeout: 7000 }).toBeGreaterThan(initialShellReads); + expect(historyReads).toBe(initialHistoryReads); + } finally { + await session.close(); + } +}, 12000); + +test("buffers tool promotion and shell events during background history bootstrap", async () => { + const harness = new V2Harness(); + let historyReads = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + harness.api.message.list = async () => { + historyReads++; + if (historyReads === 3) await gate; + return { data: [], cursor: {} }; + }; + const shell = { + id: "promoted", + status: "running" as const, + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }; + harness.api.shell.list = async () => ({ location: harness.info.location, data: [shell] }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect.poll(() => historyReads).toBeGreaterThanOrEqual(3); + harness.push({ + id: "input", + created: 2, + type: "session.tool.input.started", + durable: { aggregateID: harness.info.id, seq: 1, version: 1 }, + data: { sessionID: harness.info.id, assistantMessageID: "msg", id: "tool", name: "shell" }, + }); + harness.push({ + id: "input-ended", + created: 3, + type: "session.tool.input.ended", + durable: { aggregateID: harness.info.id, seq: 2, version: 1 }, + data: { + sessionID: harness.info.id, + assistantMessageID: "msg", + id: "tool", + text: JSON.stringify({ description: "Watch tests", command: "sleep 120" }), + }, + }); + harness.push({ id: "created", created: 3, type: "shell.created", data: { info: shell } }); + harness.push({ + id: "success", + created: 4, + type: "session.tool.success", + durable: { aggregateID: harness.info.id, seq: 3, version: 2 }, + data: { + sessionID: harness.info.id, + assistantMessageID: "msg", + id: "tool", + executed: true, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "promoted" }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events.filter((event) => event.type === "background_work")).toEqual([]); + release(); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ + items: [{ id: "promoted", description: "Watch tests", command: "sleep 120" }], + }); + harness.push({ + id: "next-input", + created: 5, + type: "session.tool.input.started", + durable: { aggregateID: harness.info.id, seq: 4, version: 1 }, + data: { sessionID: harness.info.id, assistantMessageID: "next", id: "tool", name: "shell" }, + }); + harness.push({ + id: "next-success", + created: 6, + type: "session.tool.success", + durable: { aggregateID: harness.info.id, seq: 5, version: 2 }, + data: { + sessionID: harness.info.id, + assistantMessageID: "next", + id: "tool", + executed: true, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "second" }, + }, + }); + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: [shell, { ...shell, id: "second" }], + }); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "promoted" }, { id: "second" }] }); + } finally { + release(); + await session.close(); + } +}); + +test("retries failed background bootstrap and roster without losing stale work", async () => { + const { vi } = await import("vitest"); + vi.useFakeTimers(); + try { + const { OpenCodeBackgroundInventory } = await import("./background-inventory.js"); + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + let historyCalls = 0; + let shellCalls = 0; + let historyFails = true; + let shellFails = true; + harness.api.message.list = async () => { + historyCalls++; + if (historyFails) throw new Error("history unavailable"); + return { data: harness.history, cursor: {} }; + }; + harness.api.shell.list = async () => { + shellCalls++; + if (shellFails) throw new Error("roster unavailable"); + return { + location: harness.info.location, + data: [ + { + id: "live", + status: "running", + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ], + }; + }; + const inventory = new OpenCodeBackgroundInventory({ + id: harness.info.id, + cwd: "/tmp/project", + emit: () => {}, + report: () => {}, + }); + inventory.start(harness.api); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()).toBeNull(); + historyFails = false; + await vi.advanceTimersByTimeAsync(5000); + expect(historyCalls).toBe(2); + expect(shellCalls).toBe(1); + expect(inventory.snapshot()).toBeNull(); + inventory.observe({ id: "deleted", created: 1, type: "shell.deleted", data: { id: "other" } }); + await vi.advanceTimersByTimeAsync(0); + expect(shellCalls).toBe(1); + shellFails = false; + await vi.advanceTimersByTimeAsync(5000); + expect(historyCalls).toBe(2); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live", canStop: true }]); + historyFails = true; + inventory.start(harness.api); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()?.items).toEqual([ + { id: "live", kind: "shell", description: null, command: "sleep 120" }, + ]); + await expect(inventory.stopItem("live")).rejects.toThrow("no longer available"); + historyFails = false; + await vi.advanceTimersByTimeAsync(5000); + expect(historyCalls).toBe(4); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live", canStop: true }]); + shellFails = true; + await vi.advanceTimersByTimeAsync(5000); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live" }]); + inventory.disconnect(); + const stoppedCalls = shellCalls; + await vi.advanceTimersByTimeAsync(15000); + expect(shellCalls).toBe(stoppedCalls); + } finally { + vi.useRealTimers(); + } +}); + +test("does not retry a failed bootstrap from a disconnected generation", async () => { + const { vi } = await import("vitest"); + vi.useFakeTimers(); + try { + const { OpenCodeBackgroundInventory } = await import("./background-inventory.js"); + const harness = new V2Harness(); + let reject!: (error: Error) => void; + let calls = 0; + const response = new Promise>>( + (_, fail) => { + reject = fail; + }, + ); + harness.api.message.list = () => { + calls++; + return response; + }; + const inventory = new OpenCodeBackgroundInventory({ + id: harness.info.id, + cwd: "/tmp/project", + emit: () => {}, + report: () => {}, + }); + inventory.start(harness.api); + inventory.disconnect(); + reject(new Error("late failure")); + await vi.advanceTimersByTimeAsync(15000); + expect(calls).toBe(1); + expect(inventory.snapshot()).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test("stops a selected promoted shell and retains its sibling", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: ["one", "two"].map((id) => ({ + type: "tool" as const, + id, + name: "shell", + time: { created: 1 }, + state: { + status: "completed" as const, + input: {}, + content: [{ type: "text" as const, text: "" }], + metadata: { status: "running", shellID: id }, + }, + })), + }); + let live = ["one", "two"]; + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: live.map((id) => ({ + id, + status: "running" as const, + command: `sleep ${id}`, + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + })), + }); + harness.api.shell.remove = async ({ id }) => { + live = live.filter((entry) => entry !== id); + return { data: true }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "one" }, { id: "two" }] }); + await session.stopBackgroundWork!("one"); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ id: "two" }], + }); + await expect(session.stopBackgroundWork!("one")).rejects.toThrow(); + } finally { + await session.close(); + } +}); + +test("closing during reconnect does not restart the background inventory", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [] }); + let unblock!: () => void; + let entered = false; + const blocked = new Promise((resolve) => { + unblock = resolve; + }); + harness.connection.bindSession = async () => { + entered = true; + await blocked; + }; + let reads = 0; + const original = harness.api.message.list; + harness.api.message.list = (...args) => { + reads++; + return original(...args); + }; + harness.push({ id: "reconnect-close", created: 2, type: "server.connected", data: {} }); + await expect.poll(() => entered).toBe(true); + const closing = session.close(); + unblock(); + await closing; + expect(reads).toBe(0); +}); + +test("keeps a shell available when its stop request times out", async () => { + const { vi } = await import("vitest"); + vi.useFakeTimers(); + try { + const { OpenCodeBackgroundInventory } = await import("./background-inventory.js"); + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: [ + { + id: "live", + status: "running", + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ], + }); + harness.api.shell.remove = () => new Promise(() => {}); + const inventory = new OpenCodeBackgroundInventory({ + id: harness.info.id, + cwd: "/tmp/project", + emit: () => {}, + report: () => {}, + }); + inventory.start(harness.api); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live", canStop: true }]); + const result = expect(inventory.stopItem("live")).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(10000); + await result; + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live" }]); + inventory.disconnect(); + } finally { + vi.useRealTimers(); + } +}); + +test("keeps a live background shell visible and stoppable after its launch is rewound", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + let live = true; + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: live + ? [ + { + id: "live", + status: "running", + command: "sleep 180", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ] + : [], + }); + harness.api.shell.remove = async () => { + live = false; + return { data: true }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "live", canStop: true }] }); + harness.history.length = 0; + harness.push({ + id: "revert", + created: 2, + type: "session.revert.committed", + durable: { aggregateID: harness.info.id, seq: 1, version: 1 }, + data: { sessionID: harness.info.id, to: "earlier" }, + }); + await expect + .poll(() => events.filter((event) => event.type === "background_work").length) + .toBeGreaterThanOrEqual(3); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ id: "live", canStop: true }], + }); + harness.push({ id: "reconnect-after-rewind", created: 3, type: "server.connected", data: {} }); + await expect + .poll(() => events.filter((event) => event.type === "background_work").length) + .toBeGreaterThanOrEqual(5); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ id: "live", canStop: true }], + }); + await session.stopBackgroundWork!("live"); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [], + }); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts index bec40ffbcb..31754eb468 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/api.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -2,7 +2,7 @@ import type { OpenCodeClient } from "@opencode/client"; export interface V2Api { server: OpenCodeClient["server"]; - shell: Pick; + shell: Pick; session: Pick< OpenCodeClient["session"], | "create" diff --git a/packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts b/packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts new file mode 100644 index 0000000000..9c10a1ce86 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts @@ -0,0 +1,308 @@ +import type { OpenCodeEvent, SessionMessageInfo, ShellInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Api } from "./api.js"; +import { messages } from "./history.js"; + +type InventoryEvent = Extract; +type RelevantEvent = Extract< + OpenCodeEvent, + { + type: + | "session.tool.input.started" + | "session.tool.input.ended" + | "session.tool.success" + | "shell.created" + | "shell.exited" + | "shell.deleted"; + } +>; + +export class OpenCodeBackgroundInventory { + private generation = 0; + private client: V2Api | null = null; + private loading = false; + private pending = false; + private dirty = false; + private retrying = false; + private events: RelevantEvent[] = []; + private names = new Map(); + private inputs = new Map(); + private descriptions = new Map(); + private promoted = new Set(); + private timer: ReturnType | null = null; + private current: InventoryEvent | null = null; + private readonly listeners = new Set<() => void>(); + async stopItem(id: string): Promise { + const client = this.client; + const generation = this.generation; + if (!client || !this.current?.items?.some((item) => item.id === id && item.canStop)) + throw new Error("Background shell is no longer available"); + let timer: ReturnType | undefined; + let disconnected!: () => void; + const lost = new Promise((_, reject) => { + disconnected = () => { + if (generation !== this.generation) + reject(new Error("Background shell connection changed")); + }; + this.listeners.add(disconnected); + timer = setTimeout(() => reject(new Error("Background shell stop timed out")), 10000); + }); + try { + await Promise.race([client.shell.remove({ id, location: { directory: this.cwd } }), lost]); + } finally { + clearTimeout(timer); + this.listeners.delete(disconnected); + } + if (generation !== this.generation) throw new Error("Background shell connection changed"); + await new Promise((resolve, reject) => { + const confirmationTimer = setTimeout( + () => finish(new Error("Background shell confirmation timed out")), + 15000, + ); + const finish = (error?: Error) => { + clearTimeout(confirmationTimer); + this.listeners.delete(check); + if (error) reject(error); + else resolve(); + }; + const check = () => { + if (generation !== this.generation) + finish(new Error("Background shell connection changed")); + else if (this.current?.items && !this.current.items.some((item) => item.id === id)) + finish(); + }; + this.listeners.add(check); + this.dirty = true; + this.refresh(); + }); + } + private notify() { + for (const listener of this.listeners) listener(); + } + constructor(options: { + id: string; + cwd: string; + emit: (event: InventoryEvent) => void; + report: (error: unknown) => void; + }) { + this.id = options.id; + this.cwd = options.cwd; + this.emit = options.emit; + this.report = options.report; + } + private readonly id: string; + private readonly cwd: string; + private readonly emit: (event: InventoryEvent) => void; + private readonly report: (error: unknown) => void; + snapshot() { + return this.current; + } + start(client: V2Api) { + this.disconnect(); + this.client = client; + this.loading = true; + this.events = []; + const generation = this.generation; + void this.bootstrap(client, generation); + } + disconnect(lost = false) { + this.generation++; + this.notify(); + this.client = null; + this.loading = false; + this.pending = false; + this.dirty = false; + this.retrying = false; + this.events = []; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + if (lost) { + this.promoted.clear(); + this.descriptions.clear(); + this.current = null; + this.emit({ type: "background_work", provider: "opencode", items: null }); + } else if (this.current?.items?.some((item) => item.canStop)) { + // Keep stale rows visible, but revoke their stop tokens until this generation's + // roster confirms ownership again. + const items = this.current.items.map((item) => ({ + id: item.id, + kind: item.kind, + description: item.description, + ...(item.command ? { command: item.command } : {}), + })); + this.current = { type: "background_work", provider: "opencode", items }; + this.emit(this.current); + } + } + private async bootstrap(client: V2Api, generation: number) { + try { + const history = await messages(client, this.id); + if (generation !== this.generation) return; + this.names = new Map(); + this.inputs = new Map(); + // Rewinds can remove launch messages without stopping the shell. Keep known + // promotions until the current provider roster confirms they are gone. + this.readHistory(history); + for (const event of this.events) this.apply(event); + this.events = []; + this.loading = false; + this.dirty = true; + this.refresh(); + } catch (error) { + if (generation === this.generation) { + this.report(error); + this.schedule(() => void this.bootstrap(client, generation), true); + } + } + } + private readHistory(history: readonly SessionMessageInfo[]) { + for (const message of history) { + if (message.type !== "assistant") continue; + for (const part of message.content) { + if (part.type !== "tool") continue; + if (part.state.status === "completed") { + if (part.name === "shell") this.promote(part.state.metadata, part.state.input); + } else if (part.state.status !== "error") { + const key = `${message.id}:${part.id}`; + this.names.set(key, part.name); + if (part.name === "shell") this.inputs.set(key, part.state.input); + } + } + } + } + private promote(metadata: { [key: string]: unknown } | undefined, input?: unknown) { + if (metadata?.status === "running" && typeof metadata.shellID === "string") { + this.promoted.add(metadata.shellID); + if ( + input && + typeof input === "object" && + "description" in input && + typeof input.description === "string" && + input.description.trim() + ) + this.descriptions.set(metadata.shellID, input.description.trim()); + } + } + observe(event: OpenCodeEvent) { + if (event.type === "session.revert.committed" && event.data.sessionID === this.id) { + if (this.client) this.start(this.client); + return; + } + if ( + event.type !== "shell.created" && + event.type !== "shell.exited" && + event.type !== "shell.deleted" && + event.type !== "session.tool.input.started" && + event.type !== "session.tool.input.ended" && + event.type !== "session.tool.success" + ) + return; + if ("sessionID" in event.data && event.data.sessionID !== this.id) return; + if (!this.client) return; + if (this.loading) { + this.events.push(event); + return; + } + this.apply(event); + if (event.type !== "session.tool.input.started" && event.type !== "session.tool.input.ended") { + this.dirty = true; + if (!this.retrying) this.refresh(); + } + } + private apply(event: RelevantEvent) { + if (event.type === "session.tool.input.started") + this.names.set(`${event.data.assistantMessageID}:${event.data.id}`, event.data.name); + if ( + event.type === "session.tool.input.ended" && + this.names.get(`${event.data.assistantMessageID}:${event.data.id}`) === "shell" + ) { + try { + this.inputs.set( + `${event.data.assistantMessageID}:${event.data.id}`, + JSON.parse(event.data.text), + ); + } catch { + /* A malformed tool input supplies no presentation metadata. */ + } + } + if ( + event.type === "session.tool.success" && + this.names.get(`${event.data.assistantMessageID}:${event.data.id}`) === "shell" + ) + this.promote( + event.data.metadata, + this.inputs.get(`${event.data.assistantMessageID}:${event.data.id}`), + ); + if (event.type === "session.tool.success") { + this.names.delete(`${event.data.assistantMessageID}:${event.data.id}`); + this.inputs.delete(`${event.data.assistantMessageID}:${event.data.id}`); + } + } + private refresh() { + if (!this.client || this.loading || this.pending) return; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.pending = true; + this.dirty = false; + const generation = this.generation; + const client = this.client; + let failed = false; + void client.shell + .list({ location: { directory: this.cwd } }) + .then((result) => { + if (generation !== this.generation || this.dirty) return undefined; + this.publish(result.data); + return undefined; + }) + .catch((error: unknown) => { + if (generation === this.generation) { + failed = true; + this.report(error); + } + }) + .finally(() => { + if (generation !== this.generation) return; + this.pending = false; + if (this.dirty && !failed) this.refresh(); + else if (failed || this.current?.items?.length) this.schedule(() => this.refresh(), failed); + }); + } + private schedule(callback: () => void, retry = false) { + this.retrying = retry; + this.timer = setTimeout(() => { + this.timer = null; + this.retrying = false; + callback(); + }, 5000); + this.timer.unref(); + } + private publish(shells: ShellInfo[]) { + const live = new Set( + shells + .filter((shell) => shell.status === "running" && shell.metadata.sessionID === this.id) + .map((shell) => shell.id), + ); + this.promoted = new Set([...this.promoted].filter((id) => live.has(id))); + for (const id of this.descriptions.keys()) if (!live.has(id)) this.descriptions.delete(id); + this.current = { + type: "background_work", + provider: "opencode", + items: shells + .filter( + (shell) => + shell.status === "running" && + shell.metadata.sessionID === this.id && + this.promoted.has(shell.id), + ) + .map((shell) => ({ + id: shell.id, + kind: "shell", + description: this.descriptions.get(shell.id) ?? null, + command: shell.command || undefined, + canStop: true, + })), + }; + this.emit(this.current); + this.notify(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 9c1197fa21..58981c7086 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -1,3 +1,4 @@ +import { OpenCodeBackgroundInventory } from "./background-inventory.js"; import { SessionChildren } from "./children.js"; import { SessionTurns } from "./turns.js"; import { V2Timeline } from "./timeline.js"; @@ -57,6 +58,7 @@ export class OpenCodeV2Session implements AgentSession { private refreshTimer: ReturnType | null = null; private closed = false; private history: SessionMessageInfo[] = []; + private readonly background: OpenCodeBackgroundInventory; private modes: AgentMode[] = []; constructor( private connection: V2Connection, @@ -70,6 +72,16 @@ export class OpenCodeV2Session implements AgentSession { private readonly acquire?: () => Promise, private readonly moved?: (connection: V2Connection) => void, ) { + this.background = new OpenCodeBackgroundInventory({ + id: info.id, + cwd: config.cwd, + emit: (event) => this.emit(event), + report: (error) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode background inventory failed", + ), + }); this.permissions = new SessionPermissions( () => this.client, info.id, @@ -143,6 +155,7 @@ export class OpenCodeV2Session implements AgentSession { void connection.exited.then((error) => { if (this.closed || this.connection !== connection) return undefined; this.exited = true; + this.background.disconnect(true); this.streamAbort.abort(error); this.turns.fail(error); return undefined; @@ -242,6 +255,8 @@ export class OpenCodeV2Session implements AgentSession { } subscribe(callback: (event: AgentStreamEvent) => void) { this.listeners.add(callback); + const background = this.background.snapshot(); + if (background) callback(background); // Reopening can adopt an execution during initialization, before its caller // can subscribe. Deliver that live turn so it remains visible and interruptible. if (this.turns.id) @@ -406,6 +421,9 @@ export class OpenCodeV2Session implements AgentSession { steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { return this.turns.steerActiveTurn(prompt, options); } + stopBackgroundWork(id: string) { + return this.background.stopItem(id); + } interrupt() { return this.turns.interrupt(); } @@ -515,9 +533,12 @@ export class OpenCodeV2Session implements AgentSession { if (event.type === "server.connected") { this.timeline.resetStreams(); await this.reconcileConnection(); + signal.throwIfAborted(); + this.background.start(this.client); connected = true; ready(); } + this.background.observe(event); await this.children.observe(event); this.observeOwnEvent(event); } @@ -533,6 +554,7 @@ export class OpenCodeV2Session implements AgentSession { "OpenCode event stream interrupted; reconciling on reconnect", ); } + this.background.disconnect(); await delay(100, undefined, { signal }).catch(() => undefined); } } @@ -573,6 +595,7 @@ export class OpenCodeV2Session implements AgentSession { this.turns.close(); this.streamAbort.abort(); if (this.refreshTimer) clearTimeout(this.refreshTimer); + this.background.disconnect(); await this.stream; await this.reconnecting?.catch(() => undefined); await this.sync.catch(() => undefined); diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index c45d9a5c32..728057e6b7 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -19,6 +19,8 @@ const INBOUND_PERMISSION = { "agent.detach.request": "workspace.write", "agent.fork_context.request": "workspace.read", "agent.provider_subagents.list.request": "workspace.read", + "agent.background_work.list.request": "workspace.read", + "agent.background_work.stop.request": ["workspace.write", "hub.execute"], "agent.provider_subagents.timeline.get.request": "workspace.read", "agent.rewind.request": "workspace.write", "agent.skills.get_status.request": "daemon.read", @@ -251,6 +253,9 @@ const OUTBOUND_PERMISSION = { "agent.provider_subagents.list.response": "workspace.read", "agent.provider_subagents.timeline.get.response": "workspace.read", "agent.provider_subagents.update": "workspace.read", + "agent.background_work.list.response": "workspace.read", + "agent.background_work.stop.response": ["workspace.write", "hub.execute"], + "agent.background_work.update": "workspace.read", "agent.rewind.response": "workspace.write", "agent.skills.get_status.response": "daemon.read", "agent.skills.import_legacy_selection.response": "daemon.manage", diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 1b9c2e973d..36e9a2e961 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -411,6 +411,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { agentManager: asAgentManager({ listAgents: vi.fn(() => []), listProviderSubagentActivity: vi.fn(() => []), + getAgentBackgroundWorkSummary: () => undefined, subscribe: vi.fn(() => () => {}), ...options.agentManager, }), @@ -6105,3 +6106,251 @@ test("a failing direct source observes fire-and-forget file replies without unha rmSync(root, { recursive: true, force: true }); } }); + +test("answers an agent background work list request", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + getAgent: vi.fn(() => ({ id: "agent-a" })), + getAgentBackgroundWorkSummary: () => ({ count: 1 }), + listAgentBackgroundWork: vi.fn(() => [ + { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", + }, + ]), + }, + }); + + await session.handleMessage({ + type: "agent.background_work.list.request", + agentId: "agent-a", + requestId: "list-1", + }); + + expect(messages).toContainEqual({ + type: "agent.background_work.list.response", + payload: { + requestId: "list-1", + agentId: "agent-a", + items: [ + { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", + }, + ], + error: null, + }, + }); +}); + +test("reports an unavailable inventory for a stored agent without loading it", async () => { + const messages: SessionOutboundMessage[] = []; + const resumeAgentFromPersistence = vi.fn(); + const createAgent = vi.fn(); + const listAgentBackgroundWork = vi.fn(() => []); + const session = createSessionForTest({ + messages, + agentManager: { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + getAgent: () => undefined, + getRegisteredProviderIds: vi.fn(() => ["claude"]), + resumeAgentFromPersistence, + createAgent, + listAgentBackgroundWork, + }, + agentStorage: { + get: vi.fn().mockResolvedValue( + createStoredAgentRecord({ + id: "agent-a", + cwd: "/tmp/repo", + provider: "claude", + persistence: { provider: "claude", sessionId: "session-a" }, + }), + ), + }, + }); + + await session.handleMessage({ + type: "agent.background_work.list.request", + agentId: "agent-a", + requestId: "list-stored", + }); + + expect(messages).toContainEqual({ + type: "agent.background_work.list.response", + payload: { + requestId: "list-stored", + agentId: "agent-a", + items: [], + error: "Background work inventory is unavailable", + }, + }); + expect(resumeAgentFromPersistence).not.toHaveBeenCalled(); + expect(createAgent).not.toHaveBeenCalled(); + expect(listAgentBackgroundWork).not.toHaveBeenCalled(); +}); + +test.each([ + { + name: "an archived agent", + record: createStoredAgentRecord({ + id: "agent-a", + cwd: "/tmp/repo", + archivedAt: "2026-10-06T10:00:00.000Z", + }), + error: "Agent is archived: agent-a", + }, + { name: "an unknown agent", record: undefined, error: "Unknown agent 'agent-a'" }, + { + name: "an internal agent", + record: createStoredAgentRecord({ id: "agent-a", cwd: "/tmp/repo", internal: true }), + error: "Unknown agent 'agent-a'", + }, +])("rejects a background work list request for $name that is not loaded", async (scenario) => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + getAgent: () => undefined, + getRegisteredProviderIds: vi.fn(() => ["codex"]), + resumeAgentFromPersistence: vi.fn(), + createAgent: vi.fn(), + }, + agentStorage: { get: vi.fn().mockResolvedValue(scenario.record) }, + }); + + await session.handleMessage({ + type: "agent.background_work.list.request", + agentId: "agent-a", + requestId: "list-rejected", + }); + + expect(messages).toContainEqual({ + type: "agent.background_work.list.response", + payload: { requestId: "list-rejected", agentId: "agent-a", items: [], error: scenario.error }, + }); +}); + +test("never pushes background work to an old app", async () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + targetedMessages, + agentManager: { + getAgent: vi.fn(() => undefined), + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + // provider_subagents makes subagent updates flow to this legacy socket, so the + // manager listener is installed for it. + const legacySocket = {}; + session.updateClientCapabilities({ provider_subagents: true }, legacySocket); + const listener = agentEventListeners[0]; + if (!listener) throw new Error("Agent event listener was not installed"); + + listener({ + type: "background_work", + agentId: "agent-a", + items: [ + { id: "bash-1", kind: "shell", description: null, startedAt: "2026-10-06T10:00:00.000Z" }, + ], + }); + + const pushed = [...messages, ...targetedMessages.map((entry) => entry.message)]; + expect(pushed.some((message) => message.type === "agent.background_work.update")).toBe(false); +}); + +test("pushes background work only to clients that subscribed to it", async () => { + const messages: SessionOutboundMessage[] = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + agentManager: { + getAgent: vi.fn(() => undefined), + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + const forward = (event: AgentManagerEvent) => { + for (const listener of agentEventListeners) listener(event); + }; + const event: AgentManagerEvent = { + type: "background_work", + agentId: "agent-a", + items: [ + { id: "bash-1", kind: "shell", description: null, startedAt: "2026-10-06T10:00:00.000Z" }, + ], + }; + + // Subscribe to an unrelated event so the manager listener is installed, then prove + // a client that did not ask for background work never receives it. + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "events-1", + events: ["agent.provider_subagents.update"], + }); + if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed"); + forward(event); + expect(messages.some((message) => message.type === "agent.background_work.update")).toBe(false); + + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "events-2", + events: ["agent.background_work.update"], + }); + messages.length = 0; + + forward(event); + + expect(messages).toContainEqual( + expect.objectContaining({ + type: "agent.background_work.update", + payload: expect.objectContaining({ agentId: "agent-a", items: event.items }), + }), + ); +}); + +test("does not publish internal agents' background work to subscribed clients", async () => { + const messages: SessionOutboundMessage[] = []; + let listener: ((event: AgentManagerEvent) => void) | undefined; + const session = createSessionForTest({ + messages, + agentManager: { + getAgent: () => ({ id: "internal-agent", internal: true }) as never, + subscribe: (callback) => { + listener = callback; + return () => {}; + }, + }, + }); + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "subscribe-internal-background", + events: ["agent.background_work.update"], + }); + messages.length = 0; + if (!listener) throw new Error("Agent event listener was not installed"); + listener({ + type: "background_work", + agentId: "internal-agent", + items: [ + { id: "work", kind: "shell", description: "private", startedAt: "2026-10-06T10:00:00.000Z" }, + ], + }); + expect(messages.some((message) => message.type === "agent.background_work.update")).toBe(false); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c652f70908..83f030d971 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -22,6 +22,7 @@ import { isAbsolute } from "node:path"; import { CreationService, type CreatedAgent } from "./creation/index.js"; import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; import type { MessageReceipts } from "./message-receipts/index.js"; +import type { AgentBackgroundWorkItem } from "./agent/background-work/index.js"; import equal from "fast-deep-equal"; import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; @@ -1771,7 +1772,8 @@ export class Session { this.wantsEvent("agent_attention_required") || this.wantsEvent("agent_permission_request") || this.wantsEvent("agent_permission_resolved") || - this.wantsEvent("agent.provider_subagents.update"); + this.wantsEvent("agent.provider_subagents.update") || + this.wantsEvent("agent.background_work.update"); if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents(); if (!agents) { this.unsubscribeAgentEvents?.(); @@ -2089,14 +2091,12 @@ export class Session { } if (event.type === "provider_subagent") { - // The subagents track of an internal parent stays hidden. - const parentAgentId = - event.event.type === "upsert" - ? event.event.subagent.parentAgentId - : event.event.parentAgentId; - if (this.agentManager.getAgent(parentAgentId)?.internal) return; - this.emitProviderSubagentWorkspaceUpdate(event.event); - this.forwardProviderSubagentUpdate(event.event); + this.forwardProviderSubagent(event.event); + return; + } + + if (event.type === "background_work") { + this.forwardBackgroundWork(event); return; } @@ -2171,6 +2171,26 @@ export class Session { ); } + private forwardProviderSubagent(event: ProviderSubagentManagerEvent): void { + const parentAgentId = + event.type === "upsert" ? event.subagent.parentAgentId : event.parentAgentId; + if (this.agentManager.getAgent(parentAgentId)?.internal) return; + this.emitProviderSubagentWorkspaceUpdate(event); + this.forwardProviderSubagentUpdate(event); + } + + private forwardBackgroundWork( + event: Extract, + ): void { + const agent = this.agentManager.getAgent(event.agentId); + if (agent?.internal) return; + this.emit({ + type: "agent.background_work.update", + payload: { agentId: event.agentId, items: event.items }, + }); + if (agent) void this.agentUpdates.forwardLiveAgent(agent); + } + private emitProviderSubagentWorkspaceUpdate(event: ProviderSubagentManagerEvent): void { if (event.type === "timeline") { return; @@ -2203,6 +2223,7 @@ export class Session { } private async enrichAgentPayload(payload: AgentSnapshotPayload): Promise { + payload.backgroundWork = this.agentManager.getAgentBackgroundWorkSummary(payload.id); const storedRecord = await this.agentStorage.get(payload.id); payload.title = storedRecord?.title ?? null; payload.archivedAt = storedRecord?.archivedAt ?? null; @@ -2967,6 +2988,10 @@ export class Session { return this.handleAgentTimelineListPromptsRequest(msg, source); case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); + case "agent.background_work.list.request": + return this.handleAgentBackgroundWorkListRequest(msg); + case "agent.background_work.stop.request": + return this.handleAgentBackgroundWorkStopRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg, source); case "session.events.set_subscription.request": { @@ -5858,6 +5883,7 @@ export class Session { pendingPermissionCount: agent.pendingPermissions?.length ?? 0, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason ?? null, + backgroundWork: agent.backgroundWork, }); case "created_at": return Date.parse(agent.createdAt); @@ -8468,6 +8494,75 @@ export class Session { } } + private async handleAgentBackgroundWorkStopRequest( + msg: Extract, + ): Promise { + let error: string | null = null; + try { + await this.agentManager.stopAgentBackgroundWork({ + agentId: msg.agentId, + itemId: msg.itemId, + stopToken: msg.stopToken, + }); + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + this.emit({ + type: "agent.background_work.stop.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + itemId: msg.itemId, + error, + }, + }); + } + + private async handleAgentBackgroundWorkListRequest( + msg: Extract, + ): Promise { + try { + this.emit({ + type: "agent.background_work.list.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + items: await this.readAgentBackgroundWork(msg.agentId), + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.background_work.list.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + items: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + // Background work lives only in memory, so an agent that is not loaded has none. + // Answering from storage keeps a read from resuming a dormant agent. + private async readAgentBackgroundWork(agentId: string): Promise { + if (this.agentManager.getAgent(agentId)) { + if (!this.agentManager.getAgentBackgroundWorkSummary(agentId)) { + throw new Error("Background work inventory is unavailable"); + } + return this.agentManager.listAgentBackgroundWork(agentId); + } + const record = await this.agentStorage.get(agentId); + if (!record || record.internal) { + throw new Error(`Unknown agent '${agentId}'`); + } + if (record.archivedAt) { + throw new Error(`Agent is archived: ${agentId}`); + } + throw new Error("Background work inventory is unavailable"); + } + private async handleProviderSubagentTimelineRequest( msg: Extract, source?: object, @@ -9189,6 +9284,7 @@ function sessionEventCategory(message: SessionOutboundMessage): SessionEventSubs case "script_status_update": case "workspace_setup_progress": case "agent.provider_subagents.update": + case "agent.background_work.update": case "terminal_attention_required": case "activity_log": case "hub.execution.agent.update": @@ -9227,6 +9323,9 @@ function legacyWantsEvent( return !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions); case "agent.provider_subagents.update": return capabilities.has(CLIENT_CAPS.providerSubagents); + // Old apps parse outbound messages strictly and do not know this type. + case "agent.background_work.update": + return false; default: return true; } diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index da61f3495c..89b7fcbcb9 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -37,6 +37,7 @@ export function asAgentManager(stub: { return createStub({ listProviderSubagentActivity: () => [], getRetiredInternalAgent: () => null, + getAgentBackgroundWorkSummary: () => undefined, ...stub, }); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 74188b3d7a..1bbc171f4a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2149,6 +2149,10 @@ export class VoiceAssistantWebSocketServer { agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + agentBackgroundWork: true, + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + agentBackgroundWorkStop: true, // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. projectedSubagentTimeline: true, wholeTurnTimeline: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 9e563ec40b..a0de895810 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -114,6 +114,10 @@ class InMemoryAgentManager { }; } + getAgentBackgroundWorkSummary() { + return undefined; + } + fetchTimeline(_agentId: string, options?: AgentTimelineFetchOptions) { return this.timeline.fetch("agent-1", options); } diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 85e8aeb600..5e1cb88dc7 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -273,6 +273,7 @@ interface AgentState { pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; + backgroundWork?: AgentSnapshotPayload["backgroundWork"]; background?: boolean; } @@ -293,6 +294,7 @@ function createAgent( updatedAt: NOW, lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status, + backgroundWork: input.backgroundWork, capabilities: { supportsStreaming: true, supportsSessionPersistence: true, @@ -324,6 +326,17 @@ function createAgent( } describe("WorkspaceDirectory", () => { + test("treats background work as running without changing foreground status", async () => { + const workspace = new WorkspaceStatus(); + workspace.hasRootAgent({ + id: "root-agent", + status: "idle", + backgroundWork: { count: 1 }, + requiresAttention: true, + attentionReason: "finished", + }); + await expect(workspace.workspaceStatus()).resolves.toBe("running"); + }); test("uses root agent activity, not delegated child activity, for workspace status", async () => { const workspace = new WorkspaceStatus(); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 64f544e6f8..ebab30916f 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -392,7 +392,7 @@ export class WorkspaceDirectory { continue; } const isWorkspaceRoot = workspaceAgent.id === agent.id; - if (!isWorkspaceRoot && agent.status !== "running") { + if (!isWorkspaceRoot && agent.status !== "running" && !agent.backgroundWork?.count) { continue; } const bucket = isWorkspaceRoot @@ -401,6 +401,7 @@ export class WorkspaceDirectory { pendingPermissionCount: agent.pendingPermissions?.length ?? 0, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason ?? null, + backgroundWork: agent.backgroundWork, }) : "running"; @@ -550,6 +551,7 @@ export class WorkspaceDirectory { pendingPermissionCount: agent.pendingPermissions?.length ?? 0, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason ?? null, + backgroundWork: agent.backgroundWork, }); return derived === winningBucket; }) diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index ccb152aa23..d91eae6f06 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -93,6 +93,10 @@ function createLogger(): pino.Logger { } as unknown as pino.Logger; } +function decodedTerminalFrames(frames: TerminalStreamFrame[]): string { + return frames.map((frame) => new TextDecoder().decode(frame.payload)).join(""); +} + describe("terminal-session-controller restore", () => { test("delivers output produced while restore is in flight after the restore frame", async () => { let terminalListener: ((message: ServerMessage) => void) | null = null; @@ -172,7 +176,10 @@ describe("terminal-session-controller restore", () => { terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 }); snapshot.resolve(terminalReplay("restore-before", 1, "\x1b[?1h\x1b[?2004h")); await snapshot.promise; - await new Promise((resolve) => setTimeout(resolve, 20)); + await vi.waitFor(() => { + const output = decodedTerminalFrames(binaryFrames.slice(1)); + expect(output).toBe("\x1b[?1h\x1b[?2004hrestore-after\n"); + }); expect(outboundMessages).toContainEqual({ type: "subscribe_terminal_response", @@ -183,14 +190,12 @@ describe("terminal-session-controller restore", () => { requestId: "req-1", }, }); - expect(binaryFrames.map((frame) => frame.opcode)).toEqual([ - TerminalStreamOpcode.Restore, - TerminalStreamOpcode.Output, - ]); + expect(binaryFrames[0]?.opcode).toBe(TerminalStreamOpcode.Restore); + const outputFrames = binaryFrames.slice(1); + expect(outputFrames.length).toBeGreaterThan(0); + expect(outputFrames.every((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true); expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before"); - expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe( - "\x1b[?1h\x1b[?2004hrestore-after\n", - ); + expect(decodedTerminalFrames(outputFrames)).toBe("\x1b[?1h\x1b[?2004hrestore-after\n"); }); }); diff --git a/packages/website/e2e/agent-icons.spec.ts b/packages/website/e2e/agent-icons.spec.ts index 4bc828475e..27418a7c48 100644 --- a/packages/website/e2e/agent-icons.spec.ts +++ b/packages/website/e2e/agent-icons.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page } from "./fixtures"; test("keeps unrelated agent artwork unchanged while adding the Hermes mark", async ({ page }) => { await openSupportedAgents(page); diff --git a/packages/website/e2e/download-thanks.spec.ts b/packages/website/e2e/download-thanks.spec.ts index bc33f8f087..dbbfe69a86 100644 --- a/packages/website/e2e/download-thanks.spec.ts +++ b/packages/website/e2e/download-thanks.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page } from "./fixtures"; const APPLE_SILICON_DMG = /\/getpaseo\/paseo\/releases\/download\/v[^/]+\/Paseo-[^/]+-arm64\.dmg$/; diff --git a/packages/website/e2e/fixtures.ts b/packages/website/e2e/fixtures.ts new file mode 100644 index 0000000000..a0d0ed2e8f --- /dev/null +++ b/packages/website/e2e/fixtures.ts @@ -0,0 +1,18 @@ +import { test as base } from "playwright/test"; + +export { expect } from "playwright/test"; +export type { Locator, Page } from "playwright/test"; + +export const test = base.extend({ + page: async ({ page, baseURL }, run) => { + page.on("response", async (response) => { + if (response.status() < 500 || new URL(response.url()).origin !== new URL(baseURL!).origin) + return; + const body = await response.text().catch(() => ""); + console.error( + `Website HTTP ${response.status()} ${response.url()}\n${JSON.stringify(body.slice(0, 4000))}`, + ); + }); + await run(page); + }, +}); diff --git a/packages/website/e2e/i18n.spec.ts b/packages/website/e2e/i18n.spec.ts index d235c4209e..02d17e92cb 100644 --- a/packages/website/e2e/i18n.spec.ts +++ b/packages/website/e2e/i18n.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page } from "./fixtures"; function languageButton(page: Page) { return page.getByRole("button", { name: /^(Language|语言)/ }).first(); diff --git a/packages/website/e2e/mockups.spec.ts b/packages/website/e2e/mockups.spec.ts index 41570439bf..82a440fa1c 100644 --- a/packages/website/e2e/mockups.spec.ts +++ b/packages/website/e2e/mockups.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from "playwright/test"; +import { expect, test, type Locator, type Page } from "./fixtures"; const desktopName = "Paseo desktop app with coding agents, a conversation, and a code diff open side by side"; diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 22baf9e7f1..27d4a55c4a 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,5 +1,5 @@ import securityPlugin from "./overview.fixture.json" with { type: "json" }; -import { expect, test, type Page, type Locator } from "playwright/test"; +import { expect, test, type Page, type Locator } from "./fixtures"; import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; import copy from "../src/i18n/resources/en.json" with { type: "json" }; diff --git a/packages/website/e2e/seed-cache.mjs b/packages/website/e2e/seed-cache.mjs new file mode 100644 index 0000000000..67990be289 --- /dev/null +++ b/packages/website/e2e/seed-cache.mjs @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const state = process.env.WEBSITE_E2E_PERSIST_PATH; +if (!state) throw new Error("WEBSITE_E2E_PERSIST_PATH is required"); +// Keep these local test records fresh for a full test run, even after the normal cache TTL. +const fixtureTimestamp = Date.now() + 24 * 60 * 60 * 1000; +const entries = [ + { key: "github-stars:v1", value: "12.3k" }, + { + key: "github-release:v2", + value: { + stable: { + version: "0.15.0", + linuxAppImageAsset: "Paseo-x86_64.AppImage", + windowsX64Asset: "Paseo-Setup-0.15.0-x64.exe", + windowsArm64Asset: "Paseo-Setup-0.15.0-arm64.exe", + }, + beta: null, + }, + }, +]; +mkdirSync(state, { recursive: true }); +const file = path.join(state, "fixtures.json"); +writeFileSync( + file, + JSON.stringify( + entries.map(({ key, value }) => ({ + key, + value: JSON.stringify({ fetchedAt: fixtureTimestamp, value }), + })), + ), +); +execFileSync( + process.execPath, + [ + path.join( + path.dirname(createRequire(import.meta.url).resolve("wrangler/package.json")), + "bin/wrangler.js", + ), + "kv", + "bulk", + "put", + file, + "--binding", + "WEBSITE_CACHE", + "--local", + "--persist-to", + state, + ], + { stdio: "inherit" }, +); diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts index 7b42316d54..4aa15ec413 100644 --- a/packages/website/e2e/sponsors.spec.ts +++ b/packages/website/e2e/sponsors.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "playwright/test"; +import { expect, test } from "./fixtures"; const PAGES = ["/", "/docs/connectivity", "/changelog"]; diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts index ebb29f0237..49e058ec32 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { defineConfig, devices } from "playwright/test"; const baseURL = process.env.WEBSITE_TEST_URL ?? "http://127.0.0.1:8187"; @@ -18,11 +19,16 @@ export default defineConfig({ url: "http://127.0.0.1:8188/index.json", }, { - command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", + command: + "node e2e/seed-cache.mjs && npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", // Probe the server without running the homepage loader's live GitHub requests. url: new URL("/logo.svg", baseURL).href, stdout: "pipe", env: { + WEBSITE_E2E_PERSIST_PATH: path.resolve( + import.meta.dirname, + "../../.artifacts/website-e2e-cache", + ), CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188", }, diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index 778378167b..fcdca7e610 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -129,7 +129,12 @@ export default defineConfig((): UserConfig => { }, }, plugins: [ - cloudflare({ viteEnvironment: { name: "ssr" } }), + cloudflare({ + viteEnvironment: { name: "ssr" }, + ...(process.env.WEBSITE_E2E_PERSIST_PATH + ? { persistState: { path: process.env.WEBSITE_E2E_PERSIST_PATH } } + : {}), + }), tsConfigPaths({ projects: [path.join(__dirname, "tsconfig.json")] }), tanstackStart({ router: { diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index 9762323dce..81f077a9e1 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -42,6 +42,25 @@ await directory.subscription.release(); `client.agents.subscribe()` and agent-handle `subscribe()` add local listeners to observations owned by that API instance. They do not request data. Use the returned subscription's callbacks when multiple filtered views need separate updates. +## Follow an agent's background work + +Background work is what an agent left running after its tool call returned: a Claude shell started with `run_in_background`, a `Monitor` watch, or another task that is not a subagent. Subagents and workflows stay in the agent's subagents. + +```ts +const agent = client.agents.ref(agentId); + +const unsubscribe = agent.backgroundWork.subscribe((items) => render(items)); + +// When this view closes: +unsubscribe(); +``` + +`subscribe()` calls the listener with the current list once the subscription is established, again after each reconnect, then with the whole list on every change. Use `list()` for a one-shot read. Each item has `id`, `kind` (`"shell"`, `"monitor"`, or `"other"`; treat unknown values as `"other"`), `description` (or `null`), optional `command`, and `startedAt`, the time Paseo first observed it. Inventory is scoped to the current provider session and is not persisted. When its runtime is lost, its old items are discarded. An unavailable inventory is reported as an error rather than an empty list. + +Pass `subscribe(listener, { onError })` to receive setup or list errors. After a setup failure, create a new subscription; after a list failure, the list is read again on the next reconnect. Without `onError`, the error is logged. + +`list()` rejects and `subscribe()` throws when the host does not advertise `features.agentBackgroundWork`; ask the user to update the host. For an item with `canStop`, call `backgroundWork.stop(item)` to wait for provider confirmation. Stop requires `features.agentBackgroundWorkStop`. + ## Follow timeline events ```ts diff --git a/public-docs/sdk/events.zh-CN.md b/public-docs/sdk/events.zh-CN.md index 190d4c9a48..69e8e8c5ad 100644 --- a/public-docs/sdk/events.zh-CN.md +++ b/public-docs/sdk/events.zh-CN.md @@ -40,6 +40,25 @@ await directory.subscription.release(); `client.agents.subscribe()` 和 Agent 句柄上的 `subscribe()` 会为该 API 实例持有的观察添加本地监听器。它们不会请求数据。当多个带过滤条件的视图需要各自的更新时,请使用返回的订阅上的回调。 +## 跟踪 Agent 的后台工作 {#follow-an-agents-background-work} + +后台工作是 Agent 的工具调用返回后仍在运行的工作,例如使用 `run_in_background` 启动的 Claude shell 或 `Monitor` 监控。子 Agent 和 workflow 显示在子 Agent 中。 + +```ts +const agent = client.agents.ref(agentId); + +const unsubscribe = agent.backgroundWork.subscribe((items) => render(items)); + +// When this view closes: +unsubscribe(); +``` + +订阅建立后,`subscribe()` 会向监听器投递当前列表;每次重连后和每次变化时,也会投递完整列表。用 `list()` 执行单次读取。每项包含 `id`、`kind`(`"shell"`、`"monitor"` 或 `"other"`,未知值按 `"other"` 处理)、可为 `null` 的 `description`、可选的 `command`,以及 Paseo 首次观察到该项的时间 `startedAt`。列表属于当前 Provider 会话,不会持久化。运行时丢失后,旧项会被清除。列表不可用时会报告错误,而不是返回空列表。 + +传入 `subscribe(listener, { onError })` 可接收读取当前列表失败的错误;此时不会调用监听器,下次重连时会重新读取。未提供 `onError` 时会记录错误。 + +主机未声明 `features.agentBackgroundWork` 时,`list()` 会 reject,`subscribe()` 会抛出错误;请让用户更新主机。对具有 `canStop` 的项目调用 `backgroundWork.stop(item)`,等待提供方确认。停止操作需要 `features.agentBackgroundWorkStop`。 + ## 跟踪时间线事件 {#follow-timeline-events} ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 6919cc5aa0..5a095b2c2a 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -80,32 +80,35 @@ workspace the call creates is background too. With a `parent` and no value, the ### Agent handle -| Member | Result | Behavior | -| ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Stable daemon agent ID. | -| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. | -| `workspaceId` | `string \| null` | Current workspace placement. | -| `cwd` | `string \| null` | Current working directory. | -| `status` | Agent status or `null` | Current lifecycle status. | -| `capabilities` | Capability flags or `null` | What the provider session supports. | -| `availableModes` | Agent modes or `null` | Modes the session can switch to. | -| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | -| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | -| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | -| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | -| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | -| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | -| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | -| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | -| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | -| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | -| `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | -| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | -| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | -| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | -| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | -| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | +| Member | Result | Behavior | +| ---------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Stable daemon agent ID. | +| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. | +| `workspaceId` | `string \| null` | Current workspace placement. | +| `cwd` | `string \| null` | Current working directory. | +| `status` | Agent status or `null` | Current lifecycle status. | +| `capabilities` | Capability flags or `null` | What the provider session supports. | +| `availableModes` | Agent modes or `null` | Modes the session can switch to. | +| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | +| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | +| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | +| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | +| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | +| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | +| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | +| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | +| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | +| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | +| `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | +| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | +| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | +| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | +| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | +| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | +| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | +| `backgroundWork.list(options?)` | `Promise` | Reads the agent's current background work once. Requires `features.agentBackgroundWork`. Options: `requestId`, `timeout`. | +| `backgroundWork.stop(item, options?)` | `Promise` | Stops a stoppable item and waits for provider confirmation. Pass the item returned by `list` or `subscribe`. Requires `features.agentBackgroundWorkStop`. | +| `backgroundWork.subscribe(listener, options?)` | `() => void` | Calls `listener` with the current list, again after each reconnect, then with the full list on every change. `onError` receives a failed list read. Requires `features.agentBackgroundWork`. Returns the unsubscribe function. | `workspaceId` through `archivedAt` mirror the last snapshot the handle observed. A handle from `ref()` reads `null` for all of them until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Call `current()` when you need the whole snapshot or need to distinguish those states. diff --git a/public-docs/sdk/reference.zh-CN.md b/public-docs/sdk/reference.zh-CN.md index c7848240c4..5879e9cab9 100644 --- a/public-docs/sdk/reference.zh-CN.md +++ b/public-docs/sdk/reference.zh-CN.md @@ -76,32 +76,35 @@ nav: API 参考 ### Agent 句柄 {#agent-handle} -| 成员 | 结果 | 行为 | -| ------------------------------ | --------------------------------- | ----------------------------------------------------------------------- | -| `id` | `string` | 稳定的 daemon Agent ID。 | -| `address` | `AgentAddress` | 完整的 `{ serverId, agentId }` 身份;需要连接到主机才能确定。 | -| `workspaceId` | `string \| null` | 当前所在的工作区。 | -| `cwd` | `string \| null` | 当前工作目录。 | -| `status` | Agent 状态或 `null` | 当前的生命周期状态。 | -| `capabilities` | 能力标志或 `null` | Provider 会话支持的能力。 | -| `availableModes` | Agent 模式或 `null` | 会话可以切换到的模式。 | -| `pendingPermissions` | 权限请求或 `null` | 等待回应的请求。 | -| `activeTurn` | 当前轮次或 `null` | 正在进行的轮次,包含 `turnId` 和 `startedAt`。 | -| `lastUsage` | 用量或 `null` | 上一轮的 token 数、费用和上下文窗口用量。 | -| `lastError` | `string \| null` | daemon 为该 Agent 记录的最后一个错误。 | -| `features` | Agent 功能或 `null` | Provider 的功能开关和选择项及其当前值。 | -| `runtimeInfo` | 运行时信息或 `null` | 实时的 Provider、会话 ID、模型、思考选项和模式。 | -| `archivedAt` | `string \| null` | 归档时间戳;Agent 处于活动状态时为 `null`。 | -| `current()` | `PaseoAgent \| null` | 此句柄观察到的当前详细值;从不发起请求。 | -| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | 获取当前的 Agent 及其所在项目。 | -| `send(text, options?)` | `Promise` | 在 daemon 接受提示词后 resolve。 | -| `respondToPermission(options)` | `Promise` | 按 `requestId` 以允许或拒绝的 `response` 回应一个待处理的权限请求。 | -| `run(text, options?)` | `PaseoAgentRunResult` | 发送提示词并等待该轮结束。`timeoutMs` 控制等待时间,默认 10 分钟。 | -| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | 等待当前轮次结束,包括初始提示词。默认超时:10 分钟。 | -| `commands(options?)` | `PaseoAgentCommandsResult` | 向正在运行的会话查询它的斜杠命令和技能,包括内置的。选项:`requestId`。 | -| `subscribe(handler)` | 取消订阅函数 | 把 Agent 目录更新过滤到此 ID,并刷新句柄的属性。 | -| `archive()` | `{ archivedAt }` | 软删除 Agent 并关闭它的运行时。 | -| `detach()` | `Promise` | 解除父子关系,但不停止 Agent。 | +| 成员 | 结果 | 行为 | +| ---------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | 稳定的 daemon Agent ID。 | +| `address` | `AgentAddress` | 完整的 `{ serverId, agentId }` 身份;需要连接到主机才能确定。 | +| `workspaceId` | `string \| null` | 当前所在的工作区。 | +| `cwd` | `string \| null` | 当前工作目录。 | +| `status` | Agent 状态或 `null` | 当前的生命周期状态。 | +| `capabilities` | 能力标志或 `null` | Provider 会话支持的能力。 | +| `availableModes` | Agent 模式或 `null` | 会话可以切换到的模式。 | +| `pendingPermissions` | 权限请求或 `null` | 等待回应的请求。 | +| `activeTurn` | 当前轮次或 `null` | 正在进行的轮次,包含 `turnId` 和 `startedAt`。 | +| `lastUsage` | 用量或 `null` | 上一轮的 token 数、费用和上下文窗口用量。 | +| `lastError` | `string \| null` | daemon 为该 Agent 记录的最后一个错误。 | +| `features` | Agent 功能或 `null` | Provider 的功能开关和选择项及其当前值。 | +| `runtimeInfo` | 运行时信息或 `null` | 实时的 Provider、会话 ID、模型、思考选项和模式。 | +| `archivedAt` | `string \| null` | 归档时间戳;Agent 处于活动状态时为 `null`。 | +| `current()` | `PaseoAgent \| null` | 此句柄观察到的当前详细值;从不发起请求。 | +| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | 获取当前的 Agent 及其所在项目。 | +| `send(text, options?)` | `Promise` | 在 daemon 接受提示词后 resolve。 | +| `respondToPermission(options)` | `Promise` | 按 `requestId` 以允许或拒绝的 `response` 回应一个待处理的权限请求。 | +| `run(text, options?)` | `PaseoAgentRunResult` | 发送提示词并等待该轮结束。`timeoutMs` 控制等待时间,默认 10 分钟。 | +| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | 等待当前轮次结束,包括初始提示词。默认超时:10 分钟。 | +| `commands(options?)` | `PaseoAgentCommandsResult` | 向正在运行的会话查询它的斜杠命令和技能,包括内置的。选项:`requestId`。 | +| `subscribe(handler)` | 取消订阅函数 | 把 Agent 目录更新过滤到此 ID,并刷新句柄的属性。 | +| `archive()` | `{ archivedAt }` | 软删除 Agent 并关闭它的运行时。 | +| `detach()` | `Promise` | 解除父子关系,但不停止 Agent。 | +| `backgroundWork.list(options?)` | `Promise` | 单次读取 Agent 当前的后台工作。需要 `features.agentBackgroundWork`;选项为 `requestId` 和 `timeout`。 | +| `backgroundWork.stop(item, options?)` | `Promise` | 停止可停止的项目并等待提供方确认。传入 `list` 或 `subscribe` 返回的项目。需要 `features.agentBackgroundWorkStop`。 | +| `backgroundWork.subscribe(listener, options?)` | `() => void` | 向监听器投递当前列表,并在每次重连和变化时投递完整列表。`onError` 接收读取失败的错误;需要 `features.agentBackgroundWork`。返回取消订阅函数。 | 从 `workspaceId` 到 `archivedAt` 的属性反映句柄最近观察到的快照。由 `ref()` 得到的句柄在 `refresh()`、`run()`、`waitForFinish()`、时间线重新获取或 `subscribe()` 投递快照之前,这些属性都读作 `null`。已观察到的快照中的可选值也读作 `null`。当你需要整个快照,或需要区分这些状态时,请调用 `current()`。 From dc51a3b6a9e2bec35f5c250c76181e4446c02fc2 Mon Sep 17 00:00:00 2001 From: geoqiao <105639506+geoqiao@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:12:44 +0800 Subject: [PATCH 11/32] fix(app): preserve plugin-rendered replies when collapsing turns (#8811) * fix(app): preserve plugin-rendered replies when collapsing turns * test(app): exercise plugin reply folding in the browser --- .../app/e2e/browser/turn-collapse.spec.ts | 75 ++++++++++++++-- .../e2e/support/helpers/plugin-timeline.ts | 2 +- .../app/src/agent-stream/presentation.test.ts | 86 +++++++++++++++++++ .../agent-stream/turn-collapse/model.test.ts | 53 ++++++++++++ .../src/agent-stream/turn-collapse/model.ts | 24 +++++- .../src/plugins/timeline/projection.test.ts | 2 + .../app/src/plugins/timeline/projection.ts | 1 + packages/app/src/types/stream.ts | 2 + 8 files changed, 234 insertions(+), 11 deletions(-) diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index cd899d1ee8..a7138e0850 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -9,6 +9,7 @@ import { getActiveTabTestId } from "../support/helpers/launcher"; import type { Page, Locator } from "@playwright/test"; import { test, expect } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; +import { withDirectoryPlugin } from "../support/helpers/plugin-timeline"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; test.use({ @@ -50,15 +51,16 @@ async function seedAnsweredAgent(title: string) { } /** Chat rows between the latest turn's summary line and its reply, in document order. */ -async function rowsBetweenLatestSummaryAndReply(page: Page): Promise { - return page.evaluate(() => { +async function rowsBetweenLatestSummaryAndReply( + page: Page, + replyTestId = "assistant-message", +): Promise { + return page.evaluate((testId) => { const rows = [...document.querySelectorAll("[data-message-id]")]; const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]")); - const replyRow = rows.findLastIndex((row) => - row.querySelector("[data-testid=assistant-message]"), - ); + const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`)); return replyRow - summaryRow - 1; - }); + }, replyTestId); } async function groupedToolsInsideLatestTurn(page: Page): Promise { @@ -126,6 +128,67 @@ async function expandAndCollapseLongPrompt(page: Page) { } test.describe("Turn collapse", () => { + test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "plugin-reply-fold-", + title: "Plugin reply folding", + model: "ten-second-stream", + initialPrompt: "Make the fix and run the burst script.", + }); + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await withDirectoryPlugin({ + pluginId: "reply-fold-e2e", + source: `import React from "react"; +import { Text } from "react-native"; +import { z } from "zod"; + +export default function contribute(client) { + client.addTimelineTransformer({ + id: "reply", query: { itemType: "assistant_message" }, + transform({ item, phase }) { + if (phase === "streaming") return undefined; + return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] }; + }, + }); + client.addTimelineRenderer({ + kind: "reply", version: 1, schema: z.object({ text: z.string() }), + Component: ({ item }) => {item.data.text}, + }); + return () => {}; +}`, + run: async () => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 1000 }); + const summary = page.getByTestId("turn-summary").last().getByRole("button"); + const reply = page.getByTestId("plugin-reply").last(); + await expect(summary).toBeVisible(); + await expect(reply).toContainText("(end of synthetic stream)"); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0); + await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) }); + await summary.click(); + await expect + .poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")) + .toBeGreaterThan(0); + await expect(reply).toContainText("(end of synthetic stream)"); + await summary.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0); + } + await page.reload(); + await expect(page.getByTestId("turn-summary").last()).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0); + }, + }); + } finally { + await agent.cleanup(); + } + }); + test("folds each answered turn behind a summary line that opens it", async ({ page }) => { test.setTimeout(120_000); await page.setViewportSize({ width: 1280, height: 1000 }); diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index 98ab7d303b..d321aaa236 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -134,7 +134,7 @@ export default function contribute(client) { }`; } -async function withDirectoryPlugin(options: { +export async function withDirectoryPlugin(options: { pluginId: string; source: string; run: () => Promise; diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 1d4416c359..9f8213d716 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -593,6 +593,92 @@ describe("timeline presentation", () => { }); }); +it("folds work before a plugin-rendered reply and leaves the reply visible", () => { + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Calculate", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp }, + ]; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")), + tail, + head: [], + display: "collapseTurns", + isTurnActive: false, + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]); + expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]); + expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]); +}); + +it("keeps streaming replies native, then folds work without losing manual expansion", () => { + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const work: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Calculate", timestamp }, + toolCall("one", "Bash"), + ]; + const reply: StreamItem = { + kind: "assistant_message", + id: "reply", + text: "$$x^2$$", + timestamp, + }; + const input = { + ...presentationOptions, + transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")), + display: "collapseTurns" as const, + }; + const present = createStreamPresentation(); + const live = present({ ...input, tail: work, head: [reply], isTurnActive: true }); + expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]); + const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false }; + expect(present(completed).tail.map((item) => item.id)).toEqual([ + "prompt", + "probe-plugin/reply/0", + ]); + const expandedTurnHostIds = new Set(["prompt"]); + const opened = present({ ...completed, expandedTurnHostIds }); + expect(opened.tail.map((item) => item.id)).toEqual([ + "prompt", + "tool-one", + "probe-plugin/reply/0", + ]); + expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false); + expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail); + expect(present(completed).tail.map((item) => item.id)).toEqual([ + "prompt", + "probe-plugin/reply/0", + ]); +}); + +it.each(["full", "collapseToolCalls"] as const)( + "leaves plugin replies and individual work visible in %s", + (display) => { + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Calculate", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp }, + ]; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: installedTransform(installProbe("assistant_message")), + tail, + head: [], + display, + isTurnActive: false, + }); + expect(result.tail.map((item) => item.id)).toEqual([ + "prompt", + "tool-one", + "probe-plugin/reply/0", + ]); + expect(result.turns.turnsByHostId.size).toBe(0); + }, +); + it("opening a turn reveals individual calls rather than another tool group", () => { const present = createStreamPresentation(); const timestamp = new Date(); diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index d46085f95d..4fab619628 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -12,6 +12,21 @@ function reply(id: string): StreamItem { return { kind: "assistant_message", id, text: id, timestamp: at }; } +function pluginReply(sourceId: string, part = "0"): StreamItem { + return { + kind: "plugin", + id: `math/${sourceId}/${part}`, + sourceId, + sourceKind: "assistant_message", + timestamp: at, + pluginId: "math", + pluginItemId: `${sourceId}/${part}`, + itemKind: "formula", + version: 1, + data: { text: "$$x^2$$" }, + }; +} + function thought(id: string): StreamItem { return { kind: "thought", id, text: id, timestamp: at, status: "ready" }; } @@ -49,6 +64,44 @@ function collapse(tail: StreamItem[], options: Partial = {}) const ids = (items: StreamItem[]) => items.map((item) => item.id); describe("collapseTurns", () => { + it("keeps every card of the final reply outside the folded turn", () => { + const items = [ + user("prompt"), + reply("acknowledgment"), + shell("call"), + pluginReply("final", "left"), + pluginReply("final", "right"), + ]; + const result = collapse(items); + expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]); + expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt"); + expect(result.hostIdByFoldedMessageId.has("final")).toBe(false); + const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) }); + expect(expanded.tail).toEqual(items); + expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]); + }); + + it("folds intermediate plugin replies even without tool calls", () => { + const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]); + expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]); + expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt"); + }); + + it.each([undefined, "thought", "tool_call"] as const)( + "does not treat a plugin row with source kind %s as a reply", + (sourceKind) => { + const card = { ...pluginReply("card"), sourceKind }; + const items = [user("prompt"), shell("call"), card]; + expect(collapse(items).tail).toEqual(items); + expect(collapse(items).turnsByHostId.size).toBe(0); + }, + ); + + it("keeps a plugin-rendered reply and its work unfolded while the turn is open", () => { + const items = [user("prompt"), shell("call"), pluginReply("final")]; + expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items); + }); + // A history row renders again only when its item changes, so a prompt that is already retained // when its turn closes needs its row revised to show the summary line. it("reports the hosts whose summary line appeared, changed or went away", () => { diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index 167a7cc219..0821c74401 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -100,9 +100,25 @@ function staysVisibleWhenCollapsed(item: StreamItem): boolean { return isTurnTrigger(item) || item.kind === "compaction"; } -function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { +function isAssistantReply(item: StreamItem): boolean { + return ( + item.kind === "assistant_message" || + (item.kind === "plugin" && item.sourceKind === "assistant_message") + ); +} + +function findLastReplyIndex(tail: StreamItem[], start: number, end: number): number { for (let index = end - 1; index >= start; index -= 1) { - if (tail[index]?.kind === "assistant_message") return index; + const item = tail[index]!; + if (!isAssistantReply(item)) continue; + const messageId = getStreamItemMessageId(item); + // A transformer can split one reply into several cards; keep the whole reply visible. + while (index > start) { + const previous = tail[index - 1]!; + if (!isAssistantReply(previous) || getStreamItemMessageId(previous) !== messageId) break; + index -= 1; + } + return index; } return -1; } @@ -125,7 +141,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const flushRun = (runEnd: number) => { const host = tail[runStart - 1]; const isClosed = runEnd < tail.length || !input.isLatestTurnOpen; - const replyIndex = findLastAssistantIndex(tail, runStart, runEnd); + const replyIndex = findLastReplyIndex(tail, runStart, runEnd); if ( !host || (host.kind !== "user_message" && host.kind !== "tool_call") || @@ -137,7 +153,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { } const folded = tail.slice(runStart, replyIndex); const summary = summarizeTurn(folded, input.getGroupedCalls); - if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) { + if (!hasWork(summary) && !folded.some(isAssistantReply)) { output.push(...tail.slice(runStart, runEnd)); return; } diff --git a/packages/app/src/plugins/timeline/projection.test.ts b/packages/app/src/plugins/timeline/projection.test.ts index fc6e0bfa19..58d36ad87c 100644 --- a/packages/app/src/plugins/timeline/projection.test.ts +++ b/packages/app/src/plugins/timeline/projection.test.ts @@ -98,6 +98,8 @@ describe("plugin timeline projection", () => { { kind: "plugin", id: "inline-thinking/thought-1/0", + sourceId: "thought-1", + sourceKind: "thought", data: { text: "First", phase: "streaming" }, }, ]); diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 71baf86174..f7bea7e192 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -106,6 +106,7 @@ function transformSourceItem( kind: "plugin", id: `${pluginItem.pluginId}/${pluginItem.id}`, sourceId: item.id, + sourceKind: item.kind, timestamp: item.timestamp, pluginId: pluginItem.pluginId, pluginItemId: pluginItem.id, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 0226993a8c..623deb0ac5 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -812,6 +812,8 @@ export interface PluginTimelineStreamItem { id: string; /** The id of the stream item a timeline transformer replaced; set only by projection. */ sourceId?: string; + /** The original row kind, retained by projection for turn folding. */ + sourceKind?: StreamItem["kind"]; timelineCursor?: TimelinePosition; turnId?: string; timestamp: Date; From ae157dbfb608b22082b4f7b1e1c5b4884c2e89b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 20:20:11 +0700 Subject: [PATCH 12/32] Even out chat footer spacing and show the Changes base only on worktrees (#8934) * fix(app): space the turn footer like the rest of the timeline The footer sat 13px under the last line of text while the text sat about 21px under a tool or thinking row, so the footer looked glued to the message. The message drops its bottom padding when a footer follows, so the footer's top margin alone sets that gap; 20 makes the centre-to-centre distance match the tool-to-text gap. * fix(app): show the Changes base only on Paseo worktrees The header rendered "branch -> base" whenever checkout status carried a baseRef, and the daemon sends one for every git checkout, so plain checkouts showed "-> main" with no way to change it. Only Paseo worktrees store a base, so only they show it. The arrow is extra muted so it reads as a separator, and the base no longer shrinks, so a long branch name truncates instead of the base. * fix(app): let a long Changes base truncate instead of overflowing --- packages/app/e2e/browser/changes-pane.spec.ts | 9 +++++++++ packages/app/src/agent-stream/turn-footer.tsx | 6 +++++- packages/app/src/git/diff-pane.tsx | 5 ++--- .../app/src/git/workspace-base-branch-picker.tsx | 15 +++++---------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index a233567b5f..7137c903ff 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -427,6 +427,15 @@ test("Changes shows the base as text on a host without the base-branch capabilit expect(baseRequests).toBe(0); }); +test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => { + const workspace = await createWorkspaceWithCommittedDiff(); + await openWorkspaceChangesSurface(page, workspace); + const header = page.getByTestId("changes-repository-header").filter({ visible: true }); + await expect(header).toContainText("feature"); + await expect(header).not.toContainText("main"); + await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0); +}); + test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await openWorkspaceChanges(page, workspace); diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 5d04f9eca9..5508803b5d 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -218,8 +218,12 @@ const stylesheet = StyleSheet.create((theme) => ({ alignSelf: "center", paddingHorizontal: theme.spacing[2], }, + // The message above drops its bottom padding for the footer (compactBottom), so this margin + // alone sets the gap. 20 puts the footer as far below the text as the text sits below a + // tool row: 12 message padding + 4 stream gap + 5 badge padding/border, less the footer's + // 1px taller row. turnFooterRow: { - marginTop: theme.spacing[2] + 5, + marginTop: theme.spacing[4] + theme.spacing[1], }, turnFooterSlot: { flexDirection: "row", diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index d4a7d43ea8..59d3b49797 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -667,15 +667,14 @@ function ChangesRepositoryToolbar({ isGitCheckout testID="changes-branch-switcher" /> - {model.baseRef ? ( + {model.baseRef && model.isWorktree ? ( <> - + ) : null} diff --git a/packages/app/src/git/workspace-base-branch-picker.tsx b/packages/app/src/git/workspace-base-branch-picker.tsx index cf260e90d6..abc4fbee8f 100644 --- a/packages/app/src/git/workspace-base-branch-picker.tsx +++ b/packages/app/src/git/workspace-base-branch-picker.tsx @@ -20,7 +20,6 @@ interface Props { workspaceId?: string | null; cwd: string; baseRef: string; - isWorktree: boolean; } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -31,13 +30,7 @@ function refLabel(ref: string): string { return ref.replace(/^refs\/(heads|remotes)\//, ""); } -export function WorkspaceBaseBranchPicker({ - serverId, - workspaceId, - cwd, - baseRef, - isWorktree, -}: Props) { +export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef }: Props) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const connected = useHostRuntimeIsConnected(serverId); @@ -135,7 +128,7 @@ export function WorkspaceBaseBranchPicker({ [t, isPending, error, isError, handleRetry], ); - if (!supported || !isWorktree || !workspaceId) return {label}; + if (!supported || !workspaceId) return {label}; return ( @@ -172,7 +165,9 @@ export function WorkspaceBaseBranchPicker({ } const styles = StyleSheet.create((theme) => ({ - anchor: { flexShrink: 1, minWidth: 0 }, + // The branch name truncates first so a short base like "main" stays readable; the cap lets + // a long base still truncate instead of overflowing the header. + anchor: { flexShrink: 0, minWidth: 0, maxWidth: "40%" }, footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, From 68fc16b85507b9fe4ce7830e0293c6d95999afd2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 20:20:18 +0700 Subject: [PATCH 13/32] fix(terminal): run terminal workers without process identity queries (#8818) * fix(terminal): run terminals when process identity cannot be read On some Windows machines Windows PowerShell cannot run Get-CimInstance, or cannot be started at all. The terminal worker refused to start without its own birth time and command line, so those machines had no terminals. Process identity only authorizes signals. The worker now starts anyway and records its identity as unavailable, and programs without a birth time. Cleanup never signals what it cannot identify. It asks such a worker to shut down over the socket the secret authenticates, and reports survivors as unconfirmed. So records of crashed workers do not block launches, the process table falls back to process.kill(pid, 0) when the query fails. When a live pid cannot be identified, a refused connection on the worker's socket marks it as another process, because a worker listens until it exits. * fix(terminal): ask unidentified workers to shut down on every cleanup Retries of an unconfirmed cleanup and startup cleanup run without a grace period, which skipped the shutdown request. For a worker whose identity is unavailable that request is the only way to stop it, so it is now always sent. A gone program recorded without a birth time is no longer walked through the process table: the table was unreadable when it started, and the walk only kept the cleanup unconfirmed. Also test the socket probe on every platform, restore a test of the daemon starting when the worker cannot, and keep one pid liveness helper. * test(terminal): fail the worker launch the same way on every platform Windows opens a directory sitting at the launcher's log path, so the worker started there and the #8810 test never saw a failed launch. An invalid protocol override makes the worker exit before it writes its record on every platform. * fix(terminal): identify workers through their recorded sockets * fix(terminal): authenticate legacy sockets before forced cleanup * test(terminal): exercise closing records without process queries * test(terminal): identify stale lifecycle records by their sockets * refactor(terminal): drop the unreleased unavailable-identity record shape No release wrote identity: { unavailable }; it existed only between commits of this change. Records keep reading the identity workers up to 0.15.1 wrote, and nothing consults it. --- docs/terminal-worker.md | 13 +- .../terminal-worker-lifecycle.e2e.test.ts | 33 +- .../terminal/terminal-worker-client.test.ts | 47 ++- .../src/terminal/terminal-worker-client.ts | 34 +- .../terminal-worker-ownership.test.ts | 46 +-- .../src/terminal/terminal-worker-ownership.ts | 66 ++-- .../src/terminal/terminal-worker-process.ts | 33 +- .../src/terminal/terminal-worker-processes.ts | 55 +--- .../terminal/terminal-worker-reaper.test.ts | 147 ++++++++- .../src/terminal/terminal-worker-reaper.ts | 50 +-- .../src/terminal/terminal-worker-record.ts | 17 +- .../terminal-worker-socket-path.test.ts | 18 +- .../terminal/terminal-worker-socket-path.ts | 3 +- .../terminal/worker-terminal-manager.test.ts | 298 ++++++++++++++---- .../src/terminal/worker-terminal-manager.ts | 45 ++- 15 files changed, 648 insertions(+), 257 deletions(-) diff --git a/docs/terminal-worker.md b/docs/terminal-worker.md index be9321fb4f..77aa9325b7 100644 --- a/docs/terminal-worker.md +++ b/docs/terminal-worker.md @@ -21,11 +21,14 @@ Which triggers keep terminals and which close them is owned by the stop reason ( ## Record, socket and secret -The worker writes `$PASEO_HOME/terminal-worker/.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker `), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time. +The worker writes `$PASEO_HOME/terminal-worker/.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home. -- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory. -- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-/`, then `/tmp/paseo-/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root. -- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv. +- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory. +- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed. +- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it. +- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them. +- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs. +- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-/` or `/tmp/paseo-/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded. - A kill cleanup that cannot confirm every process gone writes `closing-.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it. - An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment. @@ -40,7 +43,7 @@ The worker writes `$PASEO_HOME/terminal-worker/.json` (owner-only) with its | two or more older | shuts it down; its terminals close (logged) | | newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why | -Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it. +Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it. **The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that. diff --git a/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts index d606b4d4d9..a9789c906e 100644 --- a/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts @@ -18,6 +18,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js"; import { resolveDaemonVersion } from "../daemon-version.js"; import { readDaemonInstance, stopDaemonInstance } from "../daemon-instance.js"; import { currentBootId } from "../pid-lock.js"; +import { resolveTerminalWorkerSocketPath } from "../../terminal/terminal-worker-socket-path.js"; import { connectTerminalWorker } from "../../terminal/terminal-worker-client.js"; import { createTerminalWorkerProcessInspector } from "../../terminal/terminal-worker-processes.js"; import { reapTerminalWorkers } from "../../terminal/terminal-worker-reaper.js"; @@ -316,14 +317,6 @@ function spawnUnrelated(): ChildProcess { return child; } -async function birthOf(pid: number): Promise { - const inspection = await inspector.inspect(pid); - if (inspection.status !== "alive" || !inspection.snapshot.startedAt) { - throw new Error(`no birth time for ${pid}`); - } - return inspection.snapshot.startedAt; -} - afterEach(async () => { for (const harness of harnesses.splice(0)) { const pid = harness.supervisor.pid; @@ -582,28 +575,26 @@ describe("terminals when the daemon stops", { timeout: 240_000 }, () => { const reused = spawnUnrelated(); const earlierBoot = spawnUnrelated(); const base = await currentWorkerRecord(harness.home); - // Same pid, another birth time: what a reused pid looks like, whatever its command line. + // A reused pid with its old record-specific socket gone must never be signaled. writeTerminalWorkerRecord(harness.home, { ...base, recordId: "stale-record", pid: reused.pid!, - identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "node -e setInterval" }, + socketPath: resolveTerminalWorkerSocketPath({ + paseoHome: harness.home, + workerPid: reused.pid!, + recordId: "stale-record", + }), terminals: [{ id: "t-stale", pid: reused.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], }); - // The right birth time, but written before this boot. + // Records from an earlier boot are retired before checking any process. writeTerminalWorkerRecord(harness.home, { ...base, recordId: "earlier-boot-record", pid: earlierBoot.pid!, bootId: currentBootId() ? "another-boot" : null, createdAt: "2000-01-01T00:00:00.000Z", - identity: { - startedAt: await birthOf(earlierBoot.pid!), - commandLine: `node --paseo-terminal-worker earlier-boot-record`, - }, - terminals: [ - { id: "t-boot", pid: earlierBoot.pid!, startedAt: await birthOf(earlierBoot.pid!) }, - ], + terminals: [{ id: "t-boot", pid: earlierBoot.pid!, startedAt: null }], }); await stopDaemon(harness); @@ -616,7 +607,11 @@ describe("terminals when the daemon stops", { timeout: 240_000 }, () => { ...base, recordId: "stale-record", pid: reused.pid!, - identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "node -e setInterval" }, + socketPath: resolveTerminalWorkerSocketPath({ + paseoHome: harness.home, + workerPid: reused.pid!, + recordId: "stale-record", + }), terminals: [{ id: "t-stale", pid: reused.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], }); await stopDaemonInstance(harness.home, { force: true }); diff --git a/packages/server/src/terminal/terminal-worker-client.test.ts b/packages/server/src/terminal/terminal-worker-client.test.ts index 01a4dd91d3..119d339b18 100644 --- a/packages/server/src/terminal/terminal-worker-client.test.ts +++ b/packages/server/src/terminal/terminal-worker-client.test.ts @@ -4,7 +4,13 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { isPlatform } from "../test-utils/platform.js"; import { encodeFrame, FrameDecoder, TerminalWorkerFrameError } from "./terminal-worker-frames.js"; -import { connectTerminalWorker, TerminalWorkerConnectError } from "./terminal-worker-client.js"; +import { createConnection, createServer, type Server } from "node:net"; +import { randomUUID } from "node:crypto"; +import { + connectTerminalWorker, + probeTerminalWorkerSocket, + TerminalWorkerConnectError, +} from "./terminal-worker-client.js"; import { launchTerminalWorker } from "./terminal-worker-launch.js"; import { terminalWorkerRecordPath } from "./terminal-worker-record.js"; @@ -74,7 +80,6 @@ describe("terminal worker authentication", () => { }); it("bounds a stalled framed writer to one chunk and resumes in order", async () => { - const { createServer, createConnection } = await import("node:net"); const { attachFramedSocket } = await import("./terminal-worker-frames.js"); const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -115,3 +120,41 @@ it("bounds a stalled framed writer to one chunk and resumes in order", async () await new Promise((resolve) => server.close(() => resolve())); } }); + +// The probe decides whether a live pid named by an unidentifiable worker record is still the +// worker. A refused answer retires the record, so it must hold for a Windows named pipe too. +describe("terminal worker socket probe", () => { + function socketPath(): string { + if (isPlatform("win32")) return `\\\\.\\pipe\\paseo-tw-probe-${randomUUID()}`; + const directory = mkdtempSync(path.join(tmpdir(), "tw-probe-")); + temporaryDirs.push(directory); + return path.join(directory, "w.sock"); + } + + async function serve(at: string): Promise { + const server = createServer((socket) => socket.on("error", () => {})); + await new Promise((resolve) => server.listen(at, resolve)); + return server; + } + + it("answers accepting while a worker serves the socket", async () => { + const at = socketPath(); + const server = await serve(at); + try { + expect(await probeTerminalWorkerSocket(at)).toBe("accepting"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it("answers refused for a socket nobody ever served", async () => { + expect(await probeTerminalWorkerSocket(socketPath())).toBe("refused"); + }); + + it("answers refused once the worker that served the socket closed it", async () => { + const at = socketPath(); + const server = await serve(at); + await new Promise((resolve) => server.close(resolve)); + expect(await probeTerminalWorkerSocket(at)).toBe("refused"); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-client.ts b/packages/server/src/terminal/terminal-worker-client.ts index 532f3075c6..ac48293879 100644 --- a/packages/server/src/terminal/terminal-worker-client.ts +++ b/packages/server/src/terminal/terminal-worker-client.ts @@ -1,4 +1,5 @@ -import { EventEmitter } from "node:events"; +import { EventEmitter, once } from "node:events"; +import { setTimeout as delay } from "node:timers/promises"; import { createConnection } from "node:net"; import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; import { @@ -139,3 +140,34 @@ export function connectTerminalWorker(input: { }); }); } + +export type TerminalWorkerSocketProbe = "accepting" | "refused" | "unknown"; + +const PROBE_TIMEOUT_MS = 1_000; +// What a connect to a socket nobody listens on fails with: the unix socket file is left behind +// (ECONNREFUSED) or gone, as a Windows pipe is once its server closes (ENOENT). +const NO_LISTENER_ERRORS = new Set(["ECONNREFUSED", "ENOENT"]); + +/** + * Whether anything accepts connections on a worker's socket. It never authenticates, so the + * daemon controlling the worker keeps control: the worker drops a connection that closes + * before its hello. + */ +export async function probeTerminalWorkerSocket( + socketPath: string, +): Promise { + const socket = createConnection(socketPath); + const timeout = new AbortController(); + try { + return await Promise.race([ + once(socket, "connect").then(() => "accepting" as const), + delay(PROBE_TIMEOUT_MS, "unknown" as const, { signal: timeout.signal }), + ]); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code && NO_LISTENER_ERRORS.has(code) ? "refused" : "unknown"; + } finally { + timeout.abort(); + socket.destroy(); + } +} diff --git a/packages/server/src/terminal/terminal-worker-ownership.test.ts b/packages/server/src/terminal/terminal-worker-ownership.test.ts index 7abbb9aa62..aefa364338 100644 --- a/packages/server/src/terminal/terminal-worker-ownership.test.ts +++ b/packages/server/src/terminal/terminal-worker-ownership.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ManagedProcessInspection } from "../server/managed-processes/managed-processes.js"; import { classifyTerminalProgramProcess, - classifyTerminalWorkerProcess, + identifyTerminalWorker, } from "./terminal-worker-ownership.js"; const BIRTH = "Wed Oct 21 18:12:49 2026"; @@ -12,14 +12,6 @@ function alive(startedAt: string | null, commandLine: string | null): ManagedPro return { status: "alive", snapshot: { pid: 4242, startedAt, commandLine } }; } -const workerRecord = { - recordId: "record-1", - identity: { - startedAt: BIRTH, - commandLine: "node terminal-worker-process.js --paseo-terminal-worker record-1", - }, -}; - describe("terminal program ownership", () => { it("owns a program that ran exec: same pid and birth time, new command line", () => { expect( @@ -59,33 +51,15 @@ describe("terminal program ownership", () => { }); describe("terminal worker ownership", () => { - it("owns the worker whose birth time matches and whose command line carries its record id", () => { + it("retires a live unrelated pid when its socket is absent without inspecting it", async () => { expect( - classifyTerminalWorkerProcess(workerRecord, alive(BIRTH, workerRecord.identity.commandLine)), - ).toEqual({ status: "owned" }); - }); - - it("treats another worker reusing the pid in the same second as unrelated", () => { - expect( - classifyTerminalWorkerProcess( - workerRecord, - alive(BIRTH, "node terminal-worker-process.js --paseo-terminal-worker record-2"), - ), - ).toMatchObject({ status: "unrelated" }); - }); - - it("treats a worker pid reused with another birth time as unrelated", () => { - expect( - classifyTerminalWorkerProcess( - workerRecord, - alive(OTHER_BIRTH, workerRecord.identity.commandLine), - ), - ).toMatchObject({ status: "unrelated" }); - }); - - it("does not own a worker whose command line cannot be read", () => { - expect(classifyTerminalWorkerProcess(workerRecord, alive(BIRTH, null))).toMatchObject({ - status: "unknown", - }); + await identifyTerminalWorker({ + pid: process.pid, + socketPath: + process.platform === "win32" + ? "\\\\.\\pipe\\paseo-absent-ownership-test" + : "/tmp/paseo-absent-ownership-test.sock", + }), + ).toEqual({ status: "gone" }); }); }); diff --git a/packages/server/src/terminal/terminal-worker-ownership.ts b/packages/server/src/terminal/terminal-worker-ownership.ts index 9bd02b01cd..0f7033f1b3 100644 --- a/packages/server/src/terminal/terminal-worker-ownership.ts +++ b/packages/server/src/terminal/terminal-worker-ownership.ts @@ -1,5 +1,6 @@ import type { ManagedProcessInspection } from "../server/managed-processes/managed-processes.js"; -import { TERMINAL_WORKER_MARKER_ARG, type TerminalWorkerRecord } from "./terminal-worker-record.js"; +import { connectTerminalWorker, probeTerminalWorkerSocket } from "./terminal-worker-client.js"; +import { type TerminalWorkerRecord } from "./terminal-worker-record.js"; // Who a pid named in a terminal worker record belongs to, before anything signals it. // @@ -15,20 +16,49 @@ export type ProcessOwnership = | { status: "owned" } | { status: "unknown"; reason: string }; -export function classifyTerminalWorkerProcess( - record: Pick, - inspection: ManagedProcessInspection, -): ProcessOwnership { - const birth = classifyBirth(record.identity.startedAt, inspection); - if (birth.status !== "owned" || inspection.status !== "alive") return birth; - const commandLine = inspection.snapshot.commandLine; - if (!commandLine) { - return { status: "unknown", reason: "command line unavailable" }; +/** The home lock owns these records; the record-specific socket proves a live worker. */ +export async function identifyTerminalWorker( + record: Pick, +): Promise { + try { + process.kill(record.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return { status: "gone" }; } - if (!hasWorkerMarker(commandLine, record.recordId)) { - return { status: "unrelated", reason: "command line lacks the worker marker" }; + const socket = await probeTerminalWorkerSocket(record.socketPath); + if (socket === "refused") return { status: "gone" }; + if (socket === "accepting") return { status: "owned" }; + return { status: "unknown", reason: "worker socket probe inconclusive" }; +} + +/** Rechecks the live worker immediately before a forced termination. */ +export async function authorizeTerminalWorkerTermination( + record: TerminalWorkerRecord, +): Promise { + const ownership = await identifyTerminalWorker(record); + if (ownership.status !== "owned") return ownership; + // COMPAT(terminal-worker-legacy-socket): added after v0.15.1, remove after 2027-02-06. + // Legacy names contain only home + pid. A stale record can reach a newer worker, so an + // accepting legacy socket requires proof of its secret before cleanup may signal it. + if ( + record.socketPath.endsWith(`-${record.recordId}.sock`) || + record.socketPath.endsWith(`-${record.recordId}`) + ) + return ownership; + try { + const connection = await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + timeoutMs: 1000, + }); + const matches = connection.workerPid === record.pid; + connection.disconnect(); + return matches + ? ownership + : { status: "unknown", reason: "legacy socket authenticated another pid" }; + } catch { + return { status: "unknown", reason: "legacy socket ownership could not be authenticated" }; } - return { status: "owned" }; } export function classifyTerminalProgramProcess( @@ -57,13 +87,3 @@ function classifyBirth( } return { status: "owned" }; } - -function hasWorkerMarker(commandLine: string, recordId: string): boolean { - const tokens = commandLine.split(/\s+/); - for (let index = 0; index < tokens.length - 1; index += 1) { - if (tokens[index] === TERMINAL_WORKER_MARKER_ARG && tokens[index + 1] === recordId) { - return true; - } - } - return false; -} diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 855c39da59..b9eb4698c0 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -403,9 +403,10 @@ function runWorker(argv: string[]): void { ]); } - // A terminal enters the record only with its program's birth time, so cleanup can always - // tell the program from a later process that reuses its pid. A program that already exited - // needs no entry; one whose birth time cannot be read is killed and the create fails. + // A terminal enters the record with its program's birth time, so cleanup can tell the + // program from a later process that reuses its pid. A program that already exited needs no + // entry. One whose birth time cannot be read is recorded without it: it still runs, and + // cleanup never signals it. async function recordTerminalProgram(session: LocalTerminalSession): Promise { if (session.getExitInfo()) return; // The manager drops a session as soon as its program exits, so only a live one has a pid. @@ -413,14 +414,17 @@ function runWorker(argv: string[]): void { if (pid === null) { throw new Error(`Terminal ${session.id} has no program pid`); } - const inspection = await inspector.inspect(pid); - if (inspection.status === "not-found" || session.getExitInfo()) { + const inspection = inspector.reportsBirthTimes ? await inspector.inspect(pid) : null; + if (inspection?.status === "not-found" || session.getExitInfo()) { return; } - if (inspection.status === "error" || !inspection.snapshot.startedAt) { + const startedAt = inspection?.status === "alive" ? inspection.snapshot.startedAt : null; + if (inspector.reportsBirthTimes && !startedAt) { const reason = - inspection.status === "error" ? errorMessage(inspection.error) : "no birth time reported"; - throw new Error(`Cannot identify terminal program ${pid}: ${reason}`); + inspection?.status === "error" ? errorMessage(inspection.error) : "no birth time reported"; + console.error( + `Terminal worker cannot read the birth time of terminal program ${pid}; cleanup will never signal it: ${reason}`, + ); } if (!record) { throw new Error("Terminal worker record is not written yet"); @@ -429,7 +433,7 @@ function runWorker(argv: string[]): void { ...record, terminals: [ ...record.terminals.filter((entry) => entry.id !== session.id), - { id: session.id, pid, startedAt: inspection.snapshot.startedAt }, + { id: session.id, pid, startedAt }, ], }; await persistRecord(); @@ -694,6 +698,7 @@ function runWorker(argv: string[]): void { const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: args.paseoHome, workerPid: process.pid, + recordId: args.recordId, }); if (process.platform !== "win32") { rmSync(socketPath, { force: true }); @@ -711,21 +716,11 @@ function runWorker(argv: string[]): void { chmodSync(socketPath, PRIVATE_FILE_MODE); } - const self = await inspector.inspect(process.pid); - if ( - self.status !== "alive" || - !self.snapshot.startedAt || - !self.snapshot.commandLine?.includes(args.recordId) - ) { - const reason = self.status === "error" ? errorMessage(self.error) : "no usable snapshot"; - throw new Error(`Terminal worker cannot read its own process identity: ${reason}`); - } record = { recordId: args.recordId, pid: process.pid, bootId: currentBootId(), createdAt: new Date().toISOString(), - identity: { startedAt: self.snapshot.startedAt, commandLine: self.snapshot.commandLine }, protocolVersion, paseoVersion: args.paseoVersion, socketPath, diff --git a/packages/server/src/terminal/terminal-worker-processes.ts b/packages/server/src/terminal/terminal-worker-processes.ts index b0c2e6d0cb..90de521bab 100644 --- a/packages/server/src/terminal/terminal-worker-processes.ts +++ b/packages/server/src/terminal/terminal-worker-processes.ts @@ -1,15 +1,13 @@ import { readFile } from "node:fs/promises"; -import { z } from "zod"; import { execCommand } from "../utils/spawn.js"; import { createSystemManagedProcessTable, type ManagedProcessCommandRunner, type ManagedProcessInspection, - windowsPowerShellPath, } from "../server/managed-processes/managed-processes.js"; -// Process inspection for terminal worker ownership. Birth times are compared as the strings -// `ps -o lstart` and Win32_Process.CreationDate print, so every reader must print them the same +// POSIX terminal program birth times are compared as the strings `ps -o lstart` prints, +// so every reader must print them the same // way: `ps` follows LC_TIME, and the worker, the daemon, the supervisor and the CLI can run // under different locales. Every `ps` here runs under LC_ALL=C. @@ -27,6 +25,7 @@ export interface ProcessTableEntry { export interface TerminalWorkerProcessInspector { /** True where the process table names each process's session (Linux). */ readonly reportsSessions: boolean; + readonly reportsBirthTimes: boolean; inspect(pid: number): Promise; /** One snapshot of every process, for walking descendants. Throws when it cannot be read. */ listProcesses(): Promise; @@ -37,14 +36,6 @@ export interface TerminalWorkerProcessInspector { readEnvironment(pid: number): Promise; } -const WindowsProcessListSchema = z.array( - z.object({ - ProcessId: z.number().int(), - ParentProcessId: z.number().int().nullable().optional(), - CreationDate: z.string().nullable().optional(), - }), -); - function createLocaleNeutralCommandRunner(): ManagedProcessCommandRunner { return { exec: (command, args) => @@ -65,11 +56,16 @@ export function createTerminalWorkerProcessInspector( const table = createSystemManagedProcessTable({ platform, commandRunner }); return { reportsSessions: platform === "linux", - inspect: (pid) => table.inspect(pid), - listProcesses: () => + reportsBirthTimes: platform !== "win32", + inspect: (pid) => platform === "win32" - ? listWindowsProcesses(commandRunner) - : listPosixProcesses(commandRunner, platform), + ? Promise.resolve({ + status: "error", + error: new Error("Windows terminal programs have no birth times"), + }) + : table.inspect(pid), + listProcesses: () => + platform === "win32" ? Promise.resolve([]) : listPosixProcesses(commandRunner, platform), readEnvironment: (pid) => readLinuxEnvironment(platform, pid), }; } @@ -121,33 +117,6 @@ async function listPosixProcesses( return entries; } -async function listWindowsProcesses( - commandRunner: ManagedProcessCommandRunner, -): Promise { - const command = - "@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate) | ConvertTo-Json -Compress"; - const { stdout } = await commandRunner.exec(windowsPowerShellPath(), [ - "-NoProfile", - "-NonInteractive", - "-Command", - command, - ]); - const parsed: unknown = JSON.parse(stdout.trim()); - const rows = WindowsProcessListSchema.parse(Array.isArray(parsed) ? parsed : [parsed]); - return rows.flatMap((row) => - row.CreationDate - ? [ - { - pid: row.ProcessId, - ppid: row.ParentProcessId ?? 0, - startedAt: row.CreationDate, - sessionId: null, - }, - ] - : [], - ); -} - /** * Every process below `rootPid` in the snapshot, plus (where the platform reports sessions) * every member of the session `rootPid` led and everything below those members. A PTY program diff --git a/packages/server/src/terminal/terminal-worker-reaper.test.ts b/packages/server/src/terminal/terminal-worker-reaper.test.ts index 66315b61d3..ef8d4fe487 100644 --- a/packages/server/src/terminal/terminal-worker-reaper.test.ts +++ b/packages/server/src/terminal/terminal-worker-reaper.test.ts @@ -22,6 +22,7 @@ import { terminalCleanupRecordPath, terminalWorkerDirectory, writeTerminalWorkerRecord, + writeTerminalCleanupRecord, type TerminalWorkerRecord, } from "./terminal-worker-record.js"; @@ -119,6 +120,7 @@ function withSessionMember(input: { }): TerminalWorkerProcessInspector { return { reportsSessions: true, + reportsBirthTimes: true, inspect: (pid) => inspector.inspect(pid), listProcesses: async () => { // A fresh snapshot each call, so it is rewritten in place. @@ -308,6 +310,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { const killable = spawnIdle(); const failing: TerminalWorkerProcessInspector = { reportsSessions: inspector.reportsSessions, + reportsBirthTimes: inspector.reportsBirthTimes, inspect: (pid) => pid === uninspectable.pid ? Promise.resolve({ status: "error", error: new Error("ps unavailable") }) @@ -504,6 +507,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { }); const tableUnavailable: TerminalWorkerProcessInspector = { reportsSessions: inspector.reportsSessions, + reportsBirthTimes: inspector.reportsBirthTimes, inspect: (pid) => inspector.inspect(pid), listProcesses: () => Promise.reject(new Error("ps timed out")), readEnvironment: (pid) => inspector.readEnvironment(pid), @@ -577,12 +581,19 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { const home = makeHome(); const worker = spawn( process.execPath, - ["-e", "setInterval(() => {}, 1e9)", "--", "--paseo-terminal-worker", "record-under-test"], + [ + "-e", + `require("node:net").createServer(() => {}).listen(${JSON.stringify(path.join(home, "live-record-under-test.sock"))});setInterval(() => {}, 1e9)`, + "--", + "--paseo-terminal-worker", + "record-under-test", + ], { detached: true, stdio: "ignore" }, ); children.push(worker); const recordPath = writeRecord(home, { pid: worker.pid!, + socketPath: path.join(home, "live-record-under-test.sock"), identity: { startedAt: await birthOf(worker.pid!), commandLine: "node -e --paseo-terminal-worker record-under-test", @@ -623,12 +634,19 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { const home = makeHome(); const worker = spawn( process.execPath, - ["-e", "setInterval(() => {}, 1e9)", "--", "--paseo-terminal-worker", "record-under-test"], + [ + "-e", + `require("node:net").createServer(() => {}).listen(${JSON.stringify(path.join(home, "live-record-under-test.sock"))});setInterval(() => {}, 1e9)`, + "--", + "--paseo-terminal-worker", + "record-under-test", + ], { detached: true, stdio: "ignore" }, ); children.push(worker); const recordPath = writeRecord(home, { pid: worker.pid!, + socketPath: path.join(home, "live-record-under-test.sock"), identity: { startedAt: await birthOf(worker.pid!), commandLine: "node -e --paseo-terminal-worker record-under-test", @@ -653,3 +671,128 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { }); }); }); + +// Real sockets identify workers on every platform, independently of program birth times. +describe("terminal worker reaper without process identity", () => { + const noIdentity = createTerminalWorkerProcessInspector(); + + // A live process standing in for a worker that cannot answer: it listens on the record's + // socket and never replies to the hello. + async function spawnSilentListener(socketPath: string): Promise { + const listener = spawnIdle(` + require("node:net").createServer(() => {}).listen(${JSON.stringify(socketPath)}, () => { + process.stdout.write("listening\\n"); + }); + `); + await new Promise((resolve) => listener.stdout!.once("data", resolve)); + return listener; + } + + it("retires the record of a gone worker and its gone programs without the identity query", async () => { + const home = makeHome(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: await goneProcessPid(), startedAt: null }], + }); + + const result = await reapTerminalWorkers({ paseoHome: home, inspector: noIdentity }); + + expect(result).toEqual({ records: 1, retired: 1, signaled: [] }); + expect(existsSync(recordPath)).toBe(false); + }); + + it("does not kill a newer legacy worker when a stale cleanup secret is rejected", async () => { + const home = makeHome(); + const socketPath = + process.platform === "win32" + ? `\\\\.\\pipe\\paseo-legacy-${Date.now()}` + : path.join(home, "legacy.sock"); + const listener = spawnIdle(` + require("node:net").createServer(socket => socket.on("data", () => socket.destroy())).listen(${JSON.stringify(socketPath)}, () => process.stdout.write("ready")); + `); + await new Promise((resolve) => listener.stdout!.once("data", resolve)); + const stalePath = writeRecord(home, { pid: listener.pid!, socketPath, secret: "stale-secret" }); + const stale = await readTerminalWorkerRecord(stalePath); + const cleanupPath = await writeTerminalCleanupRecord(home, { + ...stale, + closing: { + unresolved: [ + { pid: listener.pid!, role: "worker", reason: "still alive", detail: "old cleanup" }, + ], + }, + }); + writeRecord(home, { + pid: listener.pid!, + recordId: "newer-worker", + socketPath, + secret: "new-secret", + }); + await expect( + reapTerminalWorkers({ + paseoHome: home, + gracefulMs: 0, + confirmMs: 50, + recordPaths: [cleanupPath], + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect(isAlive(listener.pid!)).toBe(true); + }); + + it("force kills a worker whose socket accepts but never answers", async () => { + const home = makeHome(); + const socketPath = + process.platform === "win32" + ? `\\\\.\\pipe\\paseo-test-${Date.now()}-record-under-test` + : path.join(home, "w-record-under-test.sock"); + const listener = await spawnSilentListener(socketPath); + writeRecord(home, { + pid: listener.pid!, + socketPath, + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: noIdentity, + gracefulMs: 300, + }); + expect(result.signaled).toContain(listener.pid); + await waitUntilGone(listener.pid!); + expect(isAlive(listener.pid!)).toBe(false); + expect(await readUnconfirmedTerminalCleanup(home)).toEqual([]); + }); + + it("treats a live pid with nothing on the worker's socket as another process, signals it never, and retires the record", async () => { + const home = makeHome(); + const reusedWorkerPid = spawnIdle().pid!; + const recordPath = writeRecord(home, { + pid: reusedWorkerPid, + }); + + const result = await reapTerminalWorkers({ paseoHome: home, inspector: noIdentity }); + + expect(result).toEqual({ records: 1, retired: 1, signaled: [] }); + expect(isAlive(reusedWorkerPid)).toBe(true); + expect(existsSync(recordPath)).toBe(false); + }); + + it.skipIf(isPlatform("win32"))( + "never signals a live terminal program recorded without a birth time", + async () => { + const home = makeHome(); + const program = spawnIdle(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: null }], + }); + + await expect( + reapTerminalWorkers({ paseoHome: home, inspector, confirmMs: 200 }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + + expect(isAlive(program.pid!)).toBe(true); + expect(await readUnconfirmedTerminalCleanup(home)).toEqual([ + expect.objectContaining({ pid: program.pid!, role: "terminal program" }), + ]); + }, + ); +}); diff --git a/packages/server/src/terminal/terminal-worker-reaper.ts b/packages/server/src/terminal/terminal-worker-reaper.ts index 2647d4b25e..da229f9ddd 100644 --- a/packages/server/src/terminal/terminal-worker-reaper.ts +++ b/packages/server/src/terminal/terminal-worker-reaper.ts @@ -6,7 +6,8 @@ import { connectTerminalWorker } from "./terminal-worker-client.js"; import { pruneTerminalWorkerRuntimes } from "./terminal-worker-runtime.js"; import { classifyTerminalProgramProcess, - classifyTerminalWorkerProcess, + identifyTerminalWorker, + authorizeTerminalWorkerTermination, type ProcessOwnership, } from "./terminal-worker-ownership.js"; import { @@ -30,7 +31,8 @@ import { // uses for its own kill stop. Every kill intent (stop, quit, --force, a kill restart whose // shutdown hung, a dead or stopped worker, a program that ignores HUP) ends here: // -// 1. Classify the worker and every terminal program of each record on its own, by birth time. +// 1. Classify each record's worker by its pid and socket, and every terminal program on its +// own, by birth time (Windows records none; its programs end with their worker's console). // A record from an earlier boot names only processes the reboot ended: retire it unsignaled. // Descendants of owned programs (and, on Linux, members of their sessions) join the // candidates from one process-table snapshot taken before anything is signaled. Once a @@ -38,9 +40,10 @@ import { // program's session joins only when its environment carries that terminal's id. When the // table cannot be read, nothing in the record is signaled: ending a program would reparent // the children only the table can find, and a retry could never reach them again. -// 2. An owned worker gets an authenticated `shutdown`: it kills each terminal and waits for it. -// 3. After `gracefulMs`, SIGKILL whatever owned process is left, re-inspecting right before -// each signal. +// 2. A worker whose socket accepts is owned and gets an authenticated `shutdown`: it kills each +// terminal and waits for it. +// 3. After `gracefulMs`, SIGKILL whatever owned process is left, checking its socket or birth +// time again right before each signal. // 4. Succeed only when every candidate is gone or proven unrelated. Otherwise keep a record of // the unresolved entries, marked closing, in a cleanup record no worker rewrites, and throw // TerminalCleanupNotConfirmedError. An unreadable record is never reported confirmed. @@ -161,7 +164,7 @@ export interface ReapTerminalWorkersOptions { interface Candidate { pid: number; - startedAt: string; + startedAt: string | null; role: UnresolvedTerminalProcess["role"]; terminalId: string; state: ProcessOwnership; @@ -252,7 +255,7 @@ async function reapRecord( return { signaled: [], unresolved: [] }; } - const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + const worker = await identifyTerminalWorker(record); const discovery = await collectCandidates(record, inspector, log); const tracked: TrackedProcesses = { record, @@ -265,13 +268,11 @@ async function reapRecord( return await keepUnresolved(input, tracked, [], `not signaled: ${discovery.tableError}`); } - if (tracked.worker.status === "owned" && input.gracefulMs > 0) { - await requestWorkerShutdown(record, input.gracefulMs, log); + const shutdownMs = shutdownWindow(tracked.worker, input.gracefulMs); + if (shutdownMs > 0) { + await requestWorkerShutdown(record, shutdownMs, log); } - await waitUntilSettled( - tracked, - Date.now() + (tracked.worker.status === "owned" ? input.gracefulMs : 0), - ); + await waitUntilSettled(tracked, Date.now() + shutdownMs); let signaled: number[] = []; if (!isSettled(tracked)) { @@ -282,6 +283,13 @@ async function reapRecord( return await keepUnresolved(input, tracked, signaled, null); } +// Only an accepting socket authorizes shutdown and eventual forced termination. +function shutdownWindow(worker: ProcessOwnership, gracefulMs: number): number { + if (worker.status === "owned") return gracefulMs; + + return 0; +} + // Retires the record when every process it names is settled; otherwise writes the cleanup // record, marked closing, with only the unresolved entries. A live worker keeps rewriting its // own record, so the closing state never goes there. `notSignaled` explains open processes that @@ -337,10 +345,7 @@ function isSettled(tracked: TrackedProcesses): boolean { async function refreshTracked(tracked: TrackedProcesses): Promise { if (isOpen(tracked.worker)) { - tracked.worker = classifyTerminalWorkerProcess( - tracked.record, - await tracked.inspector.inspect(tracked.record.pid), - ); + tracked.worker = await identifyTerminalWorker(tracked.record); } for (const candidate of tracked.candidates) { if (isOpen(candidate.state)) { @@ -369,7 +374,7 @@ async function forceKillOwned( const { record, inspector } = tracked; const signaled: number[] = []; if (tracked.worker.status === "owned") { - tracked.worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + tracked.worker = await authorizeTerminalWorkerTermination(record); if (tracked.worker.status === "owned") { await forceKillWorker(record.pid); signaled.push(record.pid); @@ -447,6 +452,7 @@ async function collectCandidates( log: NonNullable, ): Promise { const candidates = new Map(); + if (inspector.reportsBirthTimes === false) return { candidates: [], tableError: null }; for (const entry of record.terminals) { candidates.set(entry.pid, { pid: entry.pid, @@ -461,8 +467,12 @@ async function collectCandidates( // already gone: the kernel keeps a session id reserved while a member lives, so a job the // shell left behind is still found. A pid reused by another process (unrelated) leads someone // else's session and is never walked. + // A program recorded without a birth time is never walked: the worker could not read the + // process table when it started it, so the walk would only keep the cleanup unconfirmed. const roots = Array.from(candidates.values()).filter( - (c) => c.state.status === "owned" || (c.state.status === "gone" && inspector.reportsSessions), + (c) => + c.state.status === "owned" || + (c.state.status === "gone" && c.startedAt !== null && inspector.reportsSessions), ); if (roots.length === 0) return { candidates: Array.from(candidates.values()), tableError: null }; @@ -564,7 +574,7 @@ async function requestWorkerShutdown( timeoutMs: Math.min(gracefulMs, 2_000), }); } catch (error) { - // A worker that cannot be reached is escalated by birth time below; a failed connection + // A worker that cannot be reached is probed again below; a failed authentication // alone never authorizes a signal. log("Terminal worker did not accept a shutdown request", { pid: record.pid, diff --git a/packages/server/src/terminal/terminal-worker-record.ts b/packages/server/src/terminal/terminal-worker-record.ts index 3176d2ee8e..63ac0ddd91 100644 --- a/packages/server/src/terminal/terminal-worker-record.ts +++ b/packages/server/src/terminal/terminal-worker-record.ts @@ -4,14 +4,20 @@ import path from "node:path"; import { z } from "zod"; import { writePrivateFileAtomicSync, writePrivateFileAtomic } from "../server/private-files.js"; -// Owner-only metadata a detached terminal worker leaves in $PASEO_HOME so the next daemon can -// find, authenticate and adopt it, and so cleanup can identify every process it is allowed to -// signal. Process identity is the birth time `ps`/Win32_Process report, never the pid alone. +// Owner-only metadata used to find and authenticate workers in the locked home. const TerminalWorkerTerminalEntrySchema = z.object({ id: z.string().min(1), pid: z.number().int().positive(), + // Null when the program's birth time could not be read: it is never signaled. + startedAt: z.string().min(1).nullable(), +}); + +// The birth time and command line workers up to 0.15.1 recorded about themselves. Still read +// from their records, never written, and never consulted: the socket identifies a worker. +const LegacyTerminalWorkerIdentitySchema = z.object({ startedAt: z.string().min(1), + commandLine: z.string().min(1), }); // One process a cleanup could not confirm gone, kept so a later caller (a retry, or a remote @@ -28,10 +34,7 @@ const TerminalWorkerRecordSchema = z.object({ pid: z.number().int().positive(), bootId: z.string().nullable(), createdAt: z.string().min(1), - identity: z.object({ - startedAt: z.string().min(1), - commandLine: z.string().min(1), - }), + identity: LegacyTerminalWorkerIdentitySchema.optional(), // Read loosely: a version this daemon cannot interpret marks a worker from a newer Paseo // (classifyTerminalWorkerProtocol), not an unreadable record. protocolVersion: z.union([z.number(), z.string()]), diff --git a/packages/server/src/terminal/terminal-worker-socket-path.test.ts b/packages/server/src/terminal/terminal-worker-socket-path.test.ts index 409cf60c81..6017177fcf 100644 --- a/packages/server/src/terminal/terminal-worker-socket-path.test.ts +++ b/packages/server/src/terminal/terminal-worker-socket-path.test.ts @@ -37,6 +37,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { const home = makeDir("tw-h-", "/tmp"); const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: home, + recordId: "test", workerPid: 4242, platform: "darwin", }); @@ -51,6 +52,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { const uid = process.getuid!(); const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: home, + recordId: "test", workerPid: 99999, platform: "darwin", tmpDir, @@ -71,6 +73,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { // also be refused, and /tmp/paseo- takes over. const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: home, + recordId: "test", workerPid: 7, platform: "darwin", tmpDir, @@ -84,6 +87,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { path.dirname( resolveTerminalWorkerSocketPath({ paseoHome: home, + recordId: "test", workerPid: 7, platform: "darwin", tmpDir: wideTmp, @@ -101,6 +105,7 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { try { resolveTerminalWorkerSocketPath({ paseoHome: home, + recordId: "test", workerPid: 7, platform: "darwin", tmpDir, @@ -123,7 +128,11 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { it("accepts a real listen on the chosen path", async () => { const home = makeDir("tw-l-", "/tmp"); - const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: home, workerPid: 5 }); + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + recordId: "test", + workerPid: 5, + }); const server = createServer(); await new Promise((resolve, reject) => { server.once("error", reject); @@ -132,3 +141,10 @@ describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { await new Promise((resolve) => server.close(() => resolve())); }); }); + +it("uses a separate socket for each record even when its pid is reused", () => { + const paseoHome = makeDir("tw-id-"); + const first = resolveTerminalWorkerSocketPath({ paseoHome, workerPid: 42, recordId: "old" }); + const second = resolveTerminalWorkerSocketPath({ paseoHome, workerPid: 42, recordId: "new" }); + expect(first).not.toBe(second); +}); diff --git a/packages/server/src/terminal/terminal-worker-socket-path.ts b/packages/server/src/terminal/terminal-worker-socket-path.ts index 99fce0e64c..d9a153c598 100644 --- a/packages/server/src/terminal/terminal-worker-socket-path.ts +++ b/packages/server/src/terminal/terminal-worker-socket-path.ts @@ -18,6 +18,7 @@ const OWNER_ONLY_MODE = 0o700; export interface TerminalWorkerSocketPathInput { paseoHome: string; workerPid: number; + recordId: string; platform?: NodeJS.Platform; tmpDir?: string; uid?: number; @@ -60,7 +61,7 @@ export function terminalWorkerSocketId(paseoHome: string, workerPid: number): st */ export function resolveTerminalWorkerSocketPath(input: TerminalWorkerSocketPathInput): string { const platform = input.platform ?? process.platform; - const id = terminalWorkerSocketId(input.paseoHome, input.workerPid); + const id = terminalWorkerSocketId(input.paseoHome, input.workerPid) + "-" + input.recordId; if (platform === "win32") { return `\\\\.\\pipe\\paseo-tw-${id}`; } diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 759f73e639..26cbb031d0 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -62,15 +62,22 @@ import { readTerminalWorkerRecord, terminalWorkerDirectory, terminalWorkerRecordPath, + writeTerminalWorkerRecord, + writeTerminalCleanupRecord, type TerminalWorkerRecord, } from "./terminal-worker-record.js"; -import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js"; +import { + TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV, + TERMINAL_WORKER_PROTOCOL_VERSION, +} from "./terminal-worker-protocol.js"; import { NoTerminalWorkerAvailableError, TerminalWorkerIncompatibleError, } from "./terminal-worker-pool.js"; import { connectTerminalWorker } from "./terminal-worker-client.js"; import { launchTerminalWorker, TerminalWorkerLaunchError } from "./terminal-worker-launch.js"; +import { currentBootId } from "../server/pid-lock.js"; +import { spawn } from "node:child_process"; type TerminalRow = TerminalState["grid"][number]; @@ -440,26 +447,24 @@ it("creates a terminal through the worker and streams output", async () => { }), ); const messages: string[] = []; - let readinessEvents = 0; + let snapshotReady = false; const unsubscribe = session.subscribe((message) => { if (message.type === "output") { messages.push(message.data); } if (message.type === "snapshotReady") { - readinessEvents += 1; + snapshotReady = true; } }); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(readinessEvents).toBe(1); + // Subscription and initial PTY output each announce readiness; their order depends on startup. + await waitForCondition(() => snapshotReady, 10000); session.send({ type: "input", data: "hello\r" }); await waitForCondition(() => messages.join("").includes("worker-output:hello"), 10000); - await new Promise((resolve) => setTimeout(resolve, 100)); unsubscribe(); expect(messages.join("")).toContain("worker-output:hello"); - expect(readinessEvents).toBe(1); }); it("delivers rapid small writes complete and in order through worker coalescing", async () => { @@ -812,7 +817,7 @@ async function recordedTerminalPids(paseoHome: string): Promise { const paseoHome = createTemporaryHome(); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-")); @@ -844,17 +849,22 @@ it.each([false, true])( const remainingPid = pids.get(remaining.id)!; const [record] = await workerRecords(paseoHome); - // A kill cleanup whose process table read fails signals nothing and leaves the worker live. + // Seed a retained cleanup through the same record interface the reaper writes. Windows + // does not read program birth times, so a process-table failure cannot create this state. const inspector = createTerminalWorkerProcessInspector(); - await expect( - reapTerminalWorkers({ - paseoHome, - inspector: { - ...inspector, - listProcesses: () => Promise.reject(new Error("ps timed out")), - }, - }), - ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + await writeTerminalCleanupRecord(paseoHome, { + ...record!, + closing: { + unresolved: [ + { + pid: record!.pid, + role: "worker", + reason: "still alive", + detail: "shutdown was not confirmed", + }, + ], + }, + }); expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); // Keep an ordinary reader open until the worker delivers the terminal exit. @@ -1643,17 +1653,16 @@ it.each([1, 4])( expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); // Startup launches a replacement worker after cleanup. Either old PID may already // belong to another process, so verify that the recorded birth identities are gone. - for (const original of [{ pid: record!.pid, ...record!.identity }, program]) { - await expect - .poll( - async () => - !isProcessAlive(original.pid) || - classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)) - .status === "unrelated", - { timeout: 10000 }, - ) - .toBe(true); - } + await waitForCondition(() => !isProcessAlive(record!.pid), 10000); + await expect + .poll( + async () => + !isProcessAlive(program.pid) || + classifyTerminalProgramProcess(program, await inspector.inspect(program.pid)).status === + "unrelated", + { timeout: 10000 }, + ) + .toBe(true); expect(manager.getTerminal(old.terminalId)).toBeUndefined(); const workers = await workerRecords(paseoHome); expect(workers).toHaveLength(1); @@ -2683,33 +2692,216 @@ it.each([false, true])( 45000, ); -// Both Windows reports in #8766: the worker's process identity query (PowerShell there, `ps` here) -// fails, the worker exits before writing its record, and the daemon used to exit with it. -it.skipIf(isPlatform("win32"))( - "starts without a worker when the worker cannot read its own identity, and launches one once it can", +// #8810: a worker that cannot start must not take the daemon down. An override that is not a +// protocol version makes the worker fail before it writes its record, on every platform. +it("starts without a worker when the worker cannot start, and launches one once it can", async () => { + const paseoHome = createTemporaryHome(); + const previous = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = "not-a-version"; + try { + manager = await startRealWorkerManager({ paseoHome }); + expect(await workerRecords(paseoHome)).toEqual([]); + await expect( + manager.createTerminal({ workspaceId: "ws-no-worker", cwd: paseoHome }), + ).rejects.toThrow(TerminalWorkerLaunchError); + } finally { + if (previous === undefined) delete process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + else process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = previous; + } + + trackTerminal(await manager.createTerminal({ workspaceId: "ws-no-worker", cwd: paseoHome })); + expect(await workerRecords(paseoHome)).toHaveLength(1); +}, 30000); + +function echoTerminalCommand(prefix: string) { + return nodeTerminalCommand(` + process.stdin.on("data", (chunk) => { + process.stdout.write(${JSON.stringify(prefix)} + chunk.toString()); + }); + setInterval(() => {}, 1000); + `); +} + +async function expectEcho(session: TerminalSession, prefix: string, input: string): Promise { + const messages: string[] = []; + const unsubscribe = session.subscribe((message) => { + if (message.type === "output") messages.push(message.data); + }); + session.send({ type: "input", data: `${input}\r` }); + try { + await waitForCondition(() => messages.join("").includes(`${prefix}${input}`), 10000); + } finally { + unsubscribe(); + } +} + +it("runs terminals without recording a worker process identity", async () => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + const session = trackTerminal( + await manager.createTerminal({ + workspaceId: "ws-no-identity", + cwd: paseoHome, + ...echoTerminalCommand("no-identity:"), + }), + ); + await expectEcho(session, "no-identity:", "hello"); + + const [record, ...others] = await workerRecords(paseoHome); + expect(others).toEqual([]); + expect(record!.identity).toBeUndefined(); + expect(record!.terminals).toEqual([ + { + id: session.id, + pid: expect.any(Number), + startedAt: process.platform === "win32" ? null : expect.any(String), + }, + ]); +}, 30000); + +it("adopts terminals over the authenticated socket after a daemon restart", async () => { + const paseoHome = createTemporaryHome(); + const first = await startRealWorkerManager({ paseoHome }); + const created = await first.createTerminal({ + workspaceId: "ws-no-identity", + cwd: paseoHome, + ...echoTerminalCommand("adopted:"), + }); + await first.detach(); + + manager = await startRealWorkerManager({ paseoHome }); + + const adopted = manager.getTerminal(created.id); + expect(adopted).toBeDefined(); + await expectEcho(adopted!, "adopted:", "after-restart"); + expect(await workerRecords(paseoHome)).toHaveLength(1); +}, 30000); + +it("closes terminals and their worker through authenticated shutdown", async () => { + const paseoHome = createTemporaryHome(); + const stopping = await startRealWorkerManager({ paseoHome }); + manager = stopping; + await stopping.createTerminal({ + workspaceId: "ws-no-identity", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const [record] = await workerRecords(paseoHome); + const programPid = record!.terminals[0]!.pid; + + await stopping.closeTerminals(); + + // The worker closed its terminals before it answered; it stops listening as it exits. + expect(isProcessAlive(programPid)).toBe(false); + expect(await listTerminalWorkerRecords(paseoHome)).toEqual([]); + await waitForCondition(() => !isProcessAlive(record!.pid), 5000); +}, 30000); + +// Startup cleanup and retries can request immediate forced termination. +it("force closes a worker from cleanup without a grace period", async () => { + const paseoHome = createTemporaryHome(); + const previous = await startRealWorkerManager({ paseoHome }); + await previous.createTerminal({ + workspaceId: "ws-no-identity", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const [record] = await workerRecords(paseoHome); + const programPid = record!.terminals[0]!.pid; + await previous.detach(); + + const result = await reapTerminalWorkers({ paseoHome, gracefulMs: 0 }); + + expect(result.signaled).toContain(record!.pid); + expect(isProcessAlive(programPid)).toBe(false); + expect(await listTerminalWorkerRecords(paseoHome)).toEqual([]); + await waitForCondition(() => !isProcessAlive(record!.pid), 5000); +}, 30000); + +it("launches past stale records without signaling an unrelated live pid", async () => { + const paseoHome = createTemporaryHome(); + // One worker died; another died and its pid now runs an unrelated process, so nothing + // listens on its socket. Neither may count against the two-worker limit or be signaled. + const exited = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((resolve) => exited.once("exit", resolve)); + const reused = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { + stdio: "ignore", + }); + try { + for (const pid of [exited.pid!, reused.pid!]) { + writeTerminalWorkerRecord(paseoHome, { + recordId: `dead-worker-${pid}`, + pid, + bootId: currentBootId(), + createdAt: new Date().toISOString(), + protocolVersion: TERMINAL_WORKER_PROTOCOL_VERSION, + paseoVersion: "0.0.0-test", + socketPath: join(paseoHome, `gone-${pid}.sock`), + secret: "secret-of-a-gone-worker", + terminals: [], + }); + } + manager = await startRealWorkerManager({ paseoHome }); + trackTerminal(await manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome })); + + const records = await workerRecords(paseoHome); + expect(records.map((record) => record.pid)).not.toContain(exited.pid); + expect(records.map((record) => record.pid)).not.toContain(reused.pid); + expect(records).toHaveLength(1); + expect(isProcessAlive(reused.pid!)).toBe(true); + } finally { + reused.kill("SIGKILL"); + } +}, 30000); + +it("does not reach a newer worker through a stale record with its reused pid", async () => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + const session = trackTerminal( + await manager.createTerminal({ + workspaceId: "stale-worker-record", + cwd: paseoHome, + ...echoTerminalCommand("alive:"), + }), + ); + const [record] = await workerRecords(paseoHome); + const staleHome = createTemporaryHome(); + writeTerminalWorkerRecord(staleHome, { + ...record!, + recordId: "stale-record", + socketPath: + process.platform === "win32" + ? `\\\\.\\pipe\\paseo-stale-${Date.now()}` + : join(staleHome, "gone.sock"), + terminals: [], + }); + expect(await reapTerminalWorkers({ paseoHome: staleHome, gracefulMs: 0 })).toMatchObject({ + retired: 1, + signaled: [], + }); + await expectEcho(session, "alive:", "untouched"); +}, 30000); + +it.skipIf(!isPlatform("win32"))( + "observes terminal program survival after a worker hard crash", async () => { const paseoHome = createTemporaryHome(); - const binDir = mkdtempSync(join(tmpdir(), "worker-terminal-manager-no-ps-")); - temporaryDirs.push(binDir); - writeFileSync(join(binDir, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); - const originalPath = process.env.PATH; - process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + const previous = await startRealWorkerManager({ paseoHome }); + await previous.createTerminal({ + workspaceId: "worker-crash", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const [record] = await workerRecords(paseoHome); + await previous.detach(); + process.kill(record!.pid, "SIGKILL"); + await waitForCondition(() => !isProcessAlive(record!.pid), 5000); + const program = record!.terminals[0]!.pid; try { - manager = await startRealWorkerManager({ paseoHome }); - expect(await workerRecords(paseoHome)).toEqual([]); - await expect( - manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome }), - ).rejects.toThrow(TerminalWorkerLaunchError); - expect(await workerRecords(paseoHome)).toEqual([]); - } finally { - process.env.PATH = originalPath; - } - - const terminal = trackTerminal( - await manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome }), - ); - expect(terminal.id).toBeTruthy(); - expect((await workerRecords(paseoHome)).map((record) => record.pid)).toHaveLength(1); + await waitForCondition(() => !isProcessAlive(program), 5000); + } catch {} + console.log(`Windows orphaned-program check: ${isProcessAlive(program) ? "survived" : "died"}`); + if (isProcessAlive(program)) process.kill(program, "SIGKILL"); }, 30000, ); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index ea91d54a66..2592b0c89c 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -21,11 +21,8 @@ import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; import { connectTerminalWorker, type TerminalWorkerConnection } from "./terminal-worker-client.js"; import { launchTerminalWorker } from "./terminal-worker-launch.js"; -import { classifyTerminalWorkerProcess } from "./terminal-worker-ownership.js"; -import { - createTerminalWorkerProcessInspector, - type TerminalWorkerProcessInspector, -} from "./terminal-worker-processes.js"; +import { identifyTerminalWorker } from "./terminal-worker-ownership.js"; +import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; import { reapTerminalWorkers } from "./terminal-worker-reaper.js"; import { listTerminalWorkerRecords, @@ -1038,18 +1035,21 @@ export async function startWorkerTerminalManager( toReap.push(file.path); continue; } - const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); - if (worker.status === "owned") { + const worker = await identifyTerminalWorker(record); + if (worker.status === "owned" || worker.status === "unknown") { + if (worker.status === "unknown") { + // Its secret still proves it: only the worker that wrote the record can answer the + // hello below. An inconclusive probe does not authorize a signal. + logger.warn( + { recordPath: file.path, pid: record.pid, reason: worker.reason }, + "Terminal worker socket probe inconclusive; attempting authentication", + ); + } live.push({ path: file.path, record, fit: classifyTerminalWorkerProtocol(record.protocolVersion), }); - } else if (worker.status === "unknown") { - logger.warn( - { recordPath: file.path, pid: record.pid, reason: worker.reason }, - "Cannot identify terminal worker; leaving its record", - ); } else { // Its programs lost their PTY with the worker and cannot be adopted. toReap.push(file.path); @@ -1142,10 +1142,7 @@ export async function startWorkerTerminalManager( const reconnect = () => { recovery ??= (async () => { while (!recoveryState.stopped) { - const ownership = classifyTerminalWorkerProcess( - record, - await inspector.inspect(record.pid), - ); + const ownership = await identifyTerminalWorker(record); if (ownership.status === "gone" || ownership.status === "unrelated") { await reapTerminalWorkers({ paseoHome: options.paseoHome, @@ -1243,7 +1240,7 @@ export async function startWorkerTerminalManager( // Counted from the records rather than from what startup drove: a worker a cleanup could not // confirm gone keeps running, and a draining one exits on its own. const startCurrent = async (): Promise => { - const running = await countRunningTerminalWorkers(options.paseoHome, inspector); + const running = await countRunningTerminalWorkers(options.paseoHome); if (running >= MAX_TERMINAL_WORKERS) { throw new NoTerminalWorkerAvailableError( `${running} terminal workers are already running in ${options.paseoHome}; new terminals can open once one of them exits`, @@ -1273,8 +1270,8 @@ export async function startWorkerTerminalManager( logger.warn(error.message); } else { // The daemon starts without terminals rather than not at all. A worker that cannot start - // here (its process identity query failed, say) is launched again by the first terminal - // creation, which reports the same error if it still cannot. + // here is launched again by the first terminal creation, which reports the same error + // if it still cannot. logger.error( { err: error }, "Terminal worker did not start; terminals are unavailable until one starts", @@ -1298,11 +1295,9 @@ export async function startWorkerTerminalManager( // Every worker that may still run in this home, one per record: a worker and its cleanup record // are listed as one. Unreadable records and workers that cannot be identified count; a worker -// confirmed gone, or a pid reused by another process, does not. -async function countRunningTerminalWorkers( - paseoHome: string, - inspector: TerminalWorkerProcessInspector, -): Promise { +// confirmed gone, or a pid now running something else (another birth time, or nothing listening +// on the worker's socket), does not. +async function countRunningTerminalWorkers(paseoHome: string): Promise { let running = 0; for (const file of await listTerminalWorkerRecords(paseoHome)) { if ("error" in file) { @@ -1311,7 +1306,7 @@ async function countRunningTerminalWorkers( } const { record } = file; if (!writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId })) continue; - const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + const worker = await identifyTerminalWorker(record); if (worker.status === "owned" || worker.status === "unknown") running += 1; } return running; From 471db4bdb5c8bd2febf97bb7c9818595f3048e6c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 20:55:16 +0700 Subject: [PATCH 14/32] Keep workspace tabs predictable and clear attention on deliberate focus (#8935) * fix(app): preserve workspace tabs and acknowledge deliberate agent focus Workspace navigation no longer chooses an attention agent. Preserve repeated focus actions so an already selected chat can be acknowledged, and stop consuming attention on departure. * fix(app): retain deliberate focus until fresh agent data arrives Connection readiness permits a pending focus acknowledgement without creating a new focus action. Keep that intent across client changes and wait for the current timeline synchronization generation before consuming it. * fix(app): preserve reconnect feedback during attention acknowledgement --- docs/agent-lifecycle.md | 9 +- docs/expo-router.md | 2 +- .../browser/agent-timeline-sync-retry.spec.ts | 2 + .../sidebar-workspace-mark-unread.spec.ts | 94 ++++- .../src/hooks/use-agent-attention-clear.ts | 124 +++---- packages/app/src/panels/agent-panel.tsx | 45 +-- .../screens/workspace/workspace-screen.tsx | 4 +- .../index.ts | 5 +- .../navigation.test.ts | 31 +- .../navigation.ts | 27 +- .../src/stores/workspace-layout-store.test.ts | 33 ++ .../app/src/stores/workspace-layout-store.ts | 48 ++- .../app/src/utils/agent-attention.test.ts | 350 +++++------------- packages/app/src/utils/agent-attention.ts | 118 +++--- 14 files changed, 383 insertions(+), 509 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index c0aad974f6..26c9df8823 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -334,10 +334,13 @@ Running provider-native subagents contribute `running` to the workspace owned by A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is -`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow. +`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores +its selected tab; attention on another agent remains unread until that agent is focused or the +workspace is explicitly marked read. -Attention is set by the agent finishing or failing and cleared by the client's -`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is +Attention is set by the agent finishing or failing and acknowledged by deliberate focus or +interaction with its chat, including repeat clicks in an already focused pane. A completion +arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is neither, so resuming carries the stored attention and the stored last-activity time through untouched. Forging either makes a background resume look like the user read a workspace and like the agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted diff --git a/docs/expo-router.md b/docs/expo-router.md index 9a9e5e3634..f81c194ffb 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back into a host workspace, use `navigateToWorkspace()`. Do not make the caller branch on its current route. -Pass only `serverId` and `workspaceId` for normal attention-aware navigation. +Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab. When the action names a specific tab, pass it as `target`; that explicit choice is authoritative. Callers should not choose between separate route and tab navigation APIs. diff --git a/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts b/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts index 2f3fcab53a..f68efffd02 100644 --- a/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts @@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => { const gate = await installDaemonWebSocketGate(page); const agent = await seedLongMockAgentTimeline({ turns: 2 }); try { + gate.holdNextClientRequest("clear_agent_attention"); await openAgentTimeline(page, agent); await expectTimelinePromptVisible(page, agent.newestPrompt); + await gate.waitForHeldClientRequest(); gate.failTimelineResponses(agent.agentId); await disconnectViewedTimeline(page, gate); diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index ccf29179b8..a2a695af5f 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -1,12 +1,13 @@ import type { Page } from "@playwright/test"; import { test as base, expect } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; import { expectOpenTabCount } from "../support/helpers/sheet"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { expectReconnectingToastGone } from "../support/helpers/workspace-ui"; import { getServerId } from "../support/helpers/server-id"; import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; @@ -101,11 +102,13 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin } async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) { - await test.step("ordinary completion still clears on departure", async () => { + await test.step("ordinary completion stays unread on departure and clears on return", async () => { await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); await subject.client.waitForFinish(subject.agentId, 20_000); await expectStatus(page, subject.workspaceId, "attention"); await openWorkspace(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspace(page, subject.workspaceId); await expectStatus(page, subject.workspaceId, "done"); }); } @@ -141,13 +144,15 @@ async function leaveMarkedWorkspaceAndReopenOnCompact( } async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) { - await test.step("ordinary completion still clears on departure", async () => { + await test.step("ordinary completion stays unread on departure and clears on return", async () => { await closeMobileAgentSidebar(page); await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); await subject.client.waitForFinish(subject.agentId, 20_000); await openMobileAgentSidebar(page); await expectStatus(page, subject.workspaceId, "attention"); await openWorkspaceOnCompact(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspaceOnCompact(page, subject.workspaceId); await expectStatus(page, subject.workspaceId, "done"); }); } @@ -194,11 +199,12 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) { }); } -test("manual unread survives departure and clears on reopening without changing normal completions", async ({ +test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({ page, workspaces, }) => { - await gotoAppShell(page); + await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await openWorkspace(page, workspaces.other.workspaceId); await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); await leaveMarkedWorkspaceAndRead(page, workspaces); @@ -207,16 +213,19 @@ test("manual unread survives departure and clears on reopening without changing await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId); }); -test("clicking a multi-agent workspace reveals and clears its marked agent", async ({ +test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({ page, workspaces, }) => { await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await expectSelectedAgent(page, workspaces.subject.agentId); await openWorkspace(page, workspaces.other.workspaceId); - const newest = await addFinishedAgent(workspaces.subject); - await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); - await expectSelectedAgent(page, newest.id); + await addFinishedAgent(workspaces.subject); + await markAsUnread(page, workspaces.subject.workspaceId); + await openWorkspace(page, workspaces.subject.workspaceId); + await expectSelectedAgent(page, workspaces.subject.agentId); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); }); test("manual unread survives leaving the current workspace on compact layout", async ({ @@ -230,7 +239,7 @@ test("manual unread survives leaving the current workspace on compact layout", a await completeTurnAndLeaveOnCompact(page, workspaces); }); -async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { +async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { const route = page.url(); // End this app session before the turn, so its cached transcript cannot contain the new prompt. await page.goto("about:blank"); @@ -265,9 +274,72 @@ test("reopening a marked unread workspace restores only its agent tab and curren await test.step("mark the current workspace unread, then close and reopen the app", async () => { await openMobileAgentSidebar(page); await markAsUnread(page, workspaces.subject.workspaceId); - await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject); + await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject); }); await test.step("restore the main tab without Explorer entries and load its latest history", async () => { await expectRestoredCompactChat(page); }); }); + +test("clicking the already focused timeline acknowledges a new completion", async ({ + page, + workspaces, +}) => { + await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + await workspaces.subject.client.sendAgentMessage( + workspaces.subject.agentId, + "Finish another turn.", + ); + await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); + await page.getByText("Finish another turn.", { exact: true }).click(); + await expectStatus(page, workspaces.subject.workspaceId, "done"); +}); + +async function reconnectWithoutInteracting( + page: Page, + gate: Awaited>, + agentId: string, +) { + await gate.drop(); + await gate.waitForBlockedConnection(); + gate.holdTimelineResponses(agentId); + gate.restore(); + await gate.waitForHeldTimelineResponse(); + await expectReconnectingToastGone(page); + gate.releaseHeldTimelineResponses(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0); +} + +test("reconnecting a focused chat preserves its unread completion until another click", async ({ + page, + workspaces, +}) => { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + await workspaces.subject.client.sendAgentMessage( + workspaces.subject.agentId, + "Unread across reconnect.", + ); + await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); + await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); + await page.getByText("Unread across reconnect.", { exact: true }).click(); + await expectStatus(page, workspaces.subject.workspaceId, "done"); +}); + +test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({ + page, + workspaces, +}) => { + await openCompactWorkspace(page, workspaces.subject); + await expectStatus(page, workspaces.subject.workspaceId, "done"); + await closeMobileAgentSidebar(page); + await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject); + await expectRestoredCompactChat(page); + await openMobileAgentSidebar(page); + await expectStatus(page, workspaces.subject.workspaceId, "done"); +}); diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts index e1501e0dc9..16a4d77ddb 100644 --- a/packages/app/src/hooks/use-agent-attention-clear.ts +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AppState } from "react-native"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { - shouldClearAgentAttention, - type AgentAttentionClearTrigger, -} from "@/utils/agent-attention"; +import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createAgentAttentionController } from "@/utils/agent-attention"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { isWeb } from "@/constants/platform"; +import { useStableEvent } from "@/hooks/use-stable-event"; type AttentionReason = "finished" | "error" | "permission" | null | undefined; @@ -14,54 +12,48 @@ interface UseAgentAttentionClearParams { agentId: string | null | undefined; client: DaemonClient | null; isConnected: boolean; - requiresAttention: boolean | null | undefined; + isAgentDataReady: boolean; + requiresAttention: boolean; attentionReason: AttentionReason; isScreenFocused: boolean; + focusRequest: object | null; + onError: (error: unknown) => void; } interface AgentAttentionClearController { clearOnInputFocus: () => void; clearOnPromptSend: () => void; - clearOnAgentBlur: () => void; } export function useAgentAttentionClear({ agentId, client, isConnected, + isAgentDataReady, requiresAttention, attentionReason, isScreenFocused, + focusRequest, + onError, }: UseAgentAttentionClearParams): AgentAttentionClearController { const [isAppVisible, setIsAppVisible] = useState(() => getIsAppActivelyVisible()); - const deferredFocusEntryClearRef = useRef(false); - const prevRequiresAttentionRef = useRef(Boolean(requiresAttention)); - const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible()); - const prevScreenFocusedRef = useRef(false); - const prevAppVisibleRef = useRef(getIsAppActivelyVisible()); - - const clearAttention = useCallback( - (trigger: AgentAttentionClearTrigger) => { - const resolvedAgentId = agentId?.trim(); - if (!client || !resolvedAgentId) { - return; - } - if ( - !shouldClearAgentAttention({ - agentId: resolvedAgentId, - isConnected, - requiresAttention, - attentionReason, - trigger, - hasDeferredFocusEntryClear: deferredFocusEntryClearRef.current, - }) - ) { - return; - } - deferredFocusEntryClearRef.current = false; - client.clearAgentAttention(resolvedAgentId).catch(() => {}); - }, - [agentId, attentionReason, client, isConnected, requiresAttention], + const clearAttention = useStableEvent(async (id: string) => { + if (client) await client.clearAgentAttention(id); + }); + const reportError = useStableEvent((error: unknown) => { + // The timeline's reconnect status already explains a lost connection. + if (error instanceof DaemonConnectionError && error.code === "DAEMON_CONNECTION_LOST") return; + onError(error); + }); + const controller = useMemo( + () => + createAgentAttentionController({ + clear: async () => { + if (agentId) await clearAttention(agentId); + }, + onError: reportError, + }), + [agentId, clearAttention, reportError], ); useEffect(() => { @@ -90,46 +82,28 @@ export function useAgentAttentionClear({ }, []); useEffect(() => { - if (!requiresAttention) { - deferredFocusEntryClearRef.current = false; - } - }, [requiresAttention]); - - useEffect(() => { - const isActivelyViewed = isScreenFocused && isAppVisible; - if ( - !prevRequiresAttentionRef.current && - Boolean(requiresAttention) && - prevActivelyViewedRef.current && - isActivelyViewed - ) { - deferredFocusEntryClearRef.current = true; - } - prevRequiresAttentionRef.current = Boolean(requiresAttention); - prevActivelyViewedRef.current = isActivelyViewed; - }, [isAppVisible, isScreenFocused, requiresAttention]); - - useEffect(() => { - const enteredScreenFocus = !prevScreenFocusedRef.current && isScreenFocused && isAppVisible; - const resumedIntoFocusedAgent = !prevAppVisibleRef.current && isAppVisible && isScreenFocused; - - if (enteredScreenFocus || resumedIntoFocusedAgent) { - clearAttention("focus-entry"); - } - - prevScreenFocusedRef.current = isScreenFocused; - prevAppVisibleRef.current = isAppVisible; - }, [clearAttention, isAppVisible, isScreenFocused]); + controller.update({ + focused: isScreenFocused && isAppVisible, + ready: Boolean(client && agentId && isConnected && isAgentDataReady), + requiresAttention, + attentionReason, + focusRequest, + }); + }, [ + controller, + client, + agentId, + isConnected, + isAgentDataReady, + isScreenFocused, + isAppVisible, + requiresAttention, + attentionReason, + focusRequest, + ]); return { - clearOnInputFocus: useCallback(() => { - clearAttention("input-focus"); - }, [clearAttention]), - clearOnPromptSend: useCallback(() => { - clearAttention("prompt-send"); - }, [clearAttention]), - clearOnAgentBlur: useCallback(() => { - clearAttention("agent-blur"); - }, [clearAttention]), + clearOnInputFocus: controller.focus, + clearOnPromptSend: controller.focus, }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1373a5d283..c610ec2410 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -677,8 +677,6 @@ function ChatAgentContent({ const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); - const clearOnAgentBlurRef = useRef<() => void>(() => {}); - const wasPaneFocusedRef = useRef(isPaneFocused); const initAttemptTokenRef = useRef(0); const routeBottomAnchorRequestRef = useRef<{ routeKey: string; @@ -753,35 +751,31 @@ function ChatAgentContent({ const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted"; + const focusRequest = useWorkspaceLayoutStore((state) => { + const request = state.focusRequest; + return request?.workspaceKey === `${serverId}:${workspaceId}` && request.tabId === tabId + ? request + : null; + }); + const reportAttentionError = useCallback( + (error: unknown) => { + toastApi.show(String(error), { variant: "error", durationMs: null }); + }, + [toastApi], + ); const attentionController = useAgentAttentionClear({ agentId, client, isConnected, + isAgentDataReady: agentHistorySyncGeneration === historySyncGeneration, requiresAttention: agentState.requiresAttention, attentionReason: agentState.attentionReason, isScreenFocused: isPaneFocused, + focusRequest, + onError: reportAttentionError, }); - useEffect(() => { - clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; - }, [attentionController.clearOnAgentBlur]); - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); - useEffect(() => { - if (wasPaneFocusedRef.current && !isPaneFocused) { - clearOnAgentBlurRef.current(); - } - wasPaneFocusedRef.current = isPaneFocused; - }, [isPaneFocused]); - - useEffect(() => { - return () => { - if (wasPaneFocusedRef.current) { - clearOnAgentBlurRef.current(); - } - }; - }, []); - const isInitializing = agentId ? isInitializingFromMap : false; const isHistorySyncing = useMemo(() => { if (!agentId || !isInitializing) { @@ -1185,8 +1179,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : ( agentComposerSection ); + const handleAttentionInteraction = useCallback(() => { + onAttentionInputFocus(); + return false; + }, [onAttentionInputFocus]); const streamContent = ( - + {showsComposer ? null : } { - if (!isPaneFocused && paneId) onFocusPane(paneId); + if (paneId) onFocusPane(paneId); return false; - }, [isPaneFocused, onFocusPane, paneId]); + }, [onFocusPane, paneId]); return ( diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index def8c73cc4..2b6fbff11f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -37,12 +37,11 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( function navigateDeps(): NavigateToWorkspaceDeps { return { revealTab: revealWorkspaceTab, - revealWorkspace: () => { + revealWorkspace: (workspaceKey) => { + useWorkspaceLayoutStore.getState().focusWorkspace(workspaceKey); usePanelStore.getState().showMobileAgent(); }, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, - getSessionAgents: (serverId) => - useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(), deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input), openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input), diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index a3bb69821a..c3cf58a3bd 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -9,7 +9,7 @@ import { type NavigateToLastWorkspaceDeps, type NavigateToWorkspaceDeps, } from "./navigation"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; interface RecordedTab { workspaceKey: string; @@ -35,7 +35,6 @@ function createFakeDeps(overrides: Partial = {}) { return null; }, getSessionWorkspaces: () => null, - getSessionAgents: () => [] as Agent[], isWorkspaceLayoutHydrated: () => true, openTab: ({ workspaceKey, target, pin = false }) => { openedTabs.push({ workspaceKey, target, pin }); @@ -105,49 +104,27 @@ describe("workspace navigation", () => { expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]); }); - it("focuses the attention agent's tab when a workspace has one", () => { + it("reveals the workspace without choosing a different tab", () => { const workspace = { id: "workspace-a", workspaceDirectory: "/repo/workspace-a", } as WorkspaceDescriptor; - const agent = { - id: "agent-1", - cwd: "/repo/workspace-a", - workspaceId: "workspace-a", - requiresAttention: true, - attentionReason: "permission", - } as unknown as Agent; const { deps, openedTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), - getSessionAgents: () => [agent], }); navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps); - expect(openedTabs).toEqual([ - { - workspaceKey: "server-1:workspace-a", - target: { kind: "agent", agentId: "agent-1" }, - pin: false, - }, - ]); + expect(openedTabs).toEqual([]); }); - it("keeps an explicit tab authoritative over an attention agent", () => { + it("opens an explicitly requested tab", () => { const workspace = { id: "workspace-a", workspaceDirectory: "/repo/workspace-a", } as WorkspaceDescriptor; - const agent = { - id: "agent-1", - cwd: "/repo/workspace-a", - workspaceId: "workspace-a", - requiresAttention: true, - attentionReason: "permission", - } as unknown as Agent; const { deps, openedTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), - getSessionAgents: () => [agent], }); navigateToWorkspace( diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index d856a9bb36..6d94ed81a3 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -1,14 +1,10 @@ -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; -import { pickAttentionAgent } from "@/utils/agent-attention"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; -import { - normalizeWorkspaceOpaqueId, - resolveWorkspaceMapKeyByIdentity, -} from "@/utils/workspace-identity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab"; @@ -33,13 +29,12 @@ export interface NavigateToWorkspaceInput { export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { getSessionWorkspaces: (serverId: string) => Map | null | undefined; - getSessionAgents: (serverId: string) => Iterable; isWorkspaceLayoutHydrated: () => boolean; deferOpenTab: (input: OpenWorkspaceTabInput) => null; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; revealTab: (workspaceKey: string, tabId: string | null) => void; - revealWorkspace: () => void; + revealWorkspace: (workspaceKey: string) => void; } export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps { @@ -104,21 +99,7 @@ export function navigateToWorkspace( if (input.target) { prepareWorkspaceTab({ ...input, target: input.target }, { openTab }); } else { - const workspaceAgents = resolvedWorkspaceId - ? Array.from(deps.getSessionAgents(input.serverId)).filter( - (agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId, - ) - : []; - const attentionAgentId = pickAttentionAgent(workspaceAgents); - if (attentionAgentId && resolvedWorkspaceId) { - openTab({ - workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`, - target: { kind: "agent", agentId: attentionAgentId }, - intent: "reveal", - }); - } else { - deps.revealWorkspace(); - } + deps.revealWorkspace(`${input.serverId}:${resolvedWorkspaceId ?? input.workspaceId}`); } const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 8b478b7d27..2c3e6f15a4 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -5244,3 +5244,36 @@ describe("pending workspace opens", () => { expect(store.getState().pendingOpenByWorkspace).toEqual({}); }); }); + +it("records repeated deliberate focus even when the selected pane and tab do not change", () => { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + const workspaceKey = "server:focus"; + const tabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "agent" }, intent: "reveal" })!; + const paneId = store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId!; + for (const focus of [ + () => store.getState().focusPane(workspaceKey, paneId), + () => store.getState().focusTab(workspaceKey, tabId), + () => store.getState().selectTabInPane(workspaceKey, paneId, tabId), + ]) { + focus(); + const first = store.getState().focusRequest; + focus(); + expect(store.getState().focusRequest).toEqual({ workspaceKey, tabId }); + expect(store.getState().focusRequest).not.toBe(first); + } +}); + +it("reentering a workspace restores its suspended pane without choosing another tab", () => { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + const workspaceKey = "server:resume"; + const tabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "agent" }, intent: "reveal" })!; + const paneId = store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId; + store.getState().unfocusPane(workspaceKey); + store.getState().focusWorkspace(workspaceKey); + expect(store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId).toBe(paneId); + expect(store.getState().focusRequest).toEqual({ workspaceKey, tabId }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 43387bc743..f66650c7ca 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -128,6 +128,8 @@ export type WorkspaceTabDestination = | { kind: "sheet"; tabId: string }; interface WorkspaceLayoutStore { + /** Deliberate focus, including reselecting the current tab. Never persisted. */ + focusRequest: { workspaceKey: string; tabId: string } | null; panelRevisionByWorkspace: Record>; /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; @@ -210,6 +212,7 @@ interface WorkspaceLayoutStore { * (archiving agents, killing terminals) first. No-ops on the last visible pane. */ closePane: (workspaceKey: string, paneId: string) => void; + focusWorkspace: (workspaceKey: string) => void; focusPane: (workspaceKey: string, paneId: string) => void; unfocusPane: (workspaceKey: string) => string | null; restorePaneFocus: (workspaceKey: string, token: string) => void; @@ -1083,6 +1086,7 @@ export function createWorkspaceLayoutStore( }, layoutMode: "split-panes", sheetsByWorkspace: {}, + focusRequest: null, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -1135,6 +1139,7 @@ export function createWorkspaceLayoutStore( }); set((state) => ({ ...withSheets(state, normalizedWorkspaceKey, next), + focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: sheet.tab.tabId }, ...advancePanelRevision( state, normalizedWorkspaceKey, @@ -1198,6 +1203,9 @@ export function createWorkspaceLayoutStore( set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), + ...(input.intent === "background" + ? {} + : { focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: result.tabId } }), // A tab brought to the front dismisses the sheet over it. ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: @@ -1506,18 +1514,16 @@ export function createWorkspaceLayoutStore( } else { nextLayout = focusTabInLayout({ layout, tabId: normalizedTabId }); } - if (!nextLayout) { - return state; - } + if (!tabPane) return state; return { + focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: normalizedTabId }, ...withoutFocusRestoration(state, normalizedWorkspaceKey), // Choosing a tab dismisses the sheets that cover it. ...dismissSheets(state, normalizedWorkspaceKey), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, - }, + layoutByWorkspace: nextLayout + ? { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout } + : state.layoutByWorkspace, }; }); }, @@ -1535,13 +1541,15 @@ export function createWorkspaceLayoutStore( paneId: normalizedPaneId, tabId: normalizedTabId, }); - return nextLayout + const pane = findPaneById(layout.root, normalizedPaneId); + return pane?.tabIds.includes(normalizedTabId) ? { + focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: normalizedTabId }, // Choosing a tab dismisses the sheets that cover it. ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, + [normalizedWorkspaceKey]: nextLayout ?? layout, }, } : state; @@ -1971,6 +1979,13 @@ export function createWorkspaceLayoutStore( }; }); }, + focusWorkspace: (workspaceKey) => { + const state = get(); + const paneId = + state.layoutByWorkspace[workspaceKey]?.focusedPaneId ?? + state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId; + if (paneId) state.focusPane(workspaceKey, paneId); + }, focusPane: (workspaceKey, paneId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedPaneId = trimNonEmpty(paneId); @@ -1991,16 +2006,17 @@ export function createWorkspaceLayoutStore( layout, paneId: normalizedPaneId, }); - if (!nextLayout) { - return state; - } + const pane = findPaneById(layout.root, normalizedPaneId); + if (!pane) return state; return { + focusRequest: pane.focusedTabId + ? { workspaceKey: normalizedWorkspaceKey, tabId: pane.focusedTabId } + : null, ...withoutFocusRestoration(state, normalizedWorkspaceKey), - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: nextLayout, - }, + layoutByWorkspace: nextLayout + ? { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout } + : state.layoutByWorkspace, }; }); }, diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index ac5a6f84f3..dcc8d6e197 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -1,269 +1,117 @@ import { describe, expect, it } from "vitest"; -import type { Agent } from "@/stores/session-store"; -import { pickAttentionAgent, shouldClearAgentAttention } from "@/utils/agent-attention"; +import { createAgentAttentionController } from "./agent-attention"; -function createAgent(input: Partial & Pick): Agent { - const { id, ...rest } = input; - return { - serverId: "server-1", - id, - provider: "codex", - status: "idle", - createdAt: new Date("2026-01-01T00:00:00.000Z"), - updatedAt: new Date("2026-01-01T00:00:00.000Z"), - lastUserMessageAt: null, - lastActivityAt: new Date("2026-01-01T00:00:00.000Z"), - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: null, - cwd: "/repo/worktree", - model: null, - parentAgentId: null, - labels: {}, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - ...rest, - turn: rest.turn ?? { phase: "idle", cancellationRequestId: null }, - }; -} +describe("agent attention acknowledgement", () => { + function setup() { + const calls: string[] = []; + const errors: unknown[] = []; + const controller = createAgentAttentionController({ + clear: async () => { + calls.push("clear"); + }, + onError: (failure) => errors.push(failure), + }); + const state = { + focused: true, + ready: true, + requiresAttention: false, + attentionReason: "finished" as const, + focusRequest: null as object | null, + }; + return { controller, state, calls, errors }; + } -describe("shouldClearAgentAttention", () => { - it("returns true only when the agent is connected and requires attention", () => { - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: true, - attentionReason: "finished", - }), - ).toBe(true); + it("acknowledges entry, but keeps attention arriving while focused until another focus action", async () => { + const { controller, state, calls } = setup(); + controller.update(state); + state.requiresAttention = true; + controller.update(state); + expect(calls).toEqual([]); + controller.update({ ...state, focusRequest: {} }); + expect(calls).toEqual(["clear"]); + await Promise.resolve(); + controller.update({ ...state, focusRequest: {} }); + expect(calls).toEqual(["clear", "clear"]); }); - it("returns false when the app is disconnected", () => { - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: false, - requiresAttention: true, - attentionReason: "finished", - }), - ).toBe(false); + it("clears on entry and never clears on leaving", async () => { + const { controller, state, calls } = setup(); + controller.update({ ...state, requiresAttention: true }); + expect(calls).toEqual(["clear"]); + await Promise.resolve(); + controller.update({ ...state, focused: false, requiresAttention: true }); + expect(calls).toEqual(["clear"]); + controller.update({ ...state, requiresAttention: true }); + expect(calls).toEqual(["clear", "clear"]); }); - it("returns false when attention is already clear", () => { - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: false, - attentionReason: null, - }), - ).toBe(false); + it("waits for connection and agent data before consuming entry", () => { + const { controller, state, calls } = setup(); + controller.update({ ...state, ready: false }); + controller.update({ ...state, requiresAttention: true }); + expect(calls).toEqual(["clear"]); }); - it("returns false for empty agent ids", () => { - expect( - shouldClearAgentAttention({ - agentId: "", - isConnected: true, - requiresAttention: true, - attentionReason: "finished", - }), - ).toBe(false); + it("does not treat reconnecting as another focus action", () => { + const { controller, state, calls } = setup(); + controller.update(state); + controller.update({ ...state, requiresAttention: true }); + controller.update({ ...state, requiresAttention: true, ready: false }); + controller.update({ ...state, requiresAttention: true }); + expect(calls).toEqual([]); + controller.focus(); + expect(calls).toEqual(["clear"]); }); - it("returns false for permission-shaped attention", () => { - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: true, - attentionReason: "permission", - }), - ).toBe(false); + it("holds a repeat focus while cached data is waiting for synchronization", () => { + const { controller, state, calls } = setup(); + controller.update(state); + controller.update({ ...state, ready: false }); + controller.focus(); + controller.update({ ...state, ready: false, requiresAttention: true }); + expect(calls).toEqual([]); + controller.update({ ...state, requiresAttention: true }); + expect(calls).toEqual(["clear"]); }); - it("returns false for focus entry when clear was deferred while already focused", () => { - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: true, - attentionReason: "finished", - trigger: "focus-entry", - hasDeferredFocusEntryClear: true, - }), - ).toBe(false); + it("drops a pending focus when the user leaves before fresh data arrives", () => { + const { controller, state, calls } = setup(); + controller.update({ ...state, ready: false }); + controller.update({ ...state, focused: false, requiresAttention: true }); + expect(calls).toEqual([]); }); - it("returns true for explicit follow-up entrypoints after deferred focus clear", () => { - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: true, - attentionReason: "finished", - trigger: "input-focus", - hasDeferredFocusEntryClear: true, - }), - ).toBe(true); + it("keeps permission attention and deduplicates an in-flight acknowledgement", () => { + const { controller, state, calls } = setup(); + controller.update({ ...state, requiresAttention: true, attentionReason: "permission" }); + controller.focus(); + expect(calls).toEqual([]); + controller.update({ ...state, requiresAttention: true }); + controller.focus(); + controller.focus(); + expect(calls).toEqual(["clear"]); + }); - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: true, - attentionReason: "finished", - trigger: "prompt-send", - hasDeferredFocusEntryClear: true, - }), - ).toBe(true); - - expect( - shouldClearAgentAttention({ - agentId: "agent-1", - isConnected: true, - requiresAttention: true, - attentionReason: "finished", - trigger: "agent-blur", - hasDeferredFocusEntryClear: true, - }), - ).toBe(true); - }); -}); - -describe("pickAttentionAgent", () => { - it("returns null for empty input", () => { - expect(pickAttentionAgent([])).toBeNull(); - }); - - it("returns null when no agent requires attention", () => { - expect( - pickAttentionAgent([ - createAgent({ id: "agent-1", requiresAttention: false, attentionReason: "permission" }), - createAgent({ id: "agent-2", requiresAttention: false, attentionReason: null }), - ]), - ).toBeNull(); - }); - - it("returns the single permission agent", () => { - expect( - pickAttentionAgent([ - createAgent({ - id: "agent-1", - requiresAttention: true, - attentionReason: "permission", - attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"), - }), - ]), - ).toBe("agent-1"); - }); - - it("prioritizes permission over error over finished", () => { - expect( - pickAttentionAgent([ - createAgent({ - id: "finished-agent", - requiresAttention: true, - attentionReason: "finished", - attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), - }), - createAgent({ - id: "error-agent", - requiresAttention: true, - attentionReason: "error", - attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"), - }), - createAgent({ - id: "permission-agent", - requiresAttention: true, - attentionReason: "permission", - attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"), - }), - ]), - ).toBe("permission-agent"); - }); - - it("breaks ties by oldest attention timestamp", () => { - expect( - pickAttentionAgent([ - createAgent({ - id: "newer-permission-agent", - requiresAttention: true, - attentionReason: "permission", - attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"), - }), - createAgent({ - id: "older-permission-agent", - requiresAttention: true, - attentionReason: "permission", - attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), - }), - ]), - ).toBe("older-permission-agent"); - }); - - it("ignores subagents even when they require attention", () => { - expect( - pickAttentionAgent([ - createAgent({ - id: "subagent", - parentAgentId: "parent-agent", - requiresAttention: true, - attentionReason: "permission", - attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), - }), - createAgent({ - id: "top-level-agent", - requiresAttention: true, - attentionReason: "finished", - attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"), - }), - ]), - ).toBe("top-level-agent"); - }); - - it("returns null when only subagents require attention", () => { - expect( - pickAttentionAgent([ - createAgent({ - id: "subagent", - parentAgentId: "parent-agent", - requiresAttention: true, - attentionReason: "permission", - attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), - }), - ]), - ).toBeNull(); - }); - - it("ignores agents with requiresAttention true but null reason", () => { - expect( - pickAttentionAgent([ - createAgent({ - id: "ignored-agent", - requiresAttention: true, - attentionReason: null, - attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"), - }), - createAgent({ - id: "error-agent", - requiresAttention: true, - attentionReason: "error", - attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"), - }), - ]), - ).toBe("error-agent"); + it("reports failure and allows the next interaction to retry", async () => { + const errors: unknown[] = []; + let calls = 0; + const error = new Error("Disconnected"); + const controller = createAgentAttentionController({ + clear: async () => { + if (++calls === 1) throw error; + }, + onError: (failure) => errors.push(failure), + }); + controller.update({ + focused: true, + ready: true, + requiresAttention: true, + attentionReason: "finished", + focusRequest: null, + }); + await Promise.resolve(); + expect(errors).toEqual([error]); + controller.focus(); + expect(calls).toBe(2); }); }); diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts index a3e5df6279..c90e3d94a6 100644 --- a/packages/app/src/utils/agent-attention.ts +++ b/packages/app/src/utils/agent-attention.ts @@ -1,81 +1,49 @@ -import type { Agent } from "@/stores/session-store"; - -interface ShouldClearAgentAttentionInput { - agentId: string | null | undefined; - isConnected: boolean; - requiresAttention: boolean | null | undefined; - attentionReason?: "finished" | "error" | "permission" | null | undefined; - trigger?: AgentAttentionClearTrigger; - hasDeferredFocusEntryClear?: boolean; +interface AttentionState { + focused: boolean; + ready: boolean; + requiresAttention: boolean; + attentionReason: "finished" | "error" | "permission" | null | undefined; + focusRequest: object | null; } -export type AgentAttentionClearTrigger = - | "focus-entry" - | "input-focus" - | "prompt-send" - | "agent-blur"; +export function createAgentAttentionController(deps: { + clear: () => Promise; + onError: (error: unknown) => void; +}) { + let state: AttentionState | null = null; + let inFlight = false; + let pendingFocus = false; -const ATTENTION_REASON_PRIORITY = { - permission: 0, - error: 1, - finished: 2, -} as const; - -function getAttentionPriority(reason: Agent["attentionReason"]): number | null { - if (!reason) { - return null; + function acknowledgeFocus() { + if (!pendingFocus || !state?.focused || !state.ready) return; + pendingFocus = false; + if (!state.requiresAttention || state.attentionReason === "permission" || inFlight) return; + inFlight = true; + void deps.clear().then( + () => { + inFlight = false; + return undefined; + }, + (error: unknown) => { + inFlight = false; + deps.onError(error); + }, + ); } - return ATTENTION_REASON_PRIORITY[reason]; -} -export function pickAttentionAgent(agents: Agent[]): string | null { - let selectedAgentId: string | null = null; - let selectedPriority = Number.POSITIVE_INFINITY; - let selectedTimestamp = Number.POSITIVE_INFINITY; - - for (const agent of agents) { - if (agent.requiresAttention !== true) { - continue; - } - - if (agent.parentAgentId) { - continue; - } - - const priority = getAttentionPriority(agent.attentionReason); - if (priority === null) { - continue; - } - - const timestamp = agent.attentionTimestamp?.getTime() ?? Number.POSITIVE_INFINITY; - const isHigherPriority = priority < selectedPriority; - const isOlderAtSamePriority = priority === selectedPriority && timestamp < selectedTimestamp; - if (isHigherPriority || isOlderAtSamePriority) { - selectedAgentId = agent.id; - selectedPriority = priority; - selectedTimestamp = timestamp; - } - } - - return selectedAgentId; -} - -export function shouldClearAgentAttention(input: ShouldClearAgentAttentionInput): boolean { - const agentId = input.agentId?.trim(); - if (!agentId) { - return false; - } - if (!input.isConnected) { - return false; - } - if (!input.requiresAttention) { - return false; - } - if (input.attentionReason === "permission") { - return false; - } - if (input.trigger === "focus-entry" && input.hasDeferredFocusEntryClear === true) { - return false; - } - return true; + return { + focus() { + if (!state?.focused) return; + pendingFocus = true; + acknowledgeFocus(); + }, + update(next: AttentionState) { + const entered = next.focused && !state?.focused; + const requested = next.focusRequest !== null && next.focusRequest !== state?.focusRequest; + state = { ...next }; + if (!next.focused) pendingFocus = false; + else if (entered || requested) pendingFocus = true; + acknowledgeFocus(); + }, + }; } From 616f5e155c38cc7dcc015db15781d9b13c79bce1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 21:35:20 +0700 Subject: [PATCH 15/32] fix(app): show branch loading and fetch errors (#8938) * fix(app): distinguish branch loading from empty results Preserve branch query pending and error states through the switcher so first open does not report missing branches before the response. Show request failures with a retry action. * fix(app): keep branch retry progress and keyboard focus Verify failed cached refresh and keyboard retry through the real browser and daemon. Keep focus in the footer while Retry is disabled so repeated Enter cannot select a cached branch. * fix(app): preserve picker interaction when retrying branch loads Use the existing close-and-reopen retry behavior with clear feedback rather than introducing an action that competes with native list keyboard ownership. --- .../app/e2e/browser/branch-switcher.spec.ts | 88 +++++++++++++++++++ .../app/src/components/branch-switcher.tsx | 32 ++++++- packages/app/src/hooks/use-branch-switcher.ts | 14 ++- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + 12 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index c413b6413e..9799242390 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -1,4 +1,7 @@ +import { writeFile, unlink } from "node:fs/promises"; +import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { gotoAppShell } from "../support/helpers/app"; import { expectNoBranchSwitcherInWorkspaceHeader, @@ -14,6 +17,88 @@ import { waitForSidebarHydration, } from "../support/helpers/workspace-ui"; +// Hold the real daemon response until the test has observed the pending UI. +async function holdBranchSuggestions(page: Page) { + let requestCount = 0; + let release!: () => void; + let gate = new Promise((resolve) => { + release = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => { + const envelope = JSON.parse(message.toString()); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") + requestCount++; + server.send(message); + }); + server.onMessage(async (message) => { + const envelope = JSON.parse(message.toString()); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") { + await gate; + } + browser.send(message); + }); + }); + return { + release: () => release(), + holdNextResponse: () => { + gate = new Promise((resolve) => { + release = resolve; + }); + }, + requestCount: () => requestCount, + }; +} + +async function expectInitialBranchLoading(page: Page, release: () => void): Promise { + await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click(); + const picker = page.getByTestId("combobox-desktop-container"); + await expect(picker.getByText("Loading...", { exact: true })).toBeVisible(); + await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0); + release(); + await expect(picker.getByText("dev", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(picker).not.toBeVisible(); +} + +async function expectFailedRefreshRecovery( + page: Page, + repoPath: string, + responses: Awaited>, +) { + // Expire the actual query freshness window before reopening the cached picker. + const started = Date.now(); + await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000); + const picker = page.getByTestId("combobox-desktop-container"); + const packedRefs = path.join(repoPath, ".git", "packed-refs"); + await writeFile(packedRefs, "invalid packed refs\n"); + try { + await page + .getByRole("button", { name: "Current branch: dev. Press to switch branch." }) + .click(); + await expect(picker.getByRole("alert")).toHaveText( + "Could not load branches. Reopen the picker to try again.", + ); + } finally { + await unlink(packedRefs); + } + await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0); + responses.holdNextResponse(); + const beforeRetry = responses.requestCount(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click(); + await expect(picker.getByRole("alert")).toHaveText("Loading..."); + await expect.poll(responses.requestCount).toBe(beforeRetry + 1); + await expect(picker).toBeVisible(); + responses.release(); + await expect(picker.getByRole("alert")).toHaveCount(0); + await expect(picker.getByText("main", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expectWorkspaceBranch(page, "dev"); + expect(responses.requestCount()).toBe(beforeRetry + 1); +} + async function renameWorkspaceViaSidebar( page: Page, input: { workspaceId: string; title: string }, @@ -59,6 +144,7 @@ test.describe("Branch switcher", () => { try { expect(workspace.workspaceName).toBe("main"); + const releaseBranches = await holdBranchSuggestions(page); await gotoAppShell(page); await waitForSidebarHydration(page); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); @@ -82,8 +168,10 @@ test.describe("Branch switcher", () => { // and switching it checks out the real branch on disk. await openChangesPanel(page); await expectWorkspaceBranch(page, "main"); + await expectInitialBranchLoading(page, releaseBranches.release); await switchBranchFromChangesPanel(page, { from: "main", to: "dev" }); await expectWorkspaceBranch(page, "dev"); + await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches); // The custom title is unaffected by the branch switch. await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 }); diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 737035d540..2b23ff775e 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -41,7 +41,15 @@ export function BranchSwitcher({ const toast = useToast(); const queryClient = useQueryClient(); - const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ + const { + branchOptions, + isLoadingBranches, + isFetchingBranches, + branchError, + isOpen, + setIsOpen, + handleBranchSelect, + } = useBranchSwitcher({ client, normalizedServerId: serverId, normalizedWorkspaceId: workspaceId, @@ -73,6 +81,22 @@ export function BranchSwitcher({ [branchLeadingSlot], ); + let emptyText = t("branchSwitcher.empty"); + if (isLoadingBranches) emptyText = t("common.states.loading"); + else if (branchError) emptyText = ""; + + const errorFooter = useMemo( + () => + branchError ? ( + + + {isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")} + + + ) : undefined, + [branchError, isFetchingBranches, t], + ); + if (!currentBranchName) { return null; } @@ -85,6 +109,7 @@ export function BranchSwitcher({ testID={testID} label={currentBranchName} open={isOpen} + disabled={!isConnected} onPress={handleOpen} accessibilityRole="button" accessibilityLabel={t("branchSwitcher.currentBranch", { @@ -103,7 +128,8 @@ export function BranchSwitcher({ searchable placeholder={t("branchSwitcher.placeholder")} searchPlaceholder={t("branchSwitcher.searchPlaceholder")} - emptyText={t("branchSwitcher.empty")} + emptyText={emptyText} + footer={errorFooter} title={t("branchSwitcher.title")} open={isOpen} onOpenChange={setIsOpen} @@ -122,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, minWidth: 0, }, + footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, + error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index e8e33b7f25..5a47c5117e 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -22,6 +22,9 @@ interface UseBranchSwitcherInput { interface UseBranchSwitcherResult { branchOptions: ComboboxOption[]; + isLoadingBranches: boolean; + isFetchingBranches: boolean; + branchError: Error | null; isOpen: boolean; setIsOpen: (open: boolean) => void; handleBranchSelect: (branchId: string) => void; @@ -185,5 +188,14 @@ export function useBranchSwitcher({ ], ); - return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout }; + return { + branchOptions, + isLoadingBranches: branchSuggestionsQuery.isPending, + isFetchingBranches: branchSuggestionsQuery.isFetching, + branchError: branchSuggestionsQuery.error, + isOpen, + setIsOpen, + handleBranchSelect, + invalidateStashAndCheckout, + }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b731187128..48e7f15d4f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1619,6 +1619,7 @@ export const ar: TranslationResources = { openPath: "فتح المسار", }, branchSwitcher: { + failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.", triggerTooltip: "تبديل فرع مساحة العمل", currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", placeholder: "تبديل الفرع...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bd148ca12b..bf1444d3ad 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1649,6 +1649,7 @@ export const en = { placeholder: "Switch branch...", searchPlaceholder: "Filter branches...", empty: "No branches found.", + failedToLoad: "Could not load branches. Reopen the picker to try again.", title: "Switch branch", uncommittedTitle: "Uncommitted changes", uncommittedMessage: "You have uncommitted changes. Stash them before switching branches?", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e662f7624e..cf2534f323 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1665,6 +1665,7 @@ export const es: TranslationResources = { openPath: "Abrir ruta", }, branchSwitcher: { + failedToLoad: "No se pudieron cargar las ramas. Vuelve a abrir el selector para reintentarlo.", triggerTooltip: "Cambiar rama del espacio de trabajo", currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", placeholder: "Cambiar de rama...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3eee928d57..73acbfb9e8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1661,6 +1661,7 @@ export const fr: TranslationResources = { openPath: "Ouvrir le chemin", }, branchSwitcher: { + failedToLoad: "Impossible de charger les branches. Rouvrez le sélecteur pour réessayer.", triggerTooltip: "Changer la branche de l’espace de travail", currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.", placeholder: "Changer de branche…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index fbf3fd7ab8..a845f43303 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1636,6 +1636,7 @@ export const ja: TranslationResources = { openPath: "パスを開く", }, branchSwitcher: { + failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。", triggerTooltip: "ワークスペースのブランチを切り替え", currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。", placeholder: "ブランチを切り替え...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 94272f7143..033affa05e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1629,6 +1629,7 @@ export const ko: TranslationResources = { openPath: "경로 열기", }, branchSwitcher: { + failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.", triggerTooltip: "워크스페이스 브랜치 전환", currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.", placeholder: "브랜치 전환...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e8d3b166b8..c48f2974bc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1650,6 +1650,7 @@ export const ptBR: TranslationResources = { openPath: "Abrir caminho", }, branchSwitcher: { + failedToLoad: "Não foi possível carregar as branches. Reabra o seletor para tentar novamente.", triggerTooltip: "Trocar branch do workspace", currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.", placeholder: "Trocar branch...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f9eda82e87..226c03cfe4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1647,6 +1647,7 @@ export const ru: TranslationResources = { openPath: "Открыть путь", }, branchSwitcher: { + failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.", triggerTooltip: "Переключить ветку рабочего пространства", currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.", placeholder: "Сменить ветку...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 16b5e2cfa9..f95fdc9dfb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = { openPath: "打开路径", }, branchSwitcher: { + failedToLoad: "无法加载分支,请重新打开选择器以重试。", triggerTooltip: "切换工作区分支", currentBranch: "当前分支:{{branchName}}。按下以切换分支。", placeholder: "切换分支...", From 14845d3ff3482fe785bbfb15be7cf9abdfb90fb3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:10:43 +0000 Subject: [PATCH 16/32] Keep ACP mode selections valid after config updates (#8912) * fix(acp): preserve session mode identity across config updates * test(acp): verify mode identity through public sessions Exercise initialization, queued notifications, mode selection, and rejection over stdio instead of replacing private connection state. Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com> --------- Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../acp-agent.mode-namespace.test.ts | 298 ++++++++++++++++++ .../server/agent/providers/acp-agent.test.ts | 19 ++ .../src/server/agent/providers/acp-agent.ts | 48 ++- 3 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/server/agent/providers/acp-agent.mode-namespace.test.ts diff --git a/packages/server/src/server/agent/providers/acp-agent.mode-namespace.test.ts b/packages/server/src/server/agent/providers/acp-agent.mode-namespace.test.ts new file mode 100644 index 0000000000..8128604684 --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-agent.mode-namespace.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { SessionConfigOption, SessionUpdate } from "@agentclientprotocol/sdk"; +import { ACPAgentClient, type SessionStateResponse } from "./acp-agent.js"; +import { CopilotACPAgentClient, COPILOT_ALLOW_ALL_MODE_ID } from "./copilot-acp-agent.js"; +import type { AgentSession } from "../agent-sdk-types.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +const modes = { + currentModeId: "accept_edits", + availableModes: [ + { id: "default", name: "Default" }, + { id: "accept_edits", name: "Accept Edits" }, + { id: "dont_ask", name: "Don't Ask" }, + ], +}; + +function editOptions(currentValue = "workspace_session"): SessionConfigOption[] { + return [ + { + id: "edit_approval_policy", + name: "Edits", + category: "mode", + type: "select", + currentValue, + options: [ + { value: "ask", name: "Default" }, + { value: "workspace_session", name: "Accept Edits" }, + { value: "session", name: "Don't Ask" }, + ], + }, + ]; +} + +// A controlled ACP process, speaking the same stdio protocol as a provider. +// Notifications precede session/new's response to exercise queued initialization updates. +const modePeerScript = String.raw` +import { createInterface } from "node:readline"; +import { readFileSync, appendFileSync } from "node:fs"; +const scenario = JSON.parse(readFileSync(process.argv[2], "utf8")); +let configOptions = scenario.state.configOptions; +const send = (message) => process.stdout.write(JSON.stringify(message) + "\n"); +createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + const reply = (result) => send({jsonrpc: "2.0", id: request.id, result}); + const reject = (message) => send({jsonrpc: "2.0", id: request.id, error: {code: -32602, message}}); + switch (request.method) { + case "initialize": + reply({protocolVersion: 1, agentCapabilities: {}}); + break; + case "session/new": + for (const update of scenario.updates) { + send({jsonrpc: "2.0", method: "session/update", params: {sessionId: "mode-session", update}}); + } + reply({sessionId: "mode-session", ...scenario.state}); + break; + case "session/set_mode": + appendFileSync(process.argv[3], JSON.stringify(request) + "\n"); + if (request.params.modeId === scenario.rejectedMode) { + reject("mode denied"); + } else if (!scenario.state.modes?.availableModes.some(mode => mode.id === request.params.modeId)) { + reject("invalid_params"); + } else { + reply({}); + } + break; + case "session/set_config_option": + appendFileSync(process.argv[3], JSON.stringify(request) + "\n"); + configOptions = configOptions.map(option => option.id === request.params.configId + ? {...option, currentValue: request.params.value} : option); + reply({configOptions}); + break; + default: + reject("Unexpected request: " + request.method); + } +}); +`; + +interface ModeCall { + method: string; + params: { sessionId: string; modeId?: string; configId?: string; value?: string }; +} + +let session: AgentSession | undefined; +let directory: string | undefined; + +afterEach(async () => { + try { + await session?.close(); + } finally { + session = undefined; + if (directory) await rm(directory, { recursive: true, force: true }); + directory = undefined; + } +}); + +async function fixture( + options: { + state?: Omit; + updates?: SessionUpdate[]; + rejectedMode?: string; + startupMode?: string; + copilot?: boolean; + } = {}, +) { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + directory = await mkdtemp(path.join(artifacts, "acp-mode-")); + const script = path.join(directory, "peer.mjs"); + const scenario = path.join(directory, "scenario.json"); + const calls = path.join(directory, "calls.jsonl"); + await writeFile(script, modePeerScript); + await writeFile(calls, ""); + await writeFile( + scenario, + JSON.stringify({ + state: options.state ?? { modes, configOptions: editOptions() }, + updates: options.updates ?? [ + { sessionUpdate: "config_option_update", configOptions: editOptions() }, + ], + rejectedMode: options.rejectedMode, + }), + ); + const command: [string, ...string[]] = [process.execPath, script, scenario, calls]; + const client = options.copilot + ? new CopilotACPAgentClient({ + logger: createTestLogger(), + runtimeSettings: { command: { mode: "replace", argv: command } }, + }) + : new ACPAgentClient({ + provider: "mode-peer", + logger: createTestLogger(), + defaultCommand: command, + }); + session = await client.createSession({ + provider: options.copilot ? "copilot" : "mode-peer", + cwd: directory, + modeId: options.startupMode, + }); + return { + session, + async calls(): Promise { + const lines = (await readFile(calls, "utf8")).trim(); + return lines + ? lines.split("\n").map((line) => { + const { method, params } = JSON.parse(line) as ModeCall; + return { method, params }; + }) + : []; + }, + }; +} + +const modeCall = (modeId: string) => ({ + method: "session/set_mode", + params: { sessionId: "mode-session", modeId }, +}); +const configCall = (configId: string, value: string) => ({ + method: "session/set_config_option", + params: { sessionId: "mode-session", configId, value }, +}); + +describe("ACP mode and config-value namespaces over stdio", () => { + test("selects Don't Ask from the advertised catalogue after a queued config update", async () => { + const peer = await fixture(); + const available = await peer.session.getAvailableModes(); + const dontAsk = available.find((mode) => mode.label === "Don't Ask"); + expect(dontAsk).toBeDefined(); + await peer.session.setMode(dontAsk!.id); + expect(await peer.calls()).toEqual([modeCall("dont_ask")]); + expect(await peer.session.getCurrentMode()).toBe("dont_ask"); + expect(available.map((mode) => mode.id)).toEqual(["default", "accept_edits", "dont_ask"]); + }); + + test("keeps a pushed current mode through separate-policy and empty config updates", async () => { + const peer = await fixture({ + updates: [ + { sessionUpdate: "current_mode_update", currentModeId: "dont_ask" }, + { sessionUpdate: "config_option_update", configOptions: editOptions("session") }, + { sessionUpdate: "config_option_update", configOptions: [] }, + ], + }); + expect(await peer.session.getCurrentMode()).toBe("dont_ask"); + expect((await peer.session.getAvailableModes()).map((mode) => mode.id)).toEqual([ + "default", + "accept_edits", + "dont_ask", + ]); + expect(await peer.calls()).toEqual([]); + }); + + test("ignores policy values outside the advertised session modes without an RPC", async () => { + const peer = await fixture(); + await peer.session.setMode("session"); + expect(await peer.calls()).toEqual([]); + expect(await peer.session.getCurrentMode()).toBe("accept_edits"); + }); + + test("retains confirmed state after rejection without falling back to a config write", async () => { + const peer = await fixture({ rejectedMode: "dont_ask", updates: [] }); + await expect(peer.session.setMode("dont_ask")).rejects.toThrow("mode denied"); + expect(await peer.session.getCurrentMode()).toBe("accept_edits"); + expect(await peer.calls()).toEqual([modeCall("dont_ask")]); + }); + + test("selects a config-only mode through its owning config option", async () => { + const peer = await fixture({ state: { configOptions: editOptions() } }); + await peer.session.setMode("session"); + expect(await peer.calls()).toEqual([configCall("edit_approval_policy", "session")]); + expect(await peer.session.getCurrentMode()).toBe("session"); + }); + + test.each([true, false])("routes startup overrides with session modes: %s", async (withModes) => { + const peer = await fixture({ + state: { modes: withModes ? modes : undefined, configOptions: editOptions() }, + startupMode: withModes ? "dont_ask" : "session", + }); + expect(await peer.calls()).toEqual([ + withModes ? modeCall("dont_ask") : configCall("edit_approval_policy", "session"), + ]); + expect(await peer.session.getCurrentMode()).toBe(withModes ? "dont_ask" : "session"); + }); +}); + +const agentMode = "https://agentclientprotocol.com/protocol/session-modes#agent"; +const planMode = "https://agentclientprotocol.com/protocol/session-modes#plan"; +function copilotConfig(allowAll: string): SessionConfigOption[] { + return [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: agentMode, + options: [ + { value: agentMode, name: "Agent" }, + { value: planMode, name: "Plan" }, + ], + }, + { + id: "allow_all", + name: "Allow All", + type: "select", + currentValue: allowAll, + options: [ + { value: "on", name: "On" }, + { value: "off", name: "Off" }, + ], + }, + ]; +} +function copilotFixture(initial: string, pushed: string) { + return fixture({ + copilot: true, + state: { + modes: { + currentModeId: agentMode, + availableModes: [ + { id: agentMode, name: "Agent" }, + { id: planMode, name: "Plan" }, + ], + }, + configOptions: copilotConfig(initial), + }, + updates: [{ sessionUpdate: "config_option_update", configOptions: copilotConfig(pushed) }], + }); +} + +describe("Copilot Allow All through the public client", () => { + test("adopts an Allow All notification and disables it before switching modes", async () => { + const peer = await copilotFixture("off", "on"); + expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID); + await peer.session.setMode(planMode); + expect(await peer.calls()).toEqual([configCall("allow_all", "off"), modeCall(planMode)]); + expect(await peer.session.getCurrentMode()).toBe(planMode); + }); + + test("adopts an Allow All off notification after initializing with it enabled", async () => { + const peer = await copilotFixture("on", "off"); + expect(await peer.session.getCurrentMode()).toBe(agentMode); + expect(await peer.calls()).toEqual([]); + }); + + test("selects Allow All and switches away in provider RPC order", async () => { + const peer = await copilotFixture("off", "off"); + await peer.session.setMode(COPILOT_ALLOW_ALL_MODE_ID); + expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID); + await peer.session.setMode(planMode); + expect(await peer.calls()).toEqual([ + modeCall(agentMode), + configCall("allow_all", "on"), + configCall("allow_all", "off"), + modeCall(planMode), + ]); + expect(await peer.session.getCurrentMode()).toBe(planMode); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index ea0c1702e9..ae80dc74f7 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -140,6 +140,7 @@ interface ACPModelSelectionInternals { } interface ACPConfiguredOverrideInternals { + applySessionState(response: SessionStateResponse): void; sessionId: string | null; connection: { setSessionMode: (input: { sessionId: string; modeId: string }) => Promise; @@ -444,6 +445,24 @@ function prepareConfiguredOverrideSession( configOptions: options.configOptions ?? [], })); const internals = asInternals(session); + const availableModes = options.availableModes ?? []; + const modes = + availableModes.length > 0 + ? { + currentModeId: options.currentMode ?? availableModes[0]!.id, + availableModes: availableModes.map((mode) => ({ + id: mode.id, + label: mode.label, + description: mode.description, + name: mode.label, + })), + } + : undefined; + internals.applySessionState({ + sessionId: "session-1", + modes, + configOptions: options.configOptions ?? [], + }); internals.sessionId = "session-1"; internals.connection = { setSessionMode, diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 09662aaa1c..4b67d827dd 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1769,6 +1769,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly earlySessionUpdates: SessionNotification[] = []; private currentMode: string | null = null; private availableModes: AgentMode[]; + private sessionModeState: SessionStateResponse["modes"] = null; private currentModel: string | null = null; private availableModels: AvailableACPModel[] | null = null; private thinkingOptionId: string | null = null; @@ -2148,11 +2149,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - const selection = resolveACPModeSelection({ - modeId, - availableModes: this.availableModes, - configOptions: this.configOptions, - }); + const selection = this.resolveModeSelection(modeId); await this.setModeWithSelection({ modeId, selection }); } @@ -2178,7 +2175,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (providerResult.configOptions) { this.configOptions = this.transformConfigOptions(providerResult.configOptions); } - this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes; + this.availableModes = this.modesFromConfig().modes; this.pushEvent({ type: "mode_changed", provider: this.provider, @@ -2257,7 +2254,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { requestedValue: modeId, label: "mode", }); - this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes; + this.availableModes = this.modesFromConfig().modes; this.pushEvent({ type: "mode_changed", provider: this.provider, @@ -3010,6 +3007,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { : response; this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []); + this.sessionModeState = transformed.modes ?? null; const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions); this.availableModes = modeInfo.modes; @@ -3035,11 +3033,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private async applyConfiguredOverrides(): Promise { const configuredModeId = this.config.modeId; if (configuredModeId && configuredModeId !== this.currentMode) { - const selection = resolveACPModeSelection({ - modeId: configuredModeId, - availableModes: this.availableModes, - configOptions: this.configOptions, - }); + const selection = this.resolveModeSelection(configuredModeId); await this.setModeWithSelection({ modeId: configuredModeId, selection }); } const configuredModelId = this.config.model; @@ -3296,9 +3290,37 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.currentMode = this.transformModeId(update.currentModeId); } + private modesFromConfig() { + let modes = this.sessionModeState; + if (modes) { + let currentModeId = this.currentMode ?? modes.currentModeId; + const configMode = findSelectConfigOption({ + configOptions: this.configOptions, + category: "mode", + }); + // Copilot config updates name real modes; Hermes config values are a separate policy namespace. + if (configMode && modes.availableModes.some((mode) => mode.id === configMode.currentValue)) { + currentModeId = configMode.currentValue; + } + modes = { ...modes, currentModeId }; + } + return deriveModesFromACP(this.defaultModes, modes, this.configOptions); + } + + private resolveModeSelection(modeId: string): ACPModeSelection { + const hasSessionModes = Boolean(this.sessionModeState?.availableModes.length); + const modeConfig = findSelectConfigOption({ + configOptions: this.configOptions, + category: "mode", + }); + const configOnly = !hasSessionModes && modeConfig !== null; + const availableModes = configOnly ? [] : this.availableModes; + return resolveACPModeSelection({ modeId, availableModes, configOptions: this.configOptions }); + } + private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] { this.configOptions = this.transformConfigOptions(update.configOptions); - const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions); + const modeInfo = this.modesFromConfig(); const nextMode = modeInfo.currentModeId; const nextModel = deriveCurrentConfigValue(this.configOptions, "model"); const nextThinkingOptionId = deriveCurrentConfigValue(this.configOptions, "thought_level"); From 161076a853e9858b3edfb89b17c32511b428d2b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 22:15:10 +0700 Subject: [PATCH 17/32] Show loading and errors in the slash command menu, and stop defaulting to /exit (#8905) * feat(app): show loading and errors in the composer command menu Rows are single lines with a kind icon, the matched part of the name, the argument hint, the description and a source tag. The detail card is gone. The menu used to stay hidden while commands loaded and then show "Error: ..." over a list whose options Enter could still pick, so a failed load plus Enter archived the agent through a hidden /exit. Loading and error are now status rows with no options; the error row names the reason and offers Retry. Loading appears after 300ms so a warm cache paints the results first, as before. An empty query used to select /exit. Provider commands now come first, so the bottom row the input selects is a provider command. useAutocomplete derives the selection during render. When results replace the loading row, the selection is valid in the same render, so the popover stays mounted at its anchor instead of remounting. Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its own server-launched sessions, so the Claude provider no longer lists them. * fix(app): never default the command menu to a Paseo action An arrow-key choice was tied to the query it was made for, so returning to that query brought it back: arrow to /exit on `/`, type `com`, delete it, and Enter archived the agent. A choice now clears whenever the query changes. An empty query still selected /exit when the agent had no provider or plugin commands, because Paseo's actions were the only entries. With nothing else to list, an empty query now lists nothing and waits for a search; typing `/ex` still finds /exit. * fix(app): sort empty-query commands without toSorted Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is not a function" on iOS and Android and the app showed its error screen. Browsers and Node have it, which is why web and unit tests passed. * test(app): cover the stale command choice in Playwright and wait out the loading row on devices The renderHook test mounted React under JSDOM, which docs/testing.md forbids. The Playwright test drives the same sequence in a real browser and fails without the reset in useAutocomplete: Enter chose the stale /exit. The composer-keyboard harnesses treated a visible popover as a ready list. The popover now shows a loading row first, and a cold Claude command list takes several seconds, so both harnesses wait for the rows. * fix(app): size the command menu loading spinner to the row icons Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../e2e/browser/composer-autocomplete.spec.ts | 160 +++++++- .../e2e/mobile/composer-keyboard/android.sh | 2 + .../app/e2e/mobile/composer-keyboard/ios.mjs | 24 +- packages/app/src/agent-commands/use-query.ts | 13 + .../components/ui/autocomplete-popover.tsx | 21 +- .../app/src/components/ui/autocomplete.tsx | 377 +++++++++--------- packages/app/src/composer/index.tsx | 5 +- .../app/src/hooks/use-agent-autocomplete.ts | 357 ++++++++++++----- packages/app/src/hooks/use-autocomplete.ts | 99 ++--- packages/app/src/i18n/resources.test.ts | 2 +- packages/app/src/i18n/resources/ar.ts | 6 +- packages/app/src/i18n/resources/en.ts | 6 +- packages/app/src/i18n/resources/es.ts | 6 +- packages/app/src/i18n/resources/fr.ts | 6 +- packages/app/src/i18n/resources/ja.ts | 6 +- packages/app/src/i18n/resources/ko.ts | 6 +- packages/app/src/i18n/resources/pt-BR.ts | 6 +- packages/app/src/i18n/resources/ru.ts | 6 +- packages/app/src/i18n/resources/zh-CN.ts | 6 +- .../utils/agent-command-autocomplete.test.ts | 51 +++ .../src/utils/agent-command-autocomplete.ts | 22 +- .../agent/providers/claude/agent.test.ts | 7 +- .../server/agent/providers/claude/agent.ts | 7 + 23 files changed, 809 insertions(+), 392 deletions(-) diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index f40b51304b..088159e7f0 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -105,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function installListCommandsStub(page: Page): Promise { +interface ListCommandsReply { + commands: readonly { name: string; description: string; argumentHint: string }[]; + error: string | null; +} + +const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null }; + +/** + * Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds + * the response until it settles, standing in for a slow host. + */ +async function installListCommandsStub( + page: Page, + reply: () => ListCommandsReply | Promise = () => TEST_COMMANDS_REPLY, +): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); @@ -130,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise { }; }; }; + const payload = parsed.message?.payload; if ( parsed.type === "session" && parsed.message?.type === "list_commands_response" && - parsed.message.payload + payload ) { - parsed.message.payload.commands = TEST_COMMANDS; - parsed.message.payload.error = null; - ws.send(JSON.stringify(parsed)); + const send = (result: ListCommandsReply) => { + payload.commands = result.commands; + payload.error = result.error; + ws.send(JSON.stringify(parsed)); + }; + const result = reply(); + if (result instanceof Promise) void result.then(send); + else send(result); return; } } catch { @@ -149,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise { }); } +function createHeldReply(): { + reply: () => Promise; + release: () => void; +} { + let release = () => {}; + const held = new Promise((resolve) => { + release = () => resolve(TEST_COMMANDS_REPLY); + }); + return { reply: () => held, release }; +} + async function openAppWideNewWorkspace(page: Page): Promise { await page.getByTestId("sidebar-global-new-workspace").first().click(); await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 }); @@ -545,16 +576,17 @@ test.describe("Composer autocomplete", () => { const input = composerLocator(page); await expect(input).toBeEditable({ timeout: 30_000 }); - await input.fill("/he"); + // `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows. + await input.fill("/hea"); const popover = page.getByTestId("composer-autocomplete-popover"); - await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({ timeout: 30_000, }); const beforeDelete = await visiblePopoverBox(page); await input.press("Backspace"); - await expect(input).toHaveValue("/h"); - await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({ + await expect(input).toHaveValue("/he"); + await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({ timeout: 30_000, }); const afterDelete = await visiblePopoverBox(page); @@ -733,6 +765,116 @@ test.describe("Composer autocomplete", () => { } }); + test("forgets an arrow-key choice once the search changes", async ({ page }) => { + await installListCommandsStub(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "autocomplete-stale-choice-", + title: "Autocomplete stale choice", + }); + + try { + await openAgentRoute(page, session); + await expectWorkspaceTabVisible(page, session.agentId); + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/"); + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + // Down wraps from the default bottom row to the top: /clear, then /exit. + await input.press("ArrowDown"); + await input.press("ArrowDown"); + await input.press("h"); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible(); + await input.press("Backspace"); + await expect(input).toHaveValue("/"); + await input.press("Enter"); + + await expect(input).toHaveValue("/tdd "); + await expectWorkspaceTabVisible(page, session.agentId); + } finally { + await session.cleanup(); + } + }); + + test("shows loading inside the popover until a slow host answers", async ({ page }) => { + const host = createHeldReply(); + await installListCommandsStub(page, host.reply); + const agent = await openReadyMockAgent(page); + + try { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/"); + + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({ + timeout: 30_000, + }); + const loading = await visiblePopoverBox(page); + + host.release(); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0); + const results = await visiblePopoverBox(page); + + expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4); + } finally { + await agent.cleanup(); + } + }); + + test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({ + page, + }) => { + let reply: () => ListCommandsReply | Promise = () => ({ + commands: [], + error: "Timed out waiting for the agent session", + }); + await installListCommandsStub(page, () => reply()); + const session = await seedMockAgentWorkspace({ + repoPrefix: "autocomplete-failing-host-", + title: "Autocomplete failing host", + }); + + try { + await openAgentRoute(page, session); + await expectWorkspaceTabVisible(page, session.agentId); + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill("/"); + + const popover = page.getByTestId("composer-autocomplete-popover"); + const errorRow = popover.getByTestId("composer-autocomplete-error"); + await expect(errorRow).toContainText("Timed out waiting for the agent session", { + timeout: 30_000, + }); + + await input.press("Enter"); + await page.waitForTimeout(500); + await expectWorkspaceTabVisible(page, session.agentId); + + await input.fill("/"); + await expect(errorRow).toBeVisible({ timeout: 30_000 }); + + const host = createHeldReply(); + reply = host.reply; + await errorRow.getByRole("button", { name: "Retry" }).click(); + await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible(); + + host.release(); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + } finally { + await session.cleanup(); + } + }); + test.describe("compact sidebar layering", () => { test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index 809483ceff..b3b20ed2b7 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -509,6 +509,8 @@ check_autocomplete() { fi fi ad wait 'id="composer-autocomplete-popover"' 10000 + # Rows replace the popover's loading row once the host lists them; a cold Claude list is slow. + ad wait "text=\"${expected}\"" 30000 # The popover remeasures once keyboard motion settles. sleep 1 capture_screen "${prefix}.png" diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs index 513d68623e..9deb02ab68 100644 --- a/packages/app/e2e/mobile/composer-keyboard/ios.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs @@ -69,17 +69,23 @@ async function waitFor(name, predicate, timeoutMs = 10_000) { } } -// Keyboard motion has settled once two snapshots agree on the composer position. +// Keyboard motion has settled once two snapshots agree on the composer position. The +// popover shows a loading row until the host lists its commands; a cold Claude list is slow. async function settledPlacement(name, keyboard) { let previousTop; - return waitFor(name, (snap) => { - const composer = nodeById(snap, "message-input-root"); - if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; - if (keyboardShown(snap) !== keyboard) return false; - const stable = composer.rect.y === previousTop; - previousTop = composer.rect.y; - return stable; - }); + return waitFor( + name, + (snap) => { + const composer = nodeById(snap, "message-input-root"); + if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; + if (nodeById(snap, "composer-autocomplete-loading")) return false; + if (keyboardShown(snap) !== keyboard) return false; + const stable = composer.rect.y === previousTop; + previousTop = composer.rect.y; + return stable; + }, + 30_000, + ); } function assertPlacement(name, snap, expected) { diff --git a/packages/app/src/agent-commands/use-query.ts b/packages/app/src/agent-commands/use-query.ts index 000e6e8057..61a869d092 100644 --- a/packages/app/src/agent-commands/use-query.ts +++ b/packages/app/src/agent-commands/use-query.ts @@ -1,3 +1,5 @@ +import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useFetchQuery } from "@/data/query"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -75,6 +77,7 @@ export function useAgentCommandsQuery({ }), ); + const queryClient = useQueryClient(); const query = useFetchQuery({ queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryFn: async () => { @@ -101,10 +104,20 @@ export function useAgentCommandsQuery({ // isLoading is true when fetching and no data yet const isLoading = query.isPending || query.isLoading; + // Reset rather than refetch: a refetch keeps the error status while it runs, and the menu + // shows loading for the retry. + const retry = useCallback(() => { + void queryClient.resetQueries({ + queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), + exact: true, + }); + }, [agentId, draftConfig, queryClient, serverId]); + return { commands: query.data ?? [], isLoading, isError: query.isError && query.data === undefined, error: query.error, + retry, }; } diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index e74c068658..c4229775bf 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -12,7 +12,11 @@ import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; +import { + Autocomplete, + type AutocompleteOption, + type AutocompleteStatus, +} from "@/components/ui/autocomplete"; import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, @@ -37,10 +41,7 @@ interface AutocompletePopoverProps { options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; - isLoading?: boolean; - errorMessage?: string; - loadingText?: string; - emptyText?: string; + status?: AutocompleteStatus | null; } export function AutocompletePopover({ @@ -50,10 +51,7 @@ export function AutocompletePopover({ options, selectedIndex, onSelect, - isLoading, - errorMessage, - loadingText, - emptyText, + status, }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. @@ -154,10 +152,7 @@ export function AutocompletePopover({ options={options} selectedIndex={selectedIndex} onSelect={onSelect} - isLoading={isLoading} - errorMessage={errorMessage} - loadingText={loadingText} - emptyText={emptyText} + status={status} /> diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index a9cb10f33e..86d62ba2b1 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -9,32 +9,79 @@ import { type NativeSyntheticEvent, type PressableStateCallbackType, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { File, Folder } from "lucide-react-native"; +import { + Archive, + CircleAlert, + File, + Folder, + Puzzle, + RotateCw, + Sparkles, + SquarePen, + SquareSlash, +} from "lucide-react-native"; +import type { MatchRange } from "@getpaseo/protocol/search/text-match"; +import { useIsCompactFormFactor } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; +import { Button } from "./button"; +import { HighlightedText } from "./highlighted-text"; +import { mutedIconColorMapping } from "./icon-color"; +import { LoadingSpinner } from "./loading-spinner"; import { getScrollOffsetToRevealItem } from "./scroll-reveal"; +import { StatusBadge } from "./status-badge"; + +export type AutocompleteOptionIcon = + | "command" + | "skill" + | "plugin" + | "archive" + | "new-draft" + | "file" + | "directory"; export interface AutocompleteOption { id: string; label: string; - detail?: string; + icon: AutocompleteOptionIcon; + /** Spans of `label` the query matched. */ + labelMatches?: readonly MatchRange[]; + argumentHint?: string; description?: string; - kind?: "command" | "file" | "directory"; + /** Where the option comes from, when that is not the provider itself: `Skill`, `Paseo`, a plugin. */ + tag?: string; } +/** What the list shows in place of options. */ +export type AutocompleteStatus = + | { kind: "loading"; message: string } + | { kind: "empty"; message: string } + | { kind: "error"; title: string; reason?: string; onRetry?: () => void }; + interface AutocompleteProps { options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; - isLoading?: boolean; - errorMessage?: string; - loadingText?: string; - emptyText?: string; + status?: AutocompleteStatus | null; maxHeight?: number; } -const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu; +const ROW_ICONS = { + command: withUnistyles(SquareSlash, mutedIconColorMapping), + skill: withUnistyles(Sparkles, mutedIconColorMapping), + plugin: withUnistyles(Puzzle, mutedIconColorMapping), + archive: withUnistyles(Archive, mutedIconColorMapping), + "new-draft": withUnistyles(SquarePen, mutedIconColorMapping), + file: withUnistyles(File, mutedIconColorMapping), + directory: withUnistyles(Folder, mutedIconColorMapping), +} satisfies Record; +const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping); + +const ROW_ICON_SIZE = 14; + +const BOLT_GLYPH_PATTERN = /⚡|️/gu; function removeBoltGlyphs(value?: string): string | undefined { if (!value) { @@ -48,7 +95,7 @@ interface AutocompleteRowProps { index: number; option: AutocompleteOption; isSelected: boolean; - mutedColor: string; + showArgumentHint: boolean; onSelect: (option: AutocompleteOption) => void; onRowLayout: (index: number, event: LayoutChangeEvent) => void; } @@ -57,13 +104,15 @@ function AutocompleteRow({ index, option, isSelected, - mutedColor, + showArgumentHint, onSelect, onRowLayout, }: AutocompleteRowProps) { - const optionLabel = removeBoltGlyphs(option.label) ?? option.label; - const optionDescription = removeBoltGlyphs(option.description); - const isFileOrDir = option.kind === "directory" || option.kind === "file"; + const label = removeBoltGlyphs(option.label) ?? option.label; + const description = removeBoltGlyphs(option.description); + const argumentHint = showArgumentHint ? removeBoltGlyphs(option.argumentHint) : undefined; + const Icon = ROW_ICONS[option.icon]; + const labelMatches = label === option.label ? option.labelMatches : undefined; const handleLayout = useCallback( (event: LayoutChangeEvent) => onRowLayout(index, event), @@ -79,41 +128,77 @@ function AutocompleteRow({ ); return ( - - {isFileOrDir ? ( - <> - - {option.kind === "directory" ? ( - - ) : ( - - )} - - - - {optionLabel} - {removeBoltGlyphs(option.detail) ? ( - {removeBoltGlyphs(option.detail)} - ) : null} - - {optionDescription ? ( - - {optionDescription} - - ) : null} - - - ) : ( - - {optionLabel} - {optionDescription ? ( - - {optionDescription} + + + + + + {argumentHint ? ( + + {argumentHint} + + ) : null} + + {description ?? ""} + + {option.tag ? : null} + + ); +} + +function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) { + const { t } = useTranslation(); + + if (status.kind === "error") { + return ( + + + + + + + {status.title} + + {status.reason ? ( + + {status.reason} ) : null} - )} - + {status.onRetry ? ( + + ) : null} + + ); + } + + return ( + + {status.kind === "loading" ? ( + + + + ) : null} + + {status.message} + + ); } @@ -121,16 +206,11 @@ export function Autocomplete({ options, selectedIndex, onSelect, - isLoading = false, - errorMessage, - loadingText, - emptyText, + status, maxHeight = 220, }: AutocompleteProps) { const { t } = useTranslation(); - const { theme } = useUnistyles(); - const resolvedLoadingText = loadingText ?? t("common.states.loading"); - const resolvedEmptyText = emptyText ?? t("common.empty.noResults"); + const isCompact = useIsCompactFormFactor(); const scrollRef = useRef(null); const rowLayoutsRef = useRef>(new Map()); const viewportHeightRef = useRef(0); @@ -210,117 +290,51 @@ export function Autocomplete({ [ensureActiveItemVisible], ); - const selectedOption = options[selectedIndex]; const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]); - if (isLoading) { - return ( - - - {resolvedLoadingText} - - - ); - } + const noResultsMessage = t("common.empty.noResults"); + const shownStatus = useMemo( + () => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null), + [noResultsMessage, options.length, status], + ); - if (errorMessage) { + if (shownStatus) { return ( - - Error: {errorMessage} - - - ); - } - - if (options.length === 0) { - return ( - - - {resolvedEmptyText} - + ); } return ( - - {selectedOption?.kind === "command" && selectedOption.description ? ( - - - {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - - - {removeBoltGlyphs(selectedOption.description)} - - {selectedOption.detail ? ( - - {removeBoltGlyphs(selectedOption.detail)} - - ) : null} - - ) : null} - - - {options.map((option, index) => ( - - ))} - - + + + {options.map((option, index) => ( + + ))} + ); } const styles = StyleSheet.create((theme: Theme) => ({ - outerWrapper: { - flexShrink: 1, - minHeight: 0, - gap: theme.spacing[1], - }, - detailCard: { - flexShrink: 1, - minHeight: 0, - overflow: "hidden", - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - ...theme.shadow.md, - }, - detailLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - detailDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - detailHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, container: { flexShrink: 1, // Keep a complete selectable row (36), list padding (8), and borders (2). @@ -342,60 +356,63 @@ const styles = StyleSheet.create((theme: Theme) => ({ item: { flexDirection: "row", alignItems: "center", + gap: theme.spacing[2], minHeight: 36, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, - itemLeading: { - width: 18, - alignItems: "center", - justifyContent: "center", - marginRight: theme.spacing[1], - }, itemActive: { backgroundColor: theme.colors.surface2, }, - itemMain: { - flex: 1, - minWidth: 0, - }, - itemMainRow: { - flex: 1, - minWidth: 0, - flexDirection: "row", + itemIcon: { + width: 16, alignItems: "center", - gap: theme.spacing[2], - }, - itemHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], + justifyContent: "center", }, itemLabel: { + flexShrink: 0, + maxWidth: "60%", color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - itemDetail: { + itemArgumentHint: { + flexShrink: 0, + maxWidth: "30%", color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.sm, }, itemDescription: { + flex: 1, + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - marginTop: 2, }, - itemDescriptionInline: { + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 44, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + statusText: { + flex: 1, + minWidth: 0, + gap: 2, + }, + statusTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + statusReason: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + statusMessage: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - emptyItem: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, })) as unknown as Record; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index b7dd4ee36a..60b05be23e 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1058,10 +1058,7 @@ function ComposerAutocompleteBinding({ options={autocomplete.options} selectedIndex={autocomplete.selectedIndex} onSelect={onSelect} - isLoading={autocomplete.isLoading} - errorMessage={autocomplete.errorMessage} - loadingText={autocomplete.loadingText} - emptyText={autocomplete.emptyText} + status={autocomplete.status} > {children} diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index ef144477dc..a400cf6862 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import type { AutocompleteOption } from "@/components/ui/autocomplete"; +import type { AutocompleteOption, AutocompleteStatus } from "@/components/ui/autocomplete"; +import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; import { useAutocompleteCommands, type AgentSlashCommand, @@ -13,6 +14,9 @@ import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list"; +import { resolveProviderLabel } from "@/utils/provider-definitions"; import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands"; import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model"; @@ -70,10 +74,8 @@ interface AgentAutocompleteResult { isVisible: boolean; options: AutocompleteOption[]; selectedIndex: number; - isLoading: boolean; - errorMessage?: string; - loadingText: string; - emptyText: string; + /** Shown in place of options; set exactly when `options` is empty. */ + status: AutocompleteStatus | null; onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void; onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean; } @@ -162,39 +164,69 @@ function mapDirectorySuggestionsToEntries(payload: { })); } -function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocompleteOption { +const CLIENT_COMMAND_ICONS = { + "archive-agent": "archive", + "replace-agent-with-draft": "new-draft", +} as const satisfies Record; + +interface CommandOptionContext { + query: string; + pluginNames: ReadonlyMap; + t: TFunction; +} + +function mapCommandToOption( + entry: AvailableCommand, + context: CommandOptionContext, +): AgentAutocompleteOption { const command = entry.command; const base = { id: command.name, label: `/${command.name}`, - detail: command.argumentHint || undefined, - description: - entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description, - kind: "command" as const, + // The label carries a leading slash the query does not. + labelMatches: findHighlightRanges(context.query, command.name).map((range) => ({ + start: range.start + 1, + length: range.length, + })), + argumentHint: command.argumentHint || undefined, }; if (entry.source === "client") { return { ...base, type: "client_command", command: entry.command, + icon: CLIENT_COMMAND_ICONS[entry.command.kind], + description: context.t(entry.command.descriptionKey), + tag: "Paseo", }; } if (entry.source === "plugin") { - return { ...base, type: "plugin_command", command: entry.command }; + return { + ...base, + type: "plugin_command", + command: entry.command, + icon: "plugin", + description: entry.command.description, + tag: context.pluginNames.get(entry.command.pluginId) ?? entry.command.pluginId, + }; } + const isSkill = entry.command.kind === "skill"; return { ...base, type: "provider_command", + icon: isSkill ? "skill" : "command", + description: entry.command.description, + tag: isSkill ? context.t("agentAutocomplete.skillTag") : undefined, }; } type AutocompleteMode = "command" | "file" | null; interface BuildAutocompleteOptionsInput { - isVisible: boolean; mode: AutocompleteMode; commands: AgentSlashCommand[]; pluginCommands: readonly PluginClientSlashCommand[]; + pluginNames: ReadonlyMap; isDraftContext: boolean; commandFilterQuery: string; activeSlashCommand: SlashCommandRange | null; @@ -203,11 +235,7 @@ interface BuildAutocompleteOptionsInput { t: TFunction; } -function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { - if (!input.isVisible) { - return []; - } - +function buildAutocompleteOptions(input: BuildAutocompleteOptionsInput): AgentAutocompleteOption[] { if (input.mode === "command") { const providerCommands = input.commands.map((command) => ({ source: "provider" as const, @@ -236,8 +264,12 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { availableCommands, input.commandFilterQuery, ); - const orderedMatches = orderAutocompleteOptions(matches); - return orderedMatches.map((entry) => mapCommandToOption(entry, input.t)); + const context = { + query: input.commandFilterQuery, + pluginNames: input.pluginNames, + t: input.t, + }; + return orderAutocompleteOptions(matches).map((entry) => mapCommandToOption(entry, context)); } const activeFileMention = input.activeFileMention; @@ -247,7 +279,7 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { type: "workspace_entry" as const, id: `${entry.kind}:${entry.path}`, label: entry.path, - kind: entry.kind, + icon: entry.kind, entryPath: entry.path, mention: activeFileMention, })); @@ -295,42 +327,140 @@ function resolveCanLoadCommands(args: { return Boolean(args.agentId) || args.isDraftContext; } -function resolveAutocompleteIsLoading(args: { - mode: AutocompleteMode; - isCommandsLoading: boolean; - fileSuggestionsIsPending: boolean; - fileSuggestionsIsLoading: boolean; - optionsLength: number; -}): boolean { - if (args.mode === "command") { - return args.isCommandsLoading && args.optionsLength === 0; - } - if (args.mode === "file") { - return ( - args.fileSuggestionsIsPending || (args.fileSuggestionsIsLoading && args.optionsLength === 0) - ); - } - return false; +type CommandsState = "ready" | "loading" | "error"; + +function resolveCommandsState(input: { + canListCommands: boolean; + isLoading: boolean; + isError: boolean; +}): CommandsState { + if (!input.canListCommands) return "ready"; + if (input.isError) return "error"; + if (input.isLoading) return "loading"; + return "ready"; } -function resolveAutocompleteErrorMessage(args: { - mode: AutocompleteMode; - isCommandError: boolean; - commandError: Error | null; - fileSuggestionsError: unknown; +interface AutocompleteMenu { + options: AgentAutocompleteOption[]; + status: AutocompleteStatus | null; +} + +/** + * One state for the open menu, so options and a loading or error row cannot both claim it: + * while a status row shows there are no options for Enter or Tab to pick. + */ +function resolveCommandMenu(input: { + commandsState: CommandsState; + options: () => AgentAutocompleteOption[]; + providerLabel: string | null; + error: Error | null; + onRetry: () => void; + emptyText: string; t: TFunction; -}): string | undefined { - if (args.mode === "command") { - return args.isCommandError - ? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad")) - : undefined; +}): AutocompleteMenu { + const { t } = input; + if (input.commandsState === "loading") { + const message = input.providerLabel + ? t("agentAutocomplete.loadingProviderCommands", { provider: input.providerLabel }) + : t("agentAutocomplete.loadingCommands"); + return { options: [], status: { kind: "loading", message } }; } - if (args.mode === "file") { - return args.fileSuggestionsError instanceof Error - ? args.fileSuggestionsError.message - : undefined; + if (input.commandsState === "error") { + const title = input.providerLabel + ? t("agentAutocomplete.providerCommandsFailed", { provider: input.providerLabel }) + : t("agentAutocomplete.commandsFailed"); + return { + options: [], + status: { kind: "error", title, reason: input.error?.message, onRetry: input.onRetry }, + }; } - return undefined; + return withEmptyStatus(input.options(), input.emptyText); +} + +function resolveFileMenu(input: { + options: () => AgentAutocompleteOption[]; + isPending: boolean; + isLoading: boolean; + error: unknown; + t: TFunction; +}): AutocompleteMenu { + const options = input.options(); + if (input.isPending || (input.isLoading && options.length === 0)) { + return { + options: [], + status: { kind: "loading", message: input.t("agentAutocomplete.searchingWorkspace") }, + }; + } + if (input.error instanceof Error) { + return { + options: [], + status: { + kind: "error", + title: input.t("agentAutocomplete.filesFailed"), + reason: input.error.message, + }, + }; + } + return withEmptyStatus(options, input.t("agentAutocomplete.noFiles")); +} + +function withEmptyStatus(options: AgentAutocompleteOption[], emptyText: string): AutocompleteMenu { + return options.length > 0 + ? { options, status: null } + : { options, status: { kind: "empty", message: emptyText } }; +} + +/** How long a command menu waits for commands before it opens on a loading row. */ +const COMMANDS_LOADING_REVEAL_MS = 300; + +/** + * Whether the open command menu has waited long enough to show a loading row. Commands that + * arrive sooner paint the list as the first frame; once the wait is over, loading (a retry) + * shows at once so the open menu never closes on itself. + */ +function useLoadingRevealed(isMenuOpen: boolean): boolean { + const [revealed, setRevealed] = useState(false); + useEffect(() => { + if (!isMenuOpen) { + setRevealed(false); + return undefined; + } + const timer = setTimeout(() => setRevealed(true), COMMANDS_LOADING_REVEAL_MS); + return () => clearTimeout(timer); + }, [isMenuOpen]); + return revealed; +} + +/** The names command rows and status rows show: the provider's label and each plugin's name. */ +function useCommandSourceNames(input: { + serverId: string; + agentId: string; + draftConfig: DraftCommandConfig | undefined; + cwd: string; + isCommandMenuOpen: boolean; + commandsState: CommandsState; + pluginCommandCount: number; +}): { providerLabel: string | null; pluginNames: ReadonlyMap } { + const agentProvider = useSessionStore( + (state) => state.sessions[input.serverId]?.agents?.get(input.agentId)?.provider, + ); + const provider = input.draftConfig?.provider ?? agentProvider; + // Only a loading or error row names the provider; only plugin rows need plugin names. + const { entries: providerEntries } = useProvidersSnapshot(input.serverId, { + cwd: input.cwd, + enabled: input.isCommandMenuOpen && input.commandsState !== "ready", + }); + const pluginList = usePluginList(input.serverId, { + enabled: input.isCommandMenuOpen && input.pluginCommandCount > 0, + }); + const pluginNames = useMemo( + () => new Map((pluginList.data ?? []).map((plugin) => [plugin.id, pluginDisplayName(plugin)])), + [pluginList.data], + ); + return { + providerLabel: provider ? resolveProviderLabel(provider, providerEntries) : null, + pluginNames, + }; } // A draft's agentId is a draft key the daemon does not know, so a draft lists @@ -345,14 +475,10 @@ function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) { }; } -function resolveAutocompleteEmptyText(args: { - mode: AutocompleteMode; +function resolveCommandsEmptyText(args: { draft: DraftCommandTarget | undefined; t: TFunction; }): string { - if (args.mode === "file") { - return args.t("agentAutocomplete.noFiles"); - } if (args.draft?.status === "needs-project") { return args.t("agentAutocomplete.chooseProjectForCommands"); } @@ -438,10 +564,25 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut enabled: mode === "command" && canLoadCommands && canListCommands, draftConfig: queryDraftConfig, }); - const { commands, isError, error } = commandsQuery; - const isCommandsLoading = canListCommands && commandsQuery.isLoading; - - const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading); + const commandsState = resolveCommandsState({ + canListCommands, + isLoading: commandsQuery.isLoading, + isError: commandsQuery.isError, + }); + const isCommandMenuOpen = mode === "command" && canShowAutocomplete; + const isLoadingRevealed = useLoadingRevealed(isCommandMenuOpen); + const isVisible = + canShowAutocomplete && + !(isCommandMenuOpen && commandsState === "loading" && !isLoadingRevealed); + const { providerLabel, pluginNames } = useCommandSourceNames({ + serverId, + agentId, + draftConfig: queryDraftConfig, + cwd: autocompleteCwd, + isCommandMenuOpen, + commandsState, + pluginCommandCount: pluginClientSlashCommands.length, + }); const fileSuggestionsQuery = useQuery({ queryKey: [ @@ -479,33 +620,67 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut placeholderData: keepPreviousData, }); - const options = useMemo( - () => - buildCommandAutocompleteOptions({ + const commandsEmptyText = resolveCommandsEmptyText({ draft, t }); + const retryCommandsQuery = commandsQuery.retry; + const retryCommands = useCallback(() => { + retryCommandsQuery(); + // Pressing Retry takes focus on web; hand it back so the user keeps typing the command. + onAutocompleteApplied?.(); + }, [onAutocompleteApplied, retryCommandsQuery]); + const { options, status } = useMemo(() => { + if (!isVisible) return { options: [], status: null }; + const buildOptions = () => + buildAutocompleteOptions({ activeFileMention, commandFilterQuery, - commands, + commands: commandsQuery.commands, pluginCommands: pluginClientSlashCommands, + pluginNames, activeSlashCommand, fileSuggestions: fileSuggestionsQuery.data ?? [], isDraftContext, - isVisible, mode, t, - }), - [ - activeFileMention, - activeSlashCommand, - commandFilterQuery, - commands, - pluginClientSlashCommands, - fileSuggestionsQuery.data, - isDraftContext, - isVisible, - mode, + }); + if (mode === "file") { + return resolveFileMenu({ + options: buildOptions, + isPending: fileSuggestionsQuery.isPending, + isLoading: fileSuggestionsQuery.isLoading, + error: fileSuggestionsQuery.error, + t, + }); + } + return resolveCommandMenu({ + commandsState, + options: buildOptions, + providerLabel, + error: commandsQuery.error, + onRetry: retryCommands, + emptyText: commandsEmptyText, t, - ], - ); + }); + }, [ + activeFileMention, + activeSlashCommand, + commandFilterQuery, + commandsEmptyText, + commandsQuery.commands, + commandsQuery.error, + commandsState, + fileSuggestionsQuery.data, + fileSuggestionsQuery.error, + fileSuggestionsQuery.isLoading, + fileSuggestionsQuery.isPending, + isDraftContext, + isVisible, + mode, + pluginClientSlashCommands, + pluginNames, + providerLabel, + retryCommands, + t, + ]); const onSelectOption = useCallback( (option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => { @@ -587,35 +762,11 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut : undefined, }); - const isLoading = resolveAutocompleteIsLoading({ - mode, - isCommandsLoading, - fileSuggestionsIsPending: fileSuggestionsQuery.isPending, - fileSuggestionsIsLoading: fileSuggestionsQuery.isLoading, - optionsLength: options.length, - }); - const errorMessage = resolveAutocompleteErrorMessage({ - mode, - isCommandError: isError, - commandError: error, - fileSuggestionsError: fileSuggestionsQuery.error, - t, - }); - - const loadingText = - mode === "file" - ? t("agentAutocomplete.searchingWorkspace") - : t("agentAutocomplete.loadingCommands"); - const emptyText = resolveAutocompleteEmptyText({ mode, draft, t }); - return { isVisible, options, selectedIndex, - isLoading, - errorMessage, - loadingText, - emptyText, + status, onSelectOption, onKeyPress, }; diff --git a/packages/app/src/hooks/use-autocomplete.ts b/packages/app/src/hooks/use-autocomplete.ts index b461c95551..6392602810 100644 --- a/packages/app/src/hooks/use-autocomplete.ts +++ b/packages/app/src/hooks/use-autocomplete.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getAutocompleteFallbackIndex, getAutocompleteNextIndex, @@ -31,38 +31,22 @@ export function useAutocomplete< TOption, TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent, >(input: UseAutocompleteInput): UseAutocompleteResult { - const [selectedIndex, setSelectedIndex] = useState(-1); - const previousQueryRef = useRef(""); + // The user's choice, tied to the query it was made for. The shown index is resolved from it + // during render, so a list never paints its first frame without a selection: the popover only + // measures once a non-empty list has one. + const [choice, setChoice] = useState<{ index: number; query: string } | null>(null); + const itemCount = input.isVisible ? input.options.length : 0; + const selectedIndex = resolveSelectedIndex({ + choice, + query: input.query, + itemCount, + optionsPosition: input.optionsPosition, + }); + // A choice belongs to one search. Until this clears it, the query check above ignores it. useEffect(() => { - if (!input.isVisible) { - previousQueryRef.current = input.query; - setSelectedIndex(-1); - return; - } - - const queryChanged = previousQueryRef.current !== input.query; - previousQueryRef.current = input.query; - - setSelectedIndex((current) => { - if (input.options.length === 0) { - return -1; - } - - const fallbackIndex = getAutocompleteFallbackIndex( - input.options.length, - input.optionsPosition, - ); - - if (queryChanged) { - return fallbackIndex; - } - if (current < 0 || current >= input.options.length) { - return fallbackIndex; - } - return current; - }); - }, [input.isVisible, input.options.length, input.query, input.optionsPosition]); + setChoice(null); + }, [input.isVisible, input.query]); const onKeyPress = useCallback( (event: TKeyPressEvent) => { @@ -70,41 +54,22 @@ export function useAutocomplete< return false; } - if (event.key === "ArrowUp") { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); - setSelectedIndex((current) => - getAutocompleteNextIndex({ - currentIndex: current, + setChoice({ + index: getAutocompleteNextIndex({ + currentIndex: selectedIndex, itemCount: input.options.length, - key: "ArrowUp", + key: event.key, }), - ); - return true; - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - setSelectedIndex((current) => - getAutocompleteNextIndex({ - currentIndex: current, - itemCount: input.options.length, - key: "ArrowDown", - }), - ); + query: input.query, + }); return true; } if (event.key === "Tab" || event.key === "Enter") { event.preventDefault(); - const fallbackIndex = getAutocompleteFallbackIndex( - input.options.length, - input.optionsPosition, - ); - const resolvedIndex = - selectedIndex >= 0 && selectedIndex < input.options.length - ? selectedIndex - : fallbackIndex; - const selectedOption = input.options[resolvedIndex]; + const selectedOption = input.options[selectedIndex]; if (selectedOption) { input.onSelectOption(selectedOption, event); } @@ -127,3 +92,21 @@ export function useAutocomplete< onKeyPress, }; } + +function resolveSelectedIndex(input: { + choice: { index: number; query: string } | null; + query: string; + itemCount: number; + optionsPosition?: AutocompleteOptionsPosition; +}): number { + const { choice } = input; + if ( + choice && + choice.query === input.query && + choice.index >= 0 && + choice.index < input.itemCount + ) { + return choice.index; + } + return getAutocompleteFallbackIndex(input.itemCount, input.optionsPosition); +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index b73b2c4fec..af4c059fb2 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -820,7 +820,7 @@ describe("translation resources", () => { expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored"); expect(en.agentAutocomplete.searchingWorkspace).toBe("Searching workspace..."); expect(en.agentAutocomplete.noCommands).toBe("No commands found"); - expect(en.agentAutocomplete.failedToLoad).toBe("Failed to load"); + expect(en.agentAutocomplete.commandsFailed).toBe("Couldn't load commands"); expect(en.loadOlderHistory.failed).toBe("Couldn't load older history"); expect(en.agentControls.thinking.extraHigh).toBe("Extra high"); expect(en.agentControls.model.unknown).toBe("Unknown model"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 48e7f15d4f..9b3c41f663 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1642,7 +1642,11 @@ export const ar: TranslationResources = { loadingCommands: "جارٍ تحميل الأوامر...", noFiles: "لم يتم العثور على ملفات أو أدلة", noCommands: "لم يتم العثور على أي أوامر", - failedToLoad: "فشل التحميل", + loadingProviderCommands: "جارٍ تحميل أوامر {{provider}}...", + commandsFailed: "تعذر تحميل الأوامر", + providerCommandsFailed: "تعذر تحميل أوامر {{provider}}", + filesFailed: "تعذر البحث في الملفات", + skillTag: "مهارة", chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر", chooseModelForCommands: "اختر نموذجًا لعرض الأوامر", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bf1444d3ad..ecb2890c93 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1668,7 +1668,11 @@ export const en = { loadingCommands: "Loading commands...", noFiles: "No files or directories found", noCommands: "No commands found", - failedToLoad: "Failed to load", + loadingProviderCommands: "Loading {{provider}} commands...", + commandsFailed: "Couldn't load commands", + providerCommandsFailed: "Couldn't load {{provider}} commands", + filesFailed: "Couldn't search files", + skillTag: "Skill", chooseProjectForCommands: "Choose a project to see commands", chooseModelForCommands: "Select a model to see commands", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index cf2534f323..11968541d4 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1689,7 +1689,11 @@ export const es: TranslationResources = { loadingCommands: "Cargando comandos...", noFiles: "No se encontraron archivos ni directorios", noCommands: "No se encontraron comandos", - failedToLoad: "No se pudo cargar", + loadingProviderCommands: "Cargando comandos de {{provider}}...", + commandsFailed: "No se pudieron cargar los comandos", + providerCommandsFailed: "No se pudieron cargar los comandos de {{provider}}", + filesFailed: "No se pudieron buscar archivos", + skillTag: "Skill", chooseProjectForCommands: "Elige un proyecto para ver los comandos", chooseModelForCommands: "Selecciona un modelo para ver los comandos", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 73acbfb9e8..4f31acf15a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1686,7 +1686,11 @@ export const fr: TranslationResources = { loadingCommands: "Chargement des commandes…", noFiles: "Aucun fichier ou répertoire trouvé", noCommands: "Aucune commande trouvée", - failedToLoad: "Échec du chargement", + loadingProviderCommands: "Chargement des commandes {{provider}}…", + commandsFailed: "Impossible de charger les commandes", + providerCommandsFailed: "Impossible de charger les commandes {{provider}}", + filesFailed: "Impossible de rechercher les fichiers", + skillTag: "Skill", chooseProjectForCommands: "Choisissez un projet pour afficher les commandes", chooseModelForCommands: "Sélectionnez un modèle pour afficher les commandes", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a845f43303..4913934cfd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1660,7 +1660,11 @@ export const ja: TranslationResources = { loadingCommands: "コマンドを読み込み中...", noFiles: "ファイルまたはディレクトリが見つかりません", noCommands: "コマンドが見つかりません", - failedToLoad: "読み込みに失敗しました", + loadingProviderCommands: "{{provider}} のコマンドを読み込み中...", + commandsFailed: "コマンドを読み込めませんでした", + providerCommandsFailed: "{{provider}} のコマンドを読み込めませんでした", + filesFailed: "ファイルを検索できませんでした", + skillTag: "スキル", chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください", chooseModelForCommands: "コマンドを表示するにはモデルを選択してください", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 033affa05e..76c101c1d1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1653,7 +1653,11 @@ export const ko: TranslationResources = { loadingCommands: "명령 불러오는 중...", noFiles: "파일 또는 디렉터리를 찾을 수 없습니다", noCommands: "명령을 찾을 수 없습니다", - failedToLoad: "불러오지 못했습니다", + loadingProviderCommands: "{{provider}} 명령 불러오는 중...", + commandsFailed: "명령을 불러오지 못했습니다", + providerCommandsFailed: "{{provider}} 명령을 불러오지 못했습니다", + filesFailed: "파일을 검색하지 못했습니다", + skillTag: "스킬", chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요", chooseModelForCommands: "명령을 보려면 모델을 선택하세요", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c48f2974bc..cbd13dfd60 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1674,7 +1674,11 @@ export const ptBR: TranslationResources = { loadingCommands: "Carregando comandos...", noFiles: "Nenhum arquivo ou diretório encontrado", noCommands: "Nenhum comando encontrado", - failedToLoad: "Falha ao carregar", + loadingProviderCommands: "Carregando comandos do {{provider}}...", + commandsFailed: "Não foi possível carregar os comandos", + providerCommandsFailed: "Não foi possível carregar os comandos do {{provider}}", + filesFailed: "Não foi possível buscar arquivos", + skillTag: "Skill", chooseProjectForCommands: "Escolha um projeto para ver os comandos", chooseModelForCommands: "Selecione um modelo para ver os comandos", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 226c03cfe4..ee788167f5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1672,7 +1672,11 @@ export const ru: TranslationResources = { loadingCommands: "Загрузка команд...", noFiles: "Файлы и каталоги не найдены", noCommands: "Команды не найдены", - failedToLoad: "Не удалось загрузить", + loadingProviderCommands: "Загрузка команд {{provider}}...", + commandsFailed: "Не удалось загрузить команды", + providerCommandsFailed: "Не удалось загрузить команды {{provider}}", + filesFailed: "Не удалось найти файлы", + skillTag: "Навык", chooseProjectForCommands: "Выберите проект, чтобы увидеть команды", chooseModelForCommands: "Выберите модель, чтобы увидеть команды", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f95fdc9dfb..ea9d34fd4f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1625,7 +1625,11 @@ export const zhCN: TranslationResources = { loadingCommands: "正在加载 commands...", noFiles: "没有找到文件或目录", noCommands: "没有找到 commands", - failedToLoad: "加载失败", + loadingProviderCommands: "正在加载 {{provider}} commands...", + commandsFailed: "无法加载 commands", + providerCommandsFailed: "无法加载 {{provider}} commands", + filesFailed: "无法搜索文件", + skillTag: "Skill", chooseProjectForCommands: "选择项目以查看命令", chooseModelForCommands: "选择模型以查看命令", }, diff --git a/packages/app/src/utils/agent-command-autocomplete.test.ts b/packages/app/src/utils/agent-command-autocomplete.test.ts index 9d0067a02c..8443c0688b 100644 --- a/packages/app/src/utils/agent-command-autocomplete.test.ts +++ b/packages/app/src/utils/agent-command-autocomplete.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + getAutocompleteFallbackIndex, + orderAutocompleteOptions, +} from "@/components/ui/autocomplete-utils"; import { applySlashCommandReplacement, filterAndRankCommandAutocompleteEntries, @@ -33,6 +37,53 @@ describe("filterAndRankCommandAutocompleteEntries", () => { }); }); +describe("filterAndRankCommandAutocompleteEntries on an empty query", () => { + const rootEntries = [ + { source: "client" as const, command: { name: "exit", aliases: ["quit", "q"] } }, + { source: "client" as const, command: { name: "clear", aliases: ["new"] } }, + { source: "plugin" as const, command: { name: "handoff" } }, + { source: "provider" as const, command: { name: "compact" } }, + { source: "provider" as const, command: { name: "review" } }, + ]; + + function selectedOnEnter(query: string) { + const listed = orderAutocompleteOptions( + filterAndRankCommandAutocompleteEntries(rootEntries, query), + ); + return listed[getAutocompleteFallbackIndex(listed.length)]?.command.name; + } + + it("selects a provider command, never an immediate Paseo action", () => { + expect(selectedOnEnter("")).toBe("compact"); + }); + + it("lists Paseo actions farthest from the input", () => { + const listed = orderAutocompleteOptions( + filterAndRankCommandAutocompleteEntries(rootEntries, ""), + ); + expect(listed.map((entry) => entry.command.name)).toEqual([ + "clear", + "exit", + "handoff", + "review", + "compact", + ]); + }); + + it("still selects a typed Paseo action", () => { + expect(selectedOnEnter("exit")).toBe("exit"); + }); + + it("waits for a search when Paseo actions are all there is", () => { + const clientOnly = rootEntries.filter((entry) => entry.source === "client"); + + expect(filterAndRankCommandAutocompleteEntries(clientOnly, "")).toEqual([]); + expect( + filterAndRankCommandAutocompleteEntries(clientOnly, "ex").map((entry) => entry.command.name), + ).toEqual(["exit"]); + }); +}); + describe("findActiveSlashCommand", () => { it("detects a slash command token in the middle of the prompt", () => { const text = "use /tas before implementation"; diff --git a/packages/app/src/utils/agent-command-autocomplete.ts b/packages/app/src/utils/agent-command-autocomplete.ts index 39ee7f94d1..ffc5810b94 100644 --- a/packages/app/src/utils/agent-command-autocomplete.ts +++ b/packages/app/src/utils/agent-command-autocomplete.ts @@ -4,7 +4,10 @@ import { scoreTextFields, } from "@getpaseo/protocol/search/text-match"; +type CommandAutocompleteSource = "provider" | "plugin" | "client"; + interface CommandAutocompleteEntry { + source: CommandAutocompleteSource; command: { name: string; aliases?: readonly string[]; @@ -12,9 +15,14 @@ interface CommandAutocompleteEntry { }; } -interface InlineSkillCommandEntry extends CommandAutocompleteEntry { - source: "provider" | "client"; -} +// The list renders reversed above the input with the first entry selected. Paseo's own actions +// run at once (`/exit` archives), so an empty query never selects one: they sort farthest from +// the input, and with nothing else to list the menu waits for a search. +const EMPTY_QUERY_SOURCE_ORDER: Record = { + provider: 0, + plugin: 1, + client: 2, +}; export type SlashCommandPosition = "start" | "inline"; @@ -54,7 +62,11 @@ export function filterAndRankCommandAutocompleteEntries entry.source === "client")) return []; + // Hermes has no `toSorted`. + return [...entries].sort( + (a, b) => EMPTY_QUERY_SOURCE_ORDER[a.source] - EMPTY_QUERY_SOURCE_ORDER[b.source], + ); } const scoredEntries: ScoredCommandAutocompleteEntry[] = []; @@ -76,7 +88,7 @@ export function filterAndRankCommandAutocompleteEntries scored.entry); } -export function filterInlineSkillCommandEntries( +export function filterInlineSkillCommandEntries( entries: readonly TEntry[], ): TEntry[] { return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill"); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index a9f709ab1a..07b46c6b7b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -2985,7 +2985,7 @@ describe("ClaudeAgentSession context window usage", () => { expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true); }); - test("classifies Claude root-only commands separately from inline skills", async () => { + test("classifies Claude root-only commands separately from inline skills and hides internal ones", async () => { const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable }) => { void prompt; return { @@ -3018,6 +3018,11 @@ describe("ClaudeAgentSession context window usage", () => { description: "Start a new session with empty context", argumentHint: "", }, + { + name: "__remote-workflow", + description: "Run a remote workflow (server-launched sessions only)", + argumentHint: "", + }, ], rewindFiles: async () => ({ canRewind: true }), [Symbol.asyncIterator]() { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index bc5ab24af4..06b0c36e1f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -442,6 +442,12 @@ interface SlashCommandInvocation { rawInput: string; } +// Claude Code reserves `__`-prefixed commands for its own server-launched sessions +// (for example `/__remote-workflow`); a user cannot run them from a Paseo agent. +function isClaudeInternalSlashCommand(commandName: string): boolean { + return commandName.startsWith("__"); +} + function classifyClaudeSlashCommand(commandName: string): AgentSlashCommand["kind"] { // Claude exposes commands and skills as one flat SDK list, without structured source // metadata. Keep obvious root-only/session controls out of inline autocomplete and @@ -3115,6 +3121,7 @@ class ClaudeAgentSession implements AgentSession { const commands = await q.supportedCommands(); const commandMap = new Map(); for (const cmd of commands) { + if (isClaudeInternalSlashCommand(cmd.name)) continue; if (!commandMap.has(cmd.name)) { commandMap.set(cmd.name, { name: cmd.name, From 73448d483ed8de1103785d6a1180eee585a716fb Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:55:09 +0000 Subject: [PATCH 18/32] test(app): scope command-center stability checks to the panel (#8927) Document-wide layout shifts could fail a stable file-search panel. Count only shifts attributed to the panel or its descendants, and exercise both unrelated and in-panel movement in the existing browser journey. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../command-center-file-search.spec.ts | 4 ++ .../helpers/command-center-file-search.ts | 62 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 2f16db6f09..3dd51a3622 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -5,8 +5,10 @@ import { holdDirectorySuggestionResponses, expectOneLineTruncatedFileResult, expectStableCommandCenterLayout, + expectLayoutObservationToCatchPanelShift, failDirectorySuggestionRequests, startCommandCenterLayoutObservation, + shiftOutsideCommandCenter, } from "../support/helpers/command-center-file-search"; import { scrollCommandCenterAwayFromSelection, @@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible(); await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0); await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await shiftOutsideCommandCenter(page); await expectStableCommandCenterLayout(page); + await expectLayoutObservationToCatchPanelShift(page); await row.click(); await expectFileTabOpen(page, FILE_PATH); diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts index eb0b888d24..175388a952 100644 --- a/packages/app/e2e/support/helpers/command-center-file-search.ts +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -99,6 +99,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise(); const resizeObserver = new ResizeObserver(record); const mutationObserver = new MutationObserver(discover); + let performanceObserver: PerformanceObserver | undefined; function record(): void { if (!observation.active) return; @@ -132,6 +133,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise { + performanceObserver = new PerformanceObserver((list) => { + const panel = document.querySelector(selectors.panel); for (const entry of list.getEntries()) { - observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0; + const shift = entry as PerformanceEntry & { + value: number; + sources: Array<{ node: Node | null }>; + }; + if (shift.sources.some((source) => source.node && panel?.contains(source.node))) { + observation.layoutShift += shift.value; + } } }); performanceObserver.observe({ type: "layout-shift", buffered: false }); @@ -174,7 +183,54 @@ export async function expectStableCommandCenterLayout(page: Page): Promise ).toBeLessThanOrEqual(1); } } - expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001); + expect( + observation.layoutShift, + `command center layoutShift: ${JSON.stringify(observation)}`, + ).toBeLessThanOrEqual(0.001); +} + +/** Exercise an unrelated page shift while the real search panel is being observed. */ +export async function shiftOutsideCommandCenter(page: Page): Promise { + await shiftLayoutProbe(page.locator("body")); +} + +export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise { + await startCommandCenterLayoutObservation(page); + await shiftLayoutProbe(page.getByTestId("command-center-panel")); + await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift"); +} + +async function shiftLayoutProbe(parent: Locator): Promise { + const recordedShift = await parent.evaluate(async (container) => { + const sibling = document.createElement("div"); + sibling.textContent = "Layout probe"; + sibling.style.cssText = + "position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999"; + container.append(sibling); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + const value = await new Promise((resolve) => { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const shift = entry as PerformanceEntry & { + value: number; + sources: Array<{ node: Node | null }>; + }; + if (shift.sources.some((source) => source.node === sibling)) { + observer.disconnect(); + resolve(shift.value); + } + } + }); + observer.observe({ type: "layout-shift", buffered: false }); + sibling.style.left = "400px"; + }); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + sibling.remove(); + return value; + }); + expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001); } export async function expectOneLineTruncatedFileResult( From a9bce9a18d6e8e819ba52101d4fd1323c0daf237 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 23:10:29 +0700 Subject: [PATCH 19/32] Keep the chat still when an agent's turn ends (#8944) * Land a turn's reply, completed footer, and tool summary in one frame The stream handler closed turn liveness as soon as turn_completed arrived, while the final reply waited in the frame-coalesced reducer queue. For one commit the turn was finished with no reply: the footer vanished and the timeline jumped twice, and tool turns folded into their summary later. The viewed timeline owner now takes stream events whole: a turn boundary commits the queued content before liveness changes. Running and completed footers share one fixed content height so the phase switch moves nothing. * Document the turn-boundary commit and stop racing the re-pin in the turn-open test The turn-open test scrolled the received message into view with a programmatic scroll, which leaves the chat following its end. Pressing the turn summary swaps its icon, and that DOM change re-pins the chat to the end between pointerdown and pointerup. The summary had moved 14 px, which left the pointer 2 px inside the button; with the completed footer now 2 px taller, it moved 16 px and the click landed on the reply below. The message's fade and Show more are already on screen at the end, so the test no longer scrolls. --- docs/agent-stream-performance.md | 1 + .../app/e2e/browser/agent-turn-end.spec.ts | 200 ++++++++++++++++++ .../app/e2e/browser/turn-collapse.spec.ts | 1 - packages/app/src/agent-stream/turn-footer.tsx | 14 +- packages/app/src/contexts/session-context.tsx | 16 +- packages/app/src/runtime/host-runtime.ts | 2 + packages/app/src/timeline/replica.test.ts | 96 ++++++++- .../app/src/timeline/viewed-timeline-sync.ts | 16 +- 8 files changed, 319 insertions(+), 27 deletions(-) create mode 100644 packages/app/e2e/browser/agent-turn-end.spec.ts diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index fb022895bc..4c43718215 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -34,6 +34,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. +- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice. - **A history row re-renders only when its item or layout item identity changes.** Every coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a diff --git a/packages/app/e2e/browser/agent-turn-end.spec.ts b/packages/app/e2e/browser/agent-turn-end.spec.ts new file mode 100644 index 0000000000..523297729c --- /dev/null +++ b/packages/app/e2e/browser/agent-turn-end.spec.ts @@ -0,0 +1,200 @@ +import type { Page } from "@playwright/test"; +import { expect, test as baseTest } from "../support/fixtures"; +import { expectAgentIdle } from "../support/helpers/agent-stream"; +import { submitMessage } from "../support/helpers/composer"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentWorkspace, +} from "../support/helpers/mock-agent"; + +// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving +// within a few milliseconds of each other. These tests hold all three, let the running +// turn paint, then release them together and sample every painted frame. + +interface Box { + top: number; + height: number; +} + +interface TurnEndFrame { + at: number; + running: boolean; + footer: Box | null; + userRow: Box | null; + replyVisible: boolean; +} + +declare global { + interface Window { + __turnEndFrames?: { active: boolean; frames: TurnEndFrame[] }; + } +} + +const test = baseTest.extend<{ agent: MockAgentWorkspace }>({ + agent: async ({ page: _page }, provide) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "turn-end-", + title: "Turn end", + initialPrompt: "emit 4 tool agent stream updates", + }); + await agent.client.waitForFinish(agent.agentId, 15_000); + await provide(agent); + await agent.cleanup(); + }, +}); + +async function recordTurnEndFrames( + page: Page, + input: { prompt: string; reply: string | null }, +): Promise { + await page.evaluate(({ prompt, reply }) => { + const state = { active: true, frames: [] as TurnEndFrame[] }; + const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility()); + const boxOf = (element: Element | null | undefined): Box | null => { + if (!element || !isVisible(element)) return null; + const rect = element.getBoundingClientRect(); + return rect.height > 0 ? { top: rect.top, height: rect.height } : null; + }; + const sample = () => { + const viewport = Array.from( + document.querySelectorAll('[data-testid="agent-chat-scroll"]'), + ).find((candidate) => isVisible(candidate)); + const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? []) + .map(boxOf) + .filter((box): box is Box => box !== null) + .sort((left, right) => left.top - right.top); + const userRow = Array.from( + viewport?.querySelectorAll('[data-testid="user-message"]') ?? [], + ).find((candidate) => candidate.textContent?.includes(prompt)); + const replyRow = + reply === null + ? undefined + : Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find( + (candidate) => candidate.textContent?.includes(reply), + ); + state.frames.push({ + at: performance.now(), + running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')), + footer: footers.at(-1) ?? null, + userRow: boxOf(userRow), + replyVisible: boxOf(replyRow) !== null, + }); + if (state.active) requestAnimationFrame(() => setTimeout(sample, 0)); + }; + window.__turnEndFrames = state; + requestAnimationFrame(() => setTimeout(sample, 0)); + }, input); +} + +async function recordPaintsFor(page: Page, durationMs: number): Promise { + const until = await page.evaluate((duration) => performance.now() + duration, durationMs); + await expect + .poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0)) + .toBeGreaterThanOrEqual(until); +} + +async function stopRecording(page: Page): Promise { + return page.evaluate(() => { + const state = window.__turnEndFrames; + if (!state) throw new Error("Turn end frames were not recorded"); + state.active = false; + return state.frames; + }); +} + +async function recordTurnEnd( + page: Page, + agent: MockAgentWorkspace, + input: { prompt: string; reply: string | null }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectAgentIdle(page); + + if (input.reply === null) { + gate.setAgentStreamItemSuppressed("assistant_message", true); + } else { + gate.holdNextAgentStreamItem("assistant_message"); + } + gate.holdNextAgentStreamEvent("turn_completed"); + // The daemon sends more than one idle snapshot; hold the first and drop the rest until release. + gate.holdNextAgentUpdate(agent.agentId, "idle"); + gate.setServerMessageSuppressed("agent_update", true); + await recordTurnEndFrames(page, input); + await submitMessage(page, input.prompt); + await Promise.all([ + input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"), + gate.waitForHeldAgentStreamEvent("turn_completed"), + gate.waitForHeldAgentUpdate(agent.agentId, "idle"), + ]); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await recordPaintsFor(page, 200); + + if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message"); + gate.releaseHeldAgentStreamEvent("turn_completed"); + gate.releaseHeldAgentUpdate(agent.agentId, "idle"); + gate.setServerMessageSuppressed("agent_update", false); + await expectAgentIdle(page); + await recordPaintsFor(page, 300); + return stopRecording(page); +} + +function describeFrames(frames: TurnEndFrame[]): string { + return frames + .map( + (frame, index) => + `${index}: ${frame.running ? "running " : "complete"} footer=${ + frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none" + } prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`, + ) + .join("\n"); +} + +function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void { + const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null); + const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running); + const record = `frames:\n${describeFrames(frames)}`; + expect(firstRunning, record).toBeGreaterThanOrEqual(0); + expect(firstComplete, record).toBeGreaterThan(firstRunning); + + const transition = frames.slice(firstRunning); + const runningHeight = transition[0].footer?.height; + expect( + transition.map((frame) => frame.footer?.height ?? null), + `the footer must stay painted at one height\n${record}`, + ).toEqual(transition.map(() => runningHeight)); + if (input.reply !== null) { + expect( + frames.slice(firstComplete).every((frame) => frame.replyVisible), + `a completed turn must show its reply in the same frame\n${record}`, + ).toBe(true); + } + // Tool rows land while the turn runs; only the turn end itself must be one step. + const ending = frames.slice(firstComplete - 1); + const promptMoves = ending + .slice(1) + .filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top); + expect( + promptMoves.length, + `the prompt must move at most once as the turn ends\n${record}`, + ).toBeLessThanOrEqual(1); +} + +test.describe("Turn end", () => { + test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => { + const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" }; + expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input); + }); + + test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => { + const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" }; + expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input); + }); + + test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => { + const input = { prompt: "emit 2 tool agent stream updates", reply: null }; + expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input); + }); +}); diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index a7138e0850..bcfca2f763 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -407,7 +407,6 @@ test("opening a turn keeps the received message above it clipped on every frame" .getByTestId("user-message") .filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) }); await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible(); - await bubble.scrollIntoViewIfNeeded(); await watchReceivedMessagePreview(page, "Sent by Reviewer"); await page diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 5508803b5d..08fceb4005 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -23,6 +23,10 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; const ThemedSyncedLoader = withUnistyles(SyncedLoader); const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8]; +// Every footer variant (running, completed with Copy, completed with only its duration) gets +// this content height, so a turn ending swaps the footer's controls without moving the rows +// above it. +const TURN_FOOTER_CONTENT_HEIGHT = 24; export type TurnContentStrategy = StreamStrategy; export type AssistantTurnForkHandler = (input: { @@ -208,7 +212,11 @@ function CompletedTurnFooter({ function TurnFooterRow({ children }: { children: ReactNode }) { const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []); - return {children}; + return ( + + {children} + + ); } const stylesheet = StyleSheet.create((theme) => ({ @@ -229,11 +237,11 @@ const stylesheet = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", - minHeight: 24, + height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, paddingBottom: TURN_FOOTER_BOTTOM_SPACING, }, turnFooterContent: { - height: 24, + height: TURN_FOOTER_CONTENT_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "flex-start", diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 89c32c0bd5..0b46f5f2de 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -12,7 +12,6 @@ import { refreshAgentInitializationTimeout, } from "@/hooks/use-agent-initialization"; import type { StreamItem } from "@/types/stream"; -import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; @@ -360,8 +359,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const onStream = (message: SessionOutboundMessage) => { if (message.type !== "agent_stream") return; const { agentId, event, timestamp, seq, epoch } = message.payload; - const parsedTimestamp = new Date(timestamp); - const streamEvent = event; if ( event.type === "turn_started" || event.type === "turn_completed" || @@ -370,18 +367,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) { voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } - const turnLiveness = deriveAgentStreamTurnLiveness([ - { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, - ]); - if (turnLiveness.length > 0) { - getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); - } - sync.enqueueStreamEvent(agentId, { - event: streamEvent, - seq, - epoch, - timestamp: parsedTimestamp, - }); + sync.applyStreamEvent(agentId, { event, seq, epoch, timestamp: new Date(timestamp) }); // NOTE: We don't update lastActivityAt on every stream event to prevent // cascading rerenders. The agent_update handler updates agent.lastActivityAt diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 4d31258aaa..b6d10dc8c7 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2852,6 +2852,8 @@ export class HostRuntimeStore { replica, replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), + applyTurnLiveness: (agentId, transitions) => + directory.applyAgentTurnLiveness(agentId, transitions), ports, }); } diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 859ae8e6b7..0ce9b8ca2b 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -11,6 +11,7 @@ import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; import type { StreamItem } from "@/types/stream"; +import type { TurnLivenessTransition } from "./turn-liveness"; import { createTimelineReplica, createViewedTimelineOwner, @@ -40,7 +41,13 @@ function cachedTimeline(): CachedTimeline { }; } -function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { +function createOwner( + storage: TimelineReplicaStorage, + applyTurnLiveness: ( + agentId: string, + transitions: readonly TurnLivenessTransition[], + ) => void = () => undefined, +): ViewedTimelineOwner { const replica = createTimelineReplica({ serverId: SERVER_ID, storage, @@ -51,6 +58,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { replica, replaceDemandedAgentIds: () => undefined, drainQueuedAgentMessage: () => undefined, + applyTurnLiveness, ports: { observe: () => ({ ready: Promise.resolve(), release: async () => undefined }), readCursor: () => undefined, @@ -98,7 +106,7 @@ describe("viewed timeline persistence", () => { .status, ) .toBe("painted"); - owner.enqueueStreamEvent(AGENT_ID, { + owner.applyStreamEvent(AGENT_ID, { event: { type: "timeline", provider: "codex", @@ -147,7 +155,7 @@ describe("viewed timeline persistence", () => { .map((entry) => entry.text); }; expect.soft(replyTexts()).toEqual([complete]); - owner.enqueueStreamEvent(AGENT_ID, { + owner.applyStreamEvent(AGENT_ID, { event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload, seq: 7, epoch: "epoch-1", @@ -281,7 +289,7 @@ describe("viewed timeline persistence", () => { ) .toMatchObject({ status: "painted" }); - first.enqueueStreamEvent(AGENT_ID, { + first.applyStreamEvent(AGENT_ID, { event: { type: "timeline", provider: "codex", @@ -460,7 +468,7 @@ describe("viewed timeline persistence", () => { readTimeline: async () => undefined, commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline), }); - owner.enqueueStreamEvent(AGENT_ID, { + owner.applyStreamEvent(AGENT_ID, { event: { type: "timeline", provider: "codex", @@ -525,7 +533,7 @@ describe("viewed timeline persistence", () => { [AGENT_ID, 9], ["agent-2", 4], ] as const) { - owner.enqueueStreamEvent(agentId, { + owner.applyStreamEvent(agentId, { event: { type: "timeline", provider: "codex", @@ -543,6 +551,81 @@ describe("viewed timeline persistence", () => { }); }); +describe("turn boundaries", () => { + it("commits a turn's final reply before the turn stops running", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + applySynced(AGENT_ID, 8); + const committedKinds = () => { + const session = useSessionStore.getState().sessions[SERVER_ID]; + return [ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ] + .filter((entry) => entry.id !== `network-${AGENT_ID}`) + .map((entry) => entry.kind); + }; + const livenessChanges: { type: string; committed: string[] }[] = []; + const owner = createOwner( + { readTimeline: async () => undefined, commitTimeline: () => undefined }, + (_agentId, transitions) => { + for (const transition of transitions) { + livenessChanges.push({ type: transition.type, committed: committedKinds() }); + } + }, + ); + const at = (second: number) => new Date(`2026-08-26T10:00:0${second}.000Z`); + + // One frame's worth of live events: the frame flush that would commit them never runs. + owner.applyStreamEvent(AGENT_ID, { + event: { type: "turn_started", provider: "codex", turnId: "turn-1" }, + seq: undefined, + epoch: undefined, + timestamp: at(1), + }); + owner.applyStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + turnId: "turn-1", + item: { + type: "tool_call", + callId: "call-1", + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: "ls" }, + }, + } as AgentStreamEventPayload, + seq: 9, + epoch: "epoch-1", + timestamp: at(2), + }); + owner.applyStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + turnId: "turn-1", + item: { type: "assistant_message", text: "Done.", messageId: "reply-1" }, + } as AgentStreamEventPayload, + seq: 10, + epoch: "epoch-1", + timestamp: at(3), + }); + owner.applyStreamEvent(AGENT_ID, { + event: { type: "turn_completed", provider: "codex", turnId: "turn-1" }, + seq: undefined, + epoch: undefined, + timestamp: at(4), + }); + + expect(livenessChanges.at(-1)).toEqual({ + type: "stream_close", + committed: ["tool_call", "assistant_message"], + }); + owner.dispose(); + }); +}); + describe("create handoff lifetime", () => { const DRAFT_ID = "draft-1"; @@ -606,6 +689,7 @@ describe("create handoff lifetime", () => { replica, replaceDemandedAgentIds: () => undefined, drainQueuedAgentMessage: () => undefined, + applyTurnLiveness: () => undefined, ports: { observe: () => ({ ready: Promise.resolve(), release: async () => undefined }), readCursor: () => undefined, diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 67defcec04..0f5369c1c4 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -21,12 +21,14 @@ import { } from "@/utils/agent-initialization"; import { createSessionAgentStreamReducerQueue, + deriveAgentStreamTurnLiveness, type AgentStreamReducerEvent, type ProcessTimelineResponseOutput, processTimelineResponse, } from "./session-stream-reducers"; import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; import { replaceWithCanonicalStream } from "@/types/stream"; +import type { TurnLivenessTransition } from "./turn-liveness"; export interface TimelineReplicaStorage { readTimeline(serverId: string, agentId: string): Promise; @@ -361,7 +363,12 @@ export type ViewedTimelineOwnerPorts = Omit< export interface ViewedTimelineOwner extends ViewedTimelineSync { applyTimelineResponse(payload: TimelineResponsePayload): void; - enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void; + /** + * Accepts one live stream event. Content is coalesced to the next frame; a turn boundary + * commits the turn's queued content before its liveness changes, so the view never shows + * a finished turn without its final reply. + */ + applyStreamEvent(agentId: string, event: AgentStreamReducerEvent): void; flushStreamAgent(agentId: string): void; } @@ -370,6 +377,7 @@ export function createViewedTimelineOwner(input: { replica: TimelineReplica; replaceDemandedAgentIds: (agentIds: string[]) => void; drainQueuedAgentMessage: (agentId: string) => void; + applyTurnLiveness: (agentId: string, transitions: readonly TurnLivenessTransition[]) => void; ports: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { const forcedTailReplacements = new Set(); @@ -409,8 +417,12 @@ export function createViewedTimelineOwner(input: { }); if (accepted) input.replica.timelineUpdated(payload.agentId); }, - enqueueStreamEvent(agentId, event) { + applyStreamEvent(agentId, event) { streamQueue.enqueue(agentId, event); + const turnLiveness = deriveAgentStreamTurnLiveness([event]); + if (turnLiveness.length === 0) return; + streamQueue.flushAgent(agentId); + input.applyTurnLiveness(agentId, turnLiveness); }, flushStreamAgent(agentId) { streamQueue.flushAgent(agentId); From f21699a37a3d49e8c61bd6d8b65f8a7070f0470c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 23:10:33 +0700 Subject: [PATCH 20/32] chore(release): reconcile 0.15.3 metadata (#8943) --- CHANGELOG.md | 6 ++ CHANGELOG.zh-CN.md | 6 ++ .../android/en-US/changelogs/150031.txt | 4 ++ .../android/en-US/changelogs/150032.txt | 4 ++ .../android/en-US/changelogs/150033.txt | 4 ++ .../android/en-US/changelogs/150034.txt | 4 ++ nix/npm-deps.hash | 2 +- package-lock.json | 56 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++-- packages/website/package.json | 2 +- plugins/package.json | 2 +- 21 files changed, 84 insertions(+), 56 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/150031.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/150032.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/150033.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/150034.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 10cfa1ef09..fee6f2395e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.15.3 - 2026-11-06 + +### Fixed + +- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp)) + ## 0.15.2 - 2026-11-05 ### Added diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 5f0156eb0e..5a0265ae15 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,11 @@ # Changelog +## 0.15.3 - 2026-11-06 + +### 修复 + +- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp)) + ## 0.15.2 - 2026-11-05 ### 新增 diff --git a/fastlane/metadata/android/en-US/changelogs/150031.txt b/fastlane/metadata/android/en-US/changelogs/150031.txt new file mode 100644 index 0000000000..ee51531c5e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150031.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/150032.txt b/fastlane/metadata/android/en-US/changelogs/150032.txt new file mode 100644 index 0000000000..ee51531c5e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150032.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/150033.txt b/fastlane/metadata/android/en-US/changelogs/150033.txt new file mode 100644 index 0000000000..ee51531c5e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150033.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/150034.txt b/fastlane/metadata/android/en-US/changelogs/150034.txt new file mode 100644 index 0000000000..ee51531c5e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/150034.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ + +Full notes: https://paseo.sh/changelog diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index a6968c10fe..7e8367c9c0 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ= +sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw= diff --git a/package-lock.json b/package-lock.json index e4e82efad8..38a46b7319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.15.2", + "version": "0.15.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.15.2", + "version": "0.15.3", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -37468,7 +37468,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.15.2", + "version": "0.15.3", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -39637,13 +39637,13 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.15.2", - "@getpaseo/protocol": "0.15.2", - "@getpaseo/server": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/protocol": "0.15.3", + "@getpaseo/server": "0.15.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -39890,11 +39890,11 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { - "@getpaseo/protocol": "0.15.2", - "@getpaseo/relay": "0.15.2", + "@getpaseo/protocol": "0.15.3", + "@getpaseo/relay": "0.15.3", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -39906,7 +39906,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -40149,7 +40149,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.15.2", + "version": "0.15.3", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -40195,7 +40195,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "@codemirror/language": "6.12.4", @@ -40225,7 +40225,7 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", @@ -40233,8 +40233,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.15.2", - "@getpaseo/protocol": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/protocol": "0.15.3", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -40244,8 +40244,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.15.2", - "@getpaseo/protocol": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/protocol": "0.15.3", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -40267,7 +40267,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "semver": "^7.8.5", @@ -40295,7 +40295,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "@noble/ciphers": "^2.4.0", @@ -40517,17 +40517,17 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.15.2", - "@getpaseo/highlight": "0.15.2", - "@getpaseo/plugin": "0.15.2", - "@getpaseo/protocol": "0.15.2", - "@getpaseo/relay": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/highlight": "0.15.3", + "@getpaseo/plugin": "0.15.3", + "@getpaseo/protocol": "0.15.3", + "@getpaseo/relay": "0.15.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -41474,7 +41474,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.15.2", + "version": "0.15.3", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -43084,7 +43084,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.15.2", + "version": "0.15.3", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index b5fd31ae8e..a0883ec25d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.15.2", + "version": "0.15.3", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 2c897be9d4..9c2698885b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.15.2", + "version": "0.15.3", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 9248988869..a23676a0fc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.15.2", + "version": "0.15.3", "description": "Paseo CLI - control your AI coding agents from the command line", "license": "Apache-2.0", "bin": { @@ -29,9 +29,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.15.2", - "@getpaseo/protocol": "0.15.2", - "@getpaseo/server": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/protocol": "0.15.3", + "@getpaseo/server": "0.15.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 62e5b8c664..810cb8b0c9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.15.2", + "version": "0.15.3", "description": "Paseo client SDK package", "license": "Apache-2.0", "files": [ @@ -48,8 +48,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.15.2", - "@getpaseo/relay": "0.15.2", + "@getpaseo/protocol": "0.15.3", + "@getpaseo/relay": "0.15.3", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 7e3b01dfaa..a675c51dc1 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.15.2", + "version": "0.15.3", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index b9c1190375..e7a72c97f1 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.15.2", + "version": "0.15.3", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 85d9c90471..009e1677d5 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.15.2", + "version": "0.15.3", "license": "Apache-2.0", "files": [ "dist", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ef537c35e3..deb9319a48 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.15.2", + "version": "0.15.3", "description": "Paseo plugin SDK package", "license": "Apache-2.0", "files": [ @@ -78,8 +78,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.15.2", - "@getpaseo/protocol": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/protocol": "0.15.3", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -89,8 +89,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.15.2", - "@getpaseo/protocol": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/protocol": "0.15.3", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 4667c9abe1..be5d266cc2 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.15.2", + "version": "0.15.3", "description": "Paseo shared protocol schemas and wire types", "license": "Apache-2.0", "files": [ diff --git a/packages/relay/package.json b/packages/relay/package.json index 6f17d20847..9f951df9dc 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.15.2", + "version": "0.15.3", "description": "Paseo relay for bridging daemon and client connections", "license": "Apache-2.0", "files": [ diff --git a/packages/server/package.json b/packages/server/package.json index 471e558b77..0733102d65 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.15.2", + "version": "0.15.3", "description": "Paseo backend server", "license": "Apache-2.0", "files": [ @@ -108,11 +108,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.15.2", - "@getpaseo/highlight": "0.15.2", - "@getpaseo/plugin": "0.15.2", - "@getpaseo/protocol": "0.15.2", - "@getpaseo/relay": "0.15.2", + "@getpaseo/client": "0.15.3", + "@getpaseo/highlight": "0.15.3", + "@getpaseo/plugin": "0.15.3", + "@getpaseo/protocol": "0.15.3", + "@getpaseo/relay": "0.15.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 696ebb8a51..b13fa5254a 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.15.2", + "version": "0.15.3", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index 1e56036650..a47ddf956d 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.15.2", + "version": "0.15.3", "private": true, "type": "module", "scripts": { From ea30d2616a6ea1ad51447cde6e2fc6da4904fe21 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:58:28 +0000 Subject: [PATCH 21/32] Restore Mermaid source toggles and bound inline previews (#8932) * fix(app): keep Mermaid controls usable in inline previews * test(app): verify Mermaid controls through user journeys Keep source-toggle mechanics in diagram helpers and compare preview geometry with its screen and controls instead of requiring exact pixel heights. Co-authored-by: luke --------- Co-authored-by: luke Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../e2e/browser/mermaid-fullscreen.spec.ts | 36 ++++++++++++ packages/app/e2e/support/helpers/diagram.ts | 56 +++++++++++++++++++ .../markdown/fence/mermaid/host.web.tsx | 34 ++++++----- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/browser/mermaid-fullscreen.spec.ts b/packages/app/e2e/browser/mermaid-fullscreen.spec.ts index 72b05ed83e..4b458c4d03 100644 --- a/packages/app/e2e/browser/mermaid-fullscreen.spec.ts +++ b/packages/app/e2e/browser/mermaid-fullscreen.spec.ts @@ -1,6 +1,8 @@ import { test } from "../support/fixtures"; import { closeDiagramFullscreen, + expectInlineDiagramFitsViewport, + toggleDiagramSourceAndVerifyControls, closeDiagramFullscreenFromOutside, expectCompletedDiagram, expectFullscreenDiagram, @@ -21,6 +23,36 @@ const MERMAID_DIAGRAM = [ "```", ].join("\n"); +test("bounds a tall inline diagram", async ({ page }) => { + test.setTimeout(120_000); + const diagram = [ + "```mermaid", + "flowchart TD", + ...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`), + "```", + ].join("\n"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "mermaid-tall-", + title: "Tall Mermaid", + featureValues: { + mockStreamingAssistantResponse: diagram, + mockStreamingAssistantIntervalMs: 10, + }, + }); + try { + await openAgentRoute(page, agent); + await requestDiagram(agent); + await waitForDiagramTurnToComplete(agent); + await expectCompletedDiagram(page, ["N0", "N30"]); + await expectInlineDiagramFitsViewport(page); + await openDiagramFullscreen(page); + await expectFullscreenDiagram(page, ["N0", "N30"]); + await closeDiagramFullscreen(page); + } finally { + await agent.cleanup(); + } +}); + test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => { test.setTimeout(120_000); @@ -40,6 +72,10 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) await expectCompletedDiagram(page, ["Start", "Ship"]); }); + await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => { + await toggleDiagramSourceAndVerifyControls(page); + }); + await test.step("The toolbar opens the diagram fullscreen", async () => { await openDiagramFullscreen(page); await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]); diff --git a/packages/app/e2e/support/helpers/diagram.ts b/packages/app/e2e/support/helpers/diagram.ts index 82fd745de1..48ecc60086 100644 --- a/packages/app/e2e/support/helpers/diagram.ts +++ b/packages/app/e2e/support/helpers/diagram.ts @@ -148,3 +148,59 @@ export async function expectFullscreenDiagram( export async function expectFullscreenDiagramClosed(page: Page): Promise { await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0); } + +/** Tall diagrams stay within the screen so their controls remain reachable. */ +export async function expectInlineDiagramFitsViewport(page: Page): Promise { + const viewport = page.getByTestId("mermaid-viewport").last(); + const box = await viewport.boundingBox(); + expect(box, "the inline diagram has measurable layout").not.toBeNull(); + const screenHeight = page.viewportSize()!.height; + expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight); + const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate( + (svg) => (svg as SVGSVGElement).viewBox.baseVal.height, + ); + expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan( + contentHeight, + ); +} + +/** Exercise pointer toggles and the controls encountered along that same journey. */ +export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise { + const viewport = page.getByTestId("mermaid-viewport").last(); + const iframe = await viewport.locator("iframe").elementHandle(); + for (let iteration = 0; iteration < 3; iteration++) { + await viewport.hover(); + await page.getByRole("button", { name: "View source" }).last().click(); + const viewDiagram = page.getByRole("button", { name: "View diagram" }).last(); + await viewDiagram.click({ timeout: 10_000 }); + await expect(viewDiagram).toHaveCount(0); + await expectFullscreenDiagramClosed(page); + expect( + await viewport.locator("iframe").evaluate((node, original) => node === original, iframe), + "switching views preserves the rendered diagram", + ).toBe(true); + } + await viewport.hover(); + await page.getByRole("button", { name: "View source" }).last().click(); + const hiddenFullscreen = viewport.getByRole("button", { + name: "Fullscreen", + includeHidden: true, + }); + await hiddenFullscreen.focus(); + await expect(hiddenFullscreen).not.toBeFocused(); + await page.getByRole("button", { name: "View diagram" }).last().click(); + await viewport.hover(); + const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true }); + const box = await viewport.boundingBox(); + const control = await fullscreen.boundingBox(); + expect(box).not.toBeNull(); + expect(control).not.toBeNull(); + expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y); + expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual( + box!.y + box!.height, + ); + await viewport.getByRole("button", { name: "Zoom in" }).click(); + await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled(); + await viewport.getByRole("button", { name: "Reset view" }).click(); + await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled(); +} diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx index 9370bc90c0..94d6ccc666 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx @@ -72,7 +72,7 @@ function MermaidFenceHostImpl({ () => [ getDiagramBoxStyle(textStyle), containerStyle, - { height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT }, + { height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT }, ], [runtimeHeight, textStyle], ); @@ -111,20 +111,22 @@ function MermaidFenceHostImpl({ ) : null} ) : null} - - - +
+ + + +
{isFullscreen && visible ? ( Date: Fri, 6 Nov 2026 18:06:59 +0000 Subject: [PATCH 22/32] Give agents their own workspace identity through create and resume (#8939) * fix(server): give agents their workspace id as PASEO_WORKSPACE_ID Agents got PASEO_AGENT_ID and PASEO_AGENT_CWD but not their workspace id, so an agent couldn't rename or archive its own workspace, or name it to create_agent, when several workspaces share its cwd. The launch env now sets PASEO_WORKSPACE_ID whenever the agent has a workspace, including on resume after a daemon restart, where the id now comes from the stored record. OpenCode treats it as a per-session key, so it doesn't force a dedicated server. Refs #5098 Co-Authored-By: Claude Opus 5.5 * fix(server): keep the workspace when resuming an agent by handle resume_agent_request matched the stored agent but dropped its workspace id, so the resumed agent had no PASEO_WORKSPACE_ID and no workspace. Pass the matched record's workspace id into the resume. resumeAgentFromPersistence now resolves the workspace once and gives it to both the launch env and the registered agent, so a caller that omits it no longer erases the stored value on the next snapshot. Co-Authored-By: Claude Opus 5.5 * test(server): exercise current daemon creation and permission contracts The workspace resume regression shares this suite with creation checks that still waited for legacy status frames and permission listeners without an owned subscription. Verify the returned error snapshot and acquire event demand so the complete suite can validate workspace identity changes. Co-authored-by: Darrell --------- Co-authored-by: Darrell Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 73 ++++++++++++ .../server/src/server/agent/agent-manager.ts | 37 +++++- .../server/agent/providers/opencode-agent.ts | 6 +- .../opencode/v2/configuration.test.ts | 9 +- .../providers/opencode/v2/configuration.ts | 2 +- .../src/server/daemon-client.e2e.test.ts | 110 +++++++++--------- packages/server/src/server/session.ts | 7 +- public-docs/cli.md | 2 + public-docs/cli.zh-CN.md | 2 + public-docs/plugins/hooks.md | 2 +- public-docs/plugins/hooks.zh-CN.md | 2 +- 11 files changed, 188 insertions(+), 64 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5b77522473..02e1798789 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3286,6 +3286,78 @@ test.each([ }, ); +test("createAgent and resume give the agent its workspace id", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + createLaunchContext: AgentLaunchContext | undefined; + resumeLaunchContext: AgentLaunchContext | undefined; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.createLaunchContext = launchContext; + return new TestAgentSession(config); + } + + override async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.resumeLaunchContext = launchContext; + const metadata = (handle.metadata ?? {}) as Partial; + return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir }); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000140", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "ws-agent-home", + }); + expect(client.createLaunchContext?.env).toEqual({ + PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, + PASEO_WORKSPACE_ID: "ws-agent-home", + }); + + // The stored record supplies the workspace when the caller doesn't. + const handle: AgentPersistenceHandle = { + provider: "codex", + sessionId: "workspace-env-session", + metadata: { provider: "codex", cwd: workdir }, + }; + const expectedEnv = { + PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, + PASEO_WORKSPACE_ID: "ws-agent-home", + }; + + await manager.closeAgent(snapshot.id); + const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id); + expect(client.resumeLaunchContext?.env).toEqual(expectedEnv); + expect(resumed.workspaceId).toBe("ws-agent-home"); + expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home"); + + // Each restart reads what the previous resume stored. + client.resumeLaunchContext = undefined; + await manager.closeAgent(snapshot.id); + await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id); + expect(client.resumeLaunchContext?.env).toEqual(expectedEnv); +}); + test("createAgent passes persistSession to provider create options", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -4731,6 +4803,7 @@ test("importProviderSession imports the selected session without listing and pub env: { PASEO_AGENT_ID: imported.id, PASEO_AGENT_CWD: workdir, + PASEO_WORKSPACE_ID: "ws-imported", }, }); expect(imported.lifecycle).toBe("idle"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fdecc4ad1c..b47e12e558 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -664,6 +664,36 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri return Object.assign(new Error(message), { name: "AbortError" }); } +// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can +// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD. +function agentIdentityEnv({ + agentId, + cwd, + opening, +}: { + agentId: string; + cwd: string; + opening: { workspaceId?: string | null } | undefined; +}): Record { + const workspaceId = opening?.workspaceId; + return { + PASEO_AGENT_ID: agentId, + PASEO_AGENT_CWD: cwd, + ...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}), + }; +} + +// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace. +function resolveResumeWorkspaceId({ + options, + record, +}: { + options: { workspaceId?: string } | undefined; + record: Pick | null; +}): string | undefined { + return options?.workspaceId ?? record?.workspaceId; +} + function validateAgentId(agentId: string, source: string): string { const result = AgentIdSchema.safeParse(agentId); if (!result.success) { @@ -1571,6 +1601,7 @@ export class AgentManager { const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions; const launchEnv = record?.launchEnv; + const workspaceId = resolveResumeWorkspaceId({ options, record }); const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, @@ -1593,7 +1624,7 @@ export class AgentManager { { reason: "resume", purpose, - workspaceId: options?.workspaceId ?? null, + workspaceId, title: record?.title ?? mergedConfig.title ?? null, labels: record?.labels ?? options?.labels ?? {}, }, @@ -1609,6 +1640,7 @@ export class AgentManager { await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, + workspaceId, launchEnv, persistence: handle, restoring: true, @@ -5774,8 +5806,7 @@ export class AgentManager { mcpServerOwners, env: { ...env, - PASEO_AGENT_ID: agentId, - PASEO_AGENT_CWD: cwd, + ...agentIdentityEnv({ agentId, cwd, opening }), }, }; const useNativeTools = diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6fef290d91..4feef0a46b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -349,7 +349,11 @@ type OpenCodeAgentConfig = Omit & { providerOptions: OpenCodeProviderOptions; }; -const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); +const OPENCODE_SESSION_ENV_KEYS = new Set([ + "PASEO_AGENT_ID", + "PASEO_AGENT_CWD", + "PASEO_WORKSPACE_ID", +]); function requiresDedicatedOpenCodeServer( config: OpenCodeAgentConfig, diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts index 6479ca3851..27ddeef4f3 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts @@ -24,7 +24,14 @@ describe("OpenCode v2 helper isolation", () => { expect( requiresDedicatedV2Server( { provider: "opencode", cwd: "/tmp/project" }, - { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + { + agentId: "agent", + env: { + PASEO_AGENT_ID: "agent", + PASEO_AGENT_CWD: "/tmp/project", + PASEO_WORKSPACE_ID: "ws-1", + }, + }, ), ).toBe(false); }); diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts index 9a565e6fca..ab4ae1e2f6 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts @@ -72,7 +72,7 @@ export async function applyResumeOverrides( // it must not force a dedicated server. Custom MCP is location-scoped, and any // other env key belongs to the process, so both need isolation. Mirrors the v1 // adapter's `requiresDedicatedOpenCodeServer`. -const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); +const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD", "PASEO_WORKSPACE_ID"]); export function requiresDedicatedV2Server( config: AgentSessionConfig, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a442dfe6a9..55aa4537c1 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -18,6 +18,7 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js"; import type { AgentClient, + AgentLaunchContext, AgentPersistenceHandle, AgentRunResult, AgentSession, @@ -324,6 +325,7 @@ class RecordingMcpResumeClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities; readonly resumeOverrides: Array | undefined> = []; + readonly resumeLaunchContexts: Array = []; private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex; constructor() { @@ -341,8 +343,10 @@ class RecordingMcpResumeClient implements AgentClient { resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise { this.resumeOverrides.push(overrides); + this.resumeLaunchContexts.push(launchContext); return this.inner.resumeSession(handle, overrides); } @@ -402,7 +406,7 @@ class RejectingMcpResumeClient implements AgentClient { } } -test("createAgent fails when the initial turn cannot start", async () => { +test("createAgent preserves the error snapshot when the initial turn cannot start", async () => { const testAgent = new StubAgentClient({ sessionId: "start-turn-failure-session", supportsStreaming: false, @@ -421,16 +425,18 @@ test("createAgent fails when the initial turn cannot start", async () => { await client.connect(); await client.fetchAgents({ subscribe: {} }); - await expect( - client.createAgent({ - provider: "codex", - cwd: tmpCwd(), - title: "Start failure agent", - modeId: "full-access", - model: "gpt-5.4-mini", - initialPrompt: "Run exactly: sleep 30", - }), - ).rejects.toThrow("Initial turn failed to start"); + const created = await client.createAgent({ + provider: "codex", + cwd: tmpCwd(), + title: "Start failure agent", + modeId: "full-access", + model: "gpt-5.4-mini", + initialPrompt: "Run exactly: sleep 30", + }); + expect(created.status).toBe("error"); + expect(created.lastError).toBe("Initial turn failed to start"); + const fetched = await client.fetchAgent({ agentId: created.id }); + expect(fetched?.agent.lastError).toBe("Initial turn failed to start"); } finally { await client.close(); await daemon.close(); @@ -1034,6 +1040,38 @@ test("resume_agent rehydrates the newest private MCP config from a redacted pers } }); +test("resume_agent keeps the workspace of the agent it matches", async () => { + const cwd = tmpCwd(); + const provider = new RecordingMcpResumeClient(); + const localCtx = await createDaemonTestContext({ + agentClients: { codex: provider }, + }); + + try { + const opened = await localCtx.client.openProject(cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? "Workspace did not open"); + } + const workspaceId = opened.workspace.id; + const created = await localCtx.client.createAgent({ + config: { provider: "codex", cwd }, + workspaceId, + }); + if (!created.persistence) { + throw new Error("Expected a persistence handle"); + } + + await localCtx.client.archiveAgent(created.id); + const resumed = await localCtx.client.resumeAgent(created.persistence); + + expect(provider.resumeLaunchContexts[0]?.env?.PASEO_WORKSPACE_ID).toBe(workspaceId); + expect(resumed.workspaceId).toBe(workspaceId); + } finally { + await localCtx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("resume_agent restores archive state when an MCP-capable provider returns an unsupported session", async () => { const cwd = tmpCwd(); const provider = new RejectingMcpResumeClient(); @@ -1369,27 +1407,6 @@ test("creates agent and exercises lifecycle", async () => { }); const createRequestId = `create-${Date.now()}`; - const createdStatusPromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status?: string; - agentId?: string; - requestId?: string; - }; - if (payload.status !== "agent_created") { - return; - } - if (payload.requestId !== createRequestId) { - return; - } - resolve(message); - }); - return unsubscribe; - }); - const agent = await ctx.client.createAgent({ ...getFullAccessConfig("codex"), cwd, @@ -1404,30 +1421,8 @@ test("creates agent and exercises lifecycle", async () => { const agentUpdate = await agentUpdatePromise; expect(agentUpdate.payload.agent.id).toBe(agent.id); - const createdStatus = await createdStatusPromise; - expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id); const failRequestId = `fail-${Date.now()}`; - const failedStatusPromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status?: string; - requestId?: string; - }; - if (payload.status !== "agent_create_failed") { - return; - } - if (payload.requestId !== failRequestId) { - return; - } - resolve(message); - }); - return unsubscribe; - }); - await expect( ctx.client.createAgent({ ...getFullAccessConfig("codex"), @@ -1436,7 +1431,6 @@ test("creates agent and exercises lifecycle", async () => { requestId: failRequestId, }), ).rejects.toThrow("Working directory does not exist"); - await failedStatusPromise; let sawRefresh = false; const unsubscribe = ctx.client.subscribe((event) => { @@ -1615,6 +1609,11 @@ test("creates agent and exercises lifecycle", async () => { }, 300000); test("handles permission flow", async () => { + const permissionEvents = ctx.client.observeEvents([ + "agent_permission_request", + "agent_permission_resolved", + ]); + await permissionEvents.ready; const cwd = tmpCwd(); const filePath = path.join(cwd, "permission.txt"); @@ -1683,6 +1682,7 @@ test("handles permission flow", async () => { // Prevent unhandled rejections if the test fails before promises resolve. await permissionRequestPromise.catch(() => {}); await permissionResolvedPromise.catch(() => {}); + await permissionEvents.release(); await ctx.client.deleteAgent(agent.id); rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 83f030d971..55d6c58401 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4856,7 +4856,12 @@ export class Session { : overrides; let snapshot: ManagedAgent; try { - snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides); + snapshot = await this.agentManager.resumeAgentFromPersistence( + handle, + effectiveOverrides, + undefined, + { workspaceId: matched?.record.workspaceId }, + ); } catch (error) { if (matched?.didUnarchive && matched.originalArchivedAt) { await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt); diff --git a/public-docs/cli.md b/public-docs/cli.md index f28911ac5a..700206e5a5 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -211,6 +211,8 @@ paseo workspace rename --reset # back to the branch or director paseo workspace archive ``` +Inside an agent session, `PASEO_WORKSPACE_ID` holds the agent's own workspace ID, so an agent can run `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` or `paseo workspace archive "$PASEO_WORKSPACE_ID"` even when other workspaces share its directory. + Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. ## Terminals diff --git a/public-docs/cli.zh-CN.md b/public-docs/cli.zh-CN.md index 6e47391771..29b8aec65f 100644 --- a/public-docs/cli.zh-CN.md +++ b/public-docs/cli.zh-CN.md @@ -202,6 +202,8 @@ paseo workspace rename --reset # back to the branch or director paseo workspace archive ``` +在 Agent 会话中,`PASEO_WORKSPACE_ID` 保存该 Agent 自己的工作区 ID。即使其他工作区共享同一个目录,Agent 也可以运行 `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` 或 `paseo workspace archive "$PASEO_WORKSPACE_ID"`。 + 当 Paseo 无法从源检出推断代码托管平台时,为 PR 检出添加 `--forge `。setup 钩子和服务见 [Git worktree](/docs/worktrees)。 ## 终端 {#terminals} diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index 25c8cf0527..17340a00a9 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -598,7 +598,7 @@ Creation request → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace → open provider session and save agent configuration ``` diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index 5469582b3f..0c35f814b3 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -581,7 +581,7 @@ Creation request → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace → open provider session and save agent configuration ``` From 79f2db8d56864245fb4361bacd939377af88f78b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:30:36 +0000 Subject: [PATCH 23/32] test(server): retry locked provider fixture cleanup on Windows (#8942) Availability probes already await process completion. Allow bounded filesystem cleanup retries for transient executable locks, matching the neighboring Windows launch tests. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/providers/provider-availability.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index baf0cf7f9b..419b5f1cf6 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -48,7 +48,8 @@ afterEach(() => { process.env.PATH = originalEnv.PATH; process.env.PATHEXT = originalEnv.PATHEXT; for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); + // Windows can briefly retain a lock on the copied executable after its probe exits. + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); From 9f68af0274ca06c5decd6269d870d442b8e04500 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:57:29 +0000 Subject: [PATCH 24/32] Keep uploaded terminal file paths separate (#8946) * M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths * M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n * M2: review fixes: retry attempt guard, cancellable native pending start, measured terminal slot width, compact-web Cmd+D for terminal dictation * fix(terminal): separate uploaded file paths when pasting Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word. Co-authored-by: Sebastian Arena --------- Co-authored-by: Sebastian Arena Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/terminal-protocol-query.spec.ts | 37 +++++++++++++++++++ .../app/src/terminal/file-paste/index.test.ts | 9 +++-- packages/app/src/terminal/file-paste/index.ts | 2 +- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index d5a0071189..4b2fc06d41 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => { for (;;) { const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer); if (!paste) break; + const pasted = JSON.stringify(paste[1]).replace( + /\s/g, + (space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"), + ); + process.stdout.write("PASTED:" + pasted + "\r\n"); for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) { const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local"; @@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => { await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text); } + // Every bracketed paste body the receiver consumed, in order and joined: what a + // shell would read as one command line. + async function pastedText(terminalId: string): Promise { + const output = await terminalOutput(terminalId); + return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)] + .map(([, json]) => JSON.parse(json) as string) + .join(""); + } + + // Exactly one ASCII space after each quoted path and nothing else between them, + // so the paths stay separate shell words. + async function expectSeparatePastedPaths(terminalId: string): Promise { + await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/); + } + test("pastes images from two panes into their own terminals as uploaded host files", async ({ page, }) => { @@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => { await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload")); await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload")); + await expectSeparatePastedPaths(terminal.id); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => { + const terminal = await openFileReceiver(page, "file-drop-remote-twice"); + const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) }; + const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") }; + try { + await dropFiles(page, [first]); + await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload")); + await dropFiles(page, [second]); + await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload")); + + await expectSeparatePastedPaths(terminal.id); } finally { await harness.killTerminal(terminal.id); } diff --git a/packages/app/src/terminal/file-paste/index.test.ts b/packages/app/src/terminal/file-paste/index.test.ts index 63a1e5e73d..1934b311d2 100644 --- a/packages/app/src/terminal/file-paste/index.test.ts +++ b/packages/app/src/terminal/file-paste/index.test.ts @@ -74,7 +74,8 @@ describe("terminal file paste queue", () => { firstUpload.resolve("/host/uploads/a.png"); await settle(); - expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]); + expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]); + expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' "); expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] }); }); @@ -85,7 +86,7 @@ describe("terminal file paste queue", () => { queue.add(Promise.resolve(file("shot.png")), host); await settle(); - expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']); + expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']); }); it("uploads under a file name that needs no shell quoting", async () => { @@ -120,7 +121,7 @@ describe("terminal file paste queue", () => { expect(events.failures).toEqual(["disk full"]); expect(events.uploaded).toBe(1); - expect(host.pasted).toEqual(["'/host/uploads/b.png'"]); + expect(host.pasted).toEqual(["'/host/uploads/b.png' "]); }); it("reports unreadable and oversized files without uploading them", async () => { @@ -166,7 +167,7 @@ describe("terminal file paste queue", () => { queue.add(Promise.resolve(file("next.png")), host); await settle(); - expect(host.pasted).toEqual(["'/host/uploads/next.png'"]); + expect(host.pasted).toEqual(["'/host/uploads/next.png' "]); expect(events.uploading).toEqual([true, false, true, false]); expect(queue.cancel()).toBe(false); }); diff --git a/packages/app/src/terminal/file-paste/index.ts b/packages/app/src/terminal/file-paste/index.ts index bc96b17c5d..f793edb643 100644 --- a/packages/app/src/terminal/file-paste/index.ts +++ b/packages/app/src/terminal/file-paste/index.ts @@ -75,7 +75,7 @@ export class TerminalFilePasteQueue { this.setUploading(true); const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) }); if (isCurrent()) { - target.paste(prepareTerminalPath(path)); + target.paste(`${prepareTerminalPath(path)} `); this.events.onUploaded(); } } catch (error) { From ec3ae09926b94273b96b7590c6fa7f098b30ed8c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:08:25 +0000 Subject: [PATCH 25/32] Keep the native model when importing sessions (#8948) * fix(server): preserve the selected model when importing sessions Import preparation resolved a new-session catalog default and sent it as an explicit resume override. Keep import selections with the resumed provider and persist its reported model for subsequent reloads. * fix(codex): allow the first reply after importing a session Resolve collaboration settings again when default model and effort are applied so imported sessions send a complete first-turn request. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 45 +++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 11 ++++- .../providers/codex-app-server-agent.test.ts | 36 +++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 1 + 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 02e1798789..374f87e09b 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -4699,6 +4699,51 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p }); }); +test("importProviderSession preserves the native model instead of applying the new-session default", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-model-")); + onTestFinished(() => rmSync(workdir, { recursive: true, force: true })); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const nativeModel = "native-selected-model"; + + class ImportClient extends TestAgentClient { + importConfig: AgentSessionConfig | undefined; + + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + this.importConfig = context.config; + // A resumable provider applies explicit selections but otherwise retains its model. + const session = new TestAgentSession({ + ...context.config, + model: context.config.model ?? nativeModel, + }); + return { + session, + config: context.storedConfig, + persistence: { provider: this.provider, sessionId: input.providerHandleId }, + timeline: [], + }; + } + } + + const client = new ImportClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const imported = await manager.importProviderSession({ + provider: "codex", + providerHandleId: "native-session", + cwd: workdir, + workspaceId: "import-model", + }); + await manager.flush(); + + expect(imported.runtimeInfo?.model).toBe(nativeModel); + expect(imported.config.model).toBe(nativeModel); + expect(client.importConfig?.model).toBeUndefined(); + expect((await storage.get(imported.id))?.config.model).toBe(nativeModel); + + await manager.reloadAgentSession(imported.id); + expect(client.resumeOverrides.at(-1)?.model).toBe(nativeModel); + await manager.getAgent(imported.id)?.session?.close(); +}); + test("importProviderSession imports the selected session without listing and publishes ready state", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b47e12e558..e976f40853 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1679,6 +1679,7 @@ export class AgentManager { cwd: input.cwd, }, resolvedAgentId, + { resolveDefaultModel: false }, ); this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); const launchContext = await this.buildLaunchContext( @@ -1705,8 +1706,13 @@ export class AgentManager { await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext); let handedToRegistration = false; try { + const runtimeInfo = await imported.session.getRuntimeInfo(); const importedConfig = await this.normalizeConfig( - stripInternalPaseoMcpServer(imported.config), + stripInternalPaseoMcpServer({ + ...imported.config, + model: runtimeInfo.model ?? imported.config.model, + }), + { resolveDefaultModel: false }, ); const timelineRows = buildImportedTimelineRows(imported.timeline); const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); @@ -5723,11 +5729,12 @@ export class AgentManager { private async prepareSessionConfig( config: AgentSessionConfig, agentId: string, - options: { env?: Record; purpose?: AgentResumePurpose } = {}, + options: NormalizeConfigOptions = {}, ): Promise { const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env: options.env, purpose: options.purpose, + resolveDefaultModel: options.resolveDefaultModel, }); const paseoToolPolicy = this.paseoToolsEnabled ? this.resolvePaseoToolPolicy(storedConfig.provider) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 78df159fc0..40935c2668 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1379,6 +1379,42 @@ describe("Codex app-server provider", () => { } }); + test.each([undefined, "configured-model"])( + "import resolves model %s before the first collaboration turn", + async (configuredModel) => { + const appServer = createFakeCodexAppServer({ + "config/read": () => ({ + config: { model: configuredModel, model_reasoning_effort: "low" }, + }), + "collaborationMode/list": () => ({ + data: [{ name: "Code", mode: "default", model: null, reasoning_effort: null }], + }), + }); + const provider = createProviderWithFakeAppServer(appServer); + const config = createConfig({ model: undefined, thinkingOptionId: undefined }); + const imported = await provider.importSession( + { providerHandleId: "native-thread", cwd: config.cwd! }, + { config, storedConfig: config }, + ); + + try { + await imported.session.startTurn("first follow-up after import"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + threadId: "native-thread", + model: configuredModel ?? "gpt-5.4", + effort: "low", + collaborationMode: { + mode: "default", + settings: { model: configuredModel ?? "gpt-5.4", reasoning_effort: "low" }, + }, + }); + appServer.assertNoErrors(); + } finally { + await imported.session.close(); + } + }, + ); + test("provider persistence leaves options owned by the stored agent config", async () => { const appServer = createFakeCodexAppServer(); const provider = createProviderWithFakeAppServer(appServer); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index b031ae410e..2f4ba468ba 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -5554,6 +5554,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.config.model = model; this.reconcileServiceTier(); this.config.thinkingOptionId = thinkingOptionId; + this.refreshResolvedCollaborationMode(); return model; } From 78a089a14e23852f2c40d1069e3054c4c7c63fda Mon Sep 17 00:00:00 2001 From: Abdulhakeem Adetunji Mustapha Date: Sat, 7 Nov 2026 02:00:56 +0100 Subject: [PATCH 26/32] fix(desktop): browser_logs reports the HTTP status of each request (#8956) * fix(desktop): browser_logs reports the HTTP status of each request * test(desktop): browser_logs reports the status the browser recorded * test(desktop): e2e checks browser_logs reports a 404 and a 200 * docs(browser): browser_logs also reports the HTTP status --- packages/desktop/e2e/browser-tabs.e2e.mjs | 62 +++++++++++++ .../browser-automation/service.test.ts | 87 +++++++++++++++++++ .../features/browser-automation/service.ts | 2 + public-docs/browser-tools.md | 2 +- public-docs/browser-tools.zh-CN.md | 2 +- 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index f06860d862..4c8fcbaaa0 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -246,6 +246,67 @@ async function closeServer(server) { await new Promise((resolve) => server.close(resolve)); } +// browser_logs must report what the server answered: a 404 may not look like a 200. +async function verifyNetworkLogsReportStatus(client) { + const server = createServer((request, response) => { + response.writeHead(request.url === "/missing" ? 404 : 200, { + "content-type": "text/html; charset=utf-8", + }); + response.end("Status page

Status page heading

"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string", "Status page did not bind a TCP port"); + let browserId = null; + try { + const created = await callBrowserTool(client, "browser_new_tab", { + url: `http://127.0.0.1:${address.port}/`, + }); + browserId = created.browserId; + assert( + typeof browserId === "string", + "browser_new_tab returned no browserId for the status page", + ); + await callBrowserTool(client, "browser_wait", { + browserId, + text: "Status page heading", + timeoutMs: 15_000, + }); + const fetched = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: + "async () => { const response = await fetch('/missing'); await response.text(); return response.status; }", + }); + assert( + JSON.parse(fetched.resultJson) === 404, + `The status page did not answer 404: ${fetched.resultJson}`, + ); + // The browser records a request's timing entry a moment after the response completes. + const deadline = Date.now() + 5_000; + let seen = []; + while (Date.now() < deadline) { + const logs = await callBrowserTool(client, "browser_logs", { browserId }); + seen = logs.network.map((entry) => ({ + pathname: new URL(entry.url).pathname, + status: entry.status ?? null, + })); + const reported = (pathname, status) => + seen.some((entry) => entry.pathname === pathname && entry.status === status); + if (reported("/missing", 404) && reported("/", 200)) return; + await delay(100); + } + throw new Error( + `browser_logs did not report 404 for /missing and 200 for the page: ${JSON.stringify(seen)}`, + ); + } finally { + if (browserId) await callBrowserTool(client, "browser_close_tab", { browserId }); + await closeServer(server); + } +} + function mcpPayload(result, command) { const payload = result.structuredContent; assert( @@ -1020,6 +1081,7 @@ async function runRegression({ JSON.parse(readyStateResult.resultJson) === "complete", "Local browser document did not finish loading", ); + await verifyNetworkLogsReportStatus(client); // Reproduce the report's mismatch: the guest is complete, but the pane's // last loading signal says it is not ready. await page.evaluate((id) => { diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index 6bc054c6a5..dfaa5331d7 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -1,4 +1,5 @@ import { resolve as resolvePath } from "node:path"; +import { createContext, runInContext } from "node:vm"; import { describe, expect, test, vi } from "vitest"; import type { @@ -434,6 +435,26 @@ function containsScript(tab: FakeTab, ...parts: string[]): boolean { return tab.scripts.some((script) => parts.every((part) => script.includes(part))); } +/** + * A browser whose tab runs the network script the host injects against `performance`, the way a + * page runs it, so the test sees what the script reads from Chromium's timing entries. + */ +function browserWithTimingEntries(entries: { resource: unknown[]; navigation: unknown[] }) { + const browser = new BrowserAutomationHarness(); + const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab); + const page = createContext({ + performance: { + getEntriesByType: (type: string) => + type === "navigation" ? entries.navigation : entries.resource, + }, + }); + browser.tab.executeJavaScript = async (script: string) => + script.includes("performance.getEntriesByType") + ? runInContext(script, page) + : fakeAnswer(script); + return browser; +} + function requireSnapshotRefs(result: Awaited>) { expect(result).toEqual({ requestId: "req-snapshot", @@ -1406,6 +1427,72 @@ describe("executeAutomationCommand", () => { }); }); + test("logs reports the HTTP status the browser recorded, and none it did not", async () => { + const timing = (name: string, initiatorType: string, responseStatus?: number) => ({ + name, + initiatorType, + startTime: 1, + duration: 2, + transferSize: 3, + ...(responseStatus === undefined ? {} : { responseStatus }), + }); + const browser = browserWithTimingEntries({ + resource: [ + timing("https://a.test/api/orders", "fetch", 404), + // Chromium reports 0 for a cross-origin response that sends no Timing-Allow-Origin. + timing("https://cdn.test/lib.js", "script", 0), + // A browser without the field. + timing("https://a.test/old.css", "css"), + ], + navigation: [timing("https://a.test/form", "navigation", 200)], + }); + + const result = await browser.execute({ command: "logs", args: { browserId: BROWSER_A } }); + + expect(result).toEqual({ + requestId: "req-logs", + ok: true, + result: { + command: "logs", + browserId: BROWSER_A, + console: [], + network: [ + { + url: "https://a.test/api/orders", + type: "fetch", + status: 404, + startTime: 1, + duration: 2, + transferSize: 3, + }, + { + url: "https://cdn.test/lib.js", + type: "script", + startTime: 1, + duration: 2, + transferSize: 3, + }, + { + url: "https://a.test/old.css", + type: "css", + startTime: 1, + duration: 2, + transferSize: 3, + }, + { + url: "https://a.test/form", + method: "GET", + type: "navigation", + status: 200, + startTime: 1, + duration: 2, + transferSize: 3, + }, + ], + }, + }); + }); + test("evaluate returns primitive JSON from the page context", async () => { const browser = new BrowserAutomationHarness(); browser.tab.evaluateScriptResult = { ok: true, resultJson: "42" }; diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index fe07adecd3..fd5b12f83b 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -1628,6 +1628,8 @@ const NETWORK_PERFORMANCE_SCRIPT = String.raw`(() => { url: entry.name, method: entry.initiatorType === 'navigation' ? 'GET' : undefined, type: entry.initiatorType, + // 0 when the browser does not say (a cross-origin response without Timing-Allow-Origin). + status: typeof entry.responseStatus === 'number' && entry.responseStatus > 0 ? entry.responseStatus : undefined, startTime: entry.startTime, duration: entry.duration, transferSize: typeof entry.transferSize === 'number' ? entry.transferSize : undefined, diff --git a/public-docs/browser-tools.md b/public-docs/browser-tools.md index b97669e372..f1d586d3c0 100644 --- a/public-docs/browser-tools.md +++ b/public-docs/browser-tools.md @@ -33,7 +33,7 @@ Arguments marked `?` are optional. | -------------------- | -------------------------------------- | ------------------------------------------------------------------------------------- | | `browser_snapshot` | `browserId` | Return the page as an accessibility tree with element refs. | | `browser_screenshot` | `browserId, fullPage?` | Capture a PNG of the viewport, or the full page with `fullPage`. | -| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network timing entries. | +| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network entries: URL, timing, size and HTTP status. | | `browser_wait` | `browserId, text? \| url?, timeoutMs?` | Wait until the page contains text or reaches a URL fragment (exactly one of the two). | ## Interacting diff --git a/public-docs/browser-tools.zh-CN.md b/public-docs/browser-tools.zh-CN.md index b302a3267f..9b72216445 100644 --- a/public-docs/browser-tools.zh-CN.md +++ b/public-docs/browser-tools.zh-CN.md @@ -31,7 +31,7 @@ nav: 工具参考 | -------------------- | -------------------------------------- | ------------------------------------------------------------------- | | `browser_snapshot` | `browserId` | 以带元素引用的无障碍树形式返回页面。 | | `browser_screenshot` | `browserId, fullPage?` | 截取视口的 PNG;传入 `fullPage` 时截取整个页面。 | -| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络耗时条目。 | +| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络条目:URL、耗时、大小和 HTTP 状态码。 | | `browser_wait` | `browserId, text? \| url?, timeoutMs?` | 等待页面包含某段文本或到达某个 URL 片段(两者必须且只能提供一个)。 | ## 交互 {#interacting} From c5cf6cbb4ccc5eaba2134424a19bde5d05b39b60 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:01:45 +0000 Subject: [PATCH 27/32] test(server): await shared fetch observation readiness (#8975) Snapshot refresh completion does not mean both workspaces have joined the repository fetch target. Wait for observation setup before releasing the first fetch, and retain delayed checkout resolution to exercise that ordering. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/workspace-git-service.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index d87e896ead..96ca7c333d 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1134,6 +1134,18 @@ describe("WorkspaceGitServiceImpl", () => { }); test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { + // Keep the second workspace's observation setup behind its initial snapshot. + const secondCwd = join(REPO_CWD, "packages", "server"); + const runGitCommand = vi.fn(async (_args, options) => { + if (options.cwd === secondCwd) await delay(250); + return { + stdout: `${REPO_CWD}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + }); const firstFetch = createDeferred(); const runGitFetch = vi.fn(async () => { await firstFetch.promise; @@ -1155,6 +1167,7 @@ describe("WorkspaceGitServiceImpl", () => { const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); const service = createService({ + runGitCommand, getCheckoutSnapshotFacts, getCheckoutRefDerivedState, resolveAbsoluteGitDir, @@ -1163,10 +1176,7 @@ describe("WorkspaceGitServiceImpl", () => { }); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - const second = service.registerWorkspace( - { cwd: join(REPO_CWD, "packages", "server") }, - vi.fn(), - ); + const second = service.registerWorkspace({ cwd: secondCwd }, vi.fn()); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); expect(runGitFetch).toHaveBeenCalledTimes(1); @@ -1177,6 +1187,8 @@ describe("WorkspaceGitServiceImpl", () => { expect(hasOriginRemote).toHaveBeenCalledTimes(0); expect(runGitFetch).toHaveBeenCalledTimes(1); + await waitForObservationToSettle(service, 2); + firstFetch.resolve(); await flushPromises(); await vi.advanceTimersByTimeAsync(1_000); From daf26892d38de5e029326e4e936a55866a5610cf Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:18:27 +0000 Subject: [PATCH 28/32] Keep the provider's actual model after unavailable selections (#8957) * fix(server): keep the model selected by the provider A live model setter can retain or resolve a different model than the requested ID. Read its effective selection before persisting manager state so an ignored selection cannot overwrite the actual model. Preserve explicit preference resets. * fix(server): preserve completed model selections Return the effective selection from the provider mutation so a separate status request cannot reject an already completed switch or leave configured and runtime models inconsistent. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 125 ++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 12 +- .../src/server/agent/agent-sdk-types.ts | 4 +- .../src/server/agent/plugin-provider.ts | 3 +- .../server/agent/providers/acp-agent.test.ts | 2 +- .../src/server/agent/providers/acp-agent.ts | 5 +- .../server/agent/providers/claude/agent.ts | 3 +- .../src/server/agent/providers/omp/agent.ts | 5 +- .../agent/providers/opencode/v2/session.ts | 1 + .../src/server/agent/providers/pi/agent.ts | 5 +- 10 files changed, 150 insertions(+), 15 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 374f87e09b..85a303baf1 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -700,6 +700,71 @@ test("stops an OpenCode execution already active when its conversation is reopen } }); +test("a completed OpenCode model switch does not depend on another session read", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-model-")); + const harness = new V2Harness(); + harness.info.location.directory = workdir; + harness.info.model = { providerID: "provider", id: "source" }; + let observationUnavailable = false; + harness.api.model.list = async () => ({ + location: { directory: workdir }, + data: [ + { + providerID: "provider", + id: "target", + modelID: "target", + name: "Target", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + enabled: true, + variants: [], + time: { released: 1 }, + cost: [], + status: "active", + limit: { context: 200000, output: 10000 }, + }, + ], + }); + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + observationUnavailable = true; + }; + harness.api.session.get = async () => { + if (observationUnavailable) throw new Error("Transport"); + return harness.info; + }; + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime }); + const manager = new AgentManager({ clients: { opencode: client }, registry: storage, logger }); + const agent = await manager.resumeAgentFromPersistence({ + provider: "opencode", + sessionId: "session", + nativeHandle: "session", + metadata: { cwd: workdir }, + }); + try { + await manager.setAgentModel(agent.id, "provider/target"); + await manager.flush(); + expect(harness.info.model?.id).toBe("target"); + expect(manager.getAgent(agent.id)?.config.model).toBe("provider/target"); + expect(manager.getAgent(agent.id)?.runtimeInfo?.model).toBe("provider/target"); + const stored = await storage.get(agent.id); + expect(stored?.config?.model).toBe("provider/target"); + expect(stored?.runtimeInfo?.model).toBe("provider/target"); + expect(stored?.persistence?.metadata?.model).toBe("provider/target"); + observationUnavailable = false; + for await (const _event of manager.streamAgent(agent.id, "continue after switching")) { + // A successful mutation leaves a usable session. + } + expect(harness.prompts).toEqual(["continue after switching"]); + } finally { + observationUnavailable = false; + await manager.closeAgent(agent.id); + await manager.flush(); + await client.shutdown(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("uses an injected timeline store without making it a production requirement", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-")); const store = new RecordingTimelineStore(); @@ -5415,6 +5480,9 @@ test("model changes persist the resolved thinking and fresh provider handle befo thinkingOptionId: null, }); } + override async getRuntimeInfo() { + return { ...(await super.getRuntimeInfo()), ...this.selection }; + } describePersistence() { return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } }; } @@ -5497,6 +5565,63 @@ test("later explicit config mutations win over events emitted by earlier mutatio expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high"); }); +test.each([ + { requested: "unavailable", effective: "source" }, + { requested: "alias", effective: "canonical" }, + { requested: null, effective: null }, +])( + "setAgentModel stores effective selections and preserves resets for $requested", + async ({ requested, effective }) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-effective-model-")); + class SelectingSession extends TestAgentSession { + private model: string | null = "source"; + + async setModel(model: string | null) { + if (model && model !== "unavailable") this.model = model === "alias" ? "canonical" : model; + return { model: this.model }; + } + + override async getRuntimeInfo() { + return { ...(await super.getRuntimeInfo()), model: this.model }; + } + } + class SelectingClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new SelectingSession(config); + } + } + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new SelectingClient() }, + registry: storage, + logger, + }); + try { + const snapshot = await manager.createAgent( + { provider: "codex", cwd: workdir, model: "source" }, + undefined, + { workspaceId: undefined }, + ); + await manager.setAgentModel(snapshot.id, requested); + await manager.flush(); + + expect(manager.getAgent(snapshot.id)?.config.model).toBe(effective ?? undefined); + expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe(effective); + const persisted = await storage.get(snapshot.id); + expect(persisted?.config?.model).toBe(effective ?? undefined); + expect(persisted?.runtimeInfo?.model).toBe(effective); + + await manager.setAgentModel(snapshot.id, "target"); + expect(manager.getAgent(snapshot.id)?.config.model).toBe("target"); + expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe("target"); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + test("setAgentThinkingOption adopts the session's effective clamped level", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-")); class ClampingSession extends TestAgentSession { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index e976f40853..b13de1930a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2334,14 +2334,16 @@ export class AgentManager { const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; - if (agent.session.setModel) { - await agent.session.setModel(normalizedModelId); - } + const selection = await agent.session.setModel?.(normalizedModelId); await this.drainSessionEvents(agentId); - agent.config.model = normalizedModelId ?? undefined; + // A reset clears the preference even when the provider retains its active model. + let effectiveModelId = normalizedModelId; + if (normalizedModelId !== null && selection) effectiveModelId = selection.model; + + agent.config.model = effectiveModelId ?? undefined; if (agent.runtimeInfo) { - agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId }; + agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId }; } this.refreshSessionPersistence(agent); this.touchUpdatedAt(agent); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7604f1db76..c67d2afe20 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -720,7 +720,9 @@ export interface AgentSession { /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; listCommands?(): Promise; - setModel?(modelId: string | null): Promise; + /** A returned model is the effective selection committed by this mutation. + * Returning void means the requested selection was accepted. No status read is required. */ + setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; setFeature?(featureId: string, value: unknown): Promise; revertConversation?(input: { messageId: string }): Promise; diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 2d20056930..3170211757 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1400,9 +1400,10 @@ class PluginAgentSession implements AgentSession { })); } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { this.assertReplayComplete(); await this.bridge.configure({ model: modelId }); + return { model: this.bridge.config.model ?? null }; } async setThinkingOption(thinkingOptionId: string | null): Promise { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index ae80dc74f7..00794a136d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -520,7 +520,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho setSessionConfigOption.mockClear(); - await expect(session.setModel("new-provider-model")).resolves.toBeUndefined(); + await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" }); expect(childLogger.warn).toHaveBeenCalledWith( { value: "new-provider-model" }, expect.stringContaining("is not a valid claude-acp model config option"), diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 4b67d827dd..d6eb391b9a 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -2281,16 +2281,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { }; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { if (!this.connection || !this.sessionId) { throw new Error("ACP session not initialized"); } if (!modelId) { this.currentModel = null; - return; + return { model: this.currentModel }; } await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + return { model: this.currentModel }; } private resolveModelSelection(modelId: string): ACPModelSelection { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 06b0c36e1f..b4bd7e27db 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2782,7 +2782,7 @@ class ClaudeAgentSession implements AgentSession { this.currentMode = normalized; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null; const activeQuery = await this.ensureQuery(); @@ -2800,6 +2800,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; // Model change affects persistence metadata, so invalidate cached handle. this.persistence = null; + return { model: this.lastOptionsModel ?? this.config.model ?? null }; } private reconcileThinkingOptionForModel(modelId: string | null): void { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c8b44c0bd8..97ded8292f 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1302,10 +1302,10 @@ export class OmpAgentSession implements AgentSession { return null; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { const parsedReference = parseModelReference(modelId); if (!parsedReference) { - return; + return { model: modelToId(this.state.model) }; } if (!parsedReference.provider) { throw new Error(`OMP model id must include a provider: ${modelId}`); @@ -1314,6 +1314,7 @@ export class OmpAgentSession implements AgentSession { const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); this.config.model = `${model.provider}/${model.id}`; await this.refreshState(); + return { model: modelToId(this.state.model) }; } async setThinkingOption(thinkingOptionId: string | null): Promise { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 58981c7086..5d032d1476 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -367,6 +367,7 @@ export class OpenCodeV2Session implements AgentSession { thinkingOptionId: variant ?? null, }, }); + return { model: `${selected.providerID}/${selected.id}` }; } async setThinkingOption(variant: string | null) { diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 513f9b62e9..5458495821 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1704,10 +1704,10 @@ export class PiRpcAgentSession implements AgentSession { return null; } - async setModel(modelId: string | null): Promise { + async setModel(modelId: string | null): Promise<{ model: string | null }> { const parsedReference = parseModelReference(modelId); if (!parsedReference) { - return; + return { model: modelToId(this.state.model) }; } if (!parsedReference.provider) { throw new Error(`Pi model id must include a provider: ${modelId}`); @@ -1716,6 +1716,7 @@ export class PiRpcAgentSession implements AgentSession { const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); await this.refreshState(); this.config.model = `${model.provider}/${model.id}`; + return { model: modelToId(this.state.model) }; } async setThinkingOption(thinkingOptionId: string | null): Promise { From 751dc82e6c4d113b357a80593089b935113aa65c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:03:12 +0000 Subject: [PATCH 29/32] Keep browser evaluation working on strict-policy pages without shadowing page globals (#8973) * fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval' * test(desktop): browser_evaluate on pages that allow and forbid eval * test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it * fix(desktop): preserve page globals in browser evaluation Define the supplied function outside every wrapper binding so strict-policy evaluation keeps the original page scope. Co-authored-by: Abdulhakeem Mustapha --------- Co-authored-by: Abdulhakeem Mustapha Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/desktop/e2e/browser-tabs.e2e.mjs | 51 +++++++- .../browser-automation/service.test.ts | 121 ++++++++++++++++++ .../features/browser-automation/service.ts | 34 ++++- 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 4c8fcbaaa0..93e6bd61d1 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -213,8 +213,18 @@ async function settleDesktopStartup(page) { } } +const CSP_PAGE_PATH = "/csp-no-eval"; + async function startTargetPage() { - const server = createServer((_request, response) => { + const server = createServer((request, response) => { + if (request.url === CSP_PAGE_PATH) { + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "content-security-policy": "default-src 'self'; script-src 'self'", + }); + response.end("CSP page

CSP page heading

"); + return; + } response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(` @@ -351,6 +361,42 @@ async function waitForGuestActiveElement(client, browserId, elementId) { return false; } +// A page whose Content Security Policy omits 'unsafe-eval' refuses eval(); the tools must still work. +async function verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl) { + const created = await callBrowserTool(client, "browser_new_tab", { + url: new URL(CSP_PAGE_PATH, targetUrl).href, + }); + const browserId = created.browserId; + assert(typeof browserId === "string", "browser_new_tab returned no browserId for the CSP page"); + try { + await callBrowserTool(client, "browser_wait", { + browserId, + text: "CSP page heading", + timeoutMs: 15_000, + }); + const title = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.title", + }); + assert( + JSON.parse(title.resultJson) === "CSP page", + `browser_evaluate on a page without 'unsafe-eval' returned ${title.resultJson}`, + ); + // The control: the page itself really does refuse eval(). + const probe = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: + '() => { try { (0, eval)("1"); return "eval allowed"; } catch (error) { return error.message; } }', + }); + assert( + /Content Security Policy/.test(JSON.parse(probe.resultJson)), + `The CSP page did not refuse eval(): ${probe.resultJson}`, + ); + } finally { + await callBrowserTool(client, "browser_close_tab", { browserId }); + } +} + async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), @@ -736,6 +782,8 @@ async function runRegression({ document.body.appendChild(overlayRoot); }); + await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl); + const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl }); const browserId = created.browserId; assert(typeof browserId === "string", "browser_new_tab returned no browserId"); @@ -1168,6 +1216,7 @@ async function runRegression({ snapshot: "passed", click: "passed", localPageSelectors: "passed", + evaluateWithoutUnsafeEval: "passed", }; } diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index dfaa5331d7..d793e138b7 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -455,6 +455,34 @@ function browserWithTimingEntries(entries: { resource: unknown[]; navigation: un return browser; } +/** + * Runs the script the host injects into a page the way Chromium runs it on a page whose Content + * Security Policy omits 'unsafe-eval': injected code runs, but code in the page cannot turn a + * string into code (eval, new Function). Node's vm refuses string code generation the same way. + */ +function pageRunningInjectedScripts(input: { + allowEval: boolean; + globals?: Record; +}) { + const page = createContext( + { document: { title: "Fixture" }, ...input.globals }, + { codeGeneration: { strings: input.allowEval, wasm: input.allowEval } }, + ); + return (script: string): unknown => runInContext(script, page); +} + +/** A browser whose tab runs the evaluate script in `page`; its other scripts keep the fake answers. */ +function browserEvaluatingIn(page: (script: string) => unknown) { + const browser = new BrowserAutomationHarness(); + const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab); + browser.tab.executeJavaScript = async (script: string) => { + if (!script.includes("__PASEO_BROWSER_EVALUATE__")) return fakeAnswer(script); + browser.tab.scripts.push(script); + return page(script); + }; + return browser; +} + function requireSnapshotRefs(result: Awaited>) { expect(result).toEqual({ requestId: "req-snapshot", @@ -1645,6 +1673,99 @@ describe("executeAutomationCommand", () => { expect(containsScript(browser.tab, "resultJson.slice(0, 79000)")).toBe(true); }); + describe.each([ + { page: "allows eval", allowEval: true }, + { page: "forbids eval (its Content Security Policy omits 'unsafe-eval')", allowEval: false }, + ])("evaluate on a page that $page", ({ allowEval }) => { + const evaluate = (browser: BrowserAutomationHarness, source: string) => + browser.execute({ + command: "evaluate", + args: { browserId: BROWSER_A, function: source }, + }); + + test("runs the function and returns its result as JSON", async () => { + const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval })); + + expect(await evaluate(browser, "() => document.title")).toEqual({ + requestId: "req-evaluate", + ok: true, + result: { + command: "evaluate", + browserId: BROWSER_A, + resultJson: '"Fixture"', + truncated: false, + }, + }); + }); + + test("awaits an async function", async () => { + const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval })); + + const result = await evaluate( + browser, + "async () => { await Promise.resolve(); return 6 * 7; }", + ); + + expect(result).toMatchObject({ ok: true, result: { resultJson: "42" } }); + }); + + test("resolves names against the page's globals, never the wrapper's own", async () => { + const globals = { + element: "page element", + args: "page args", + value: "page value", + resultJson: "page json", + userFunction: "page function", + __paseoEvaluate: "page wrapper", + readFunction: "page reader", + }; + const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval, globals })); + + const result = await evaluate( + browser, + "() => [element, args, value, resultJson, userFunction, __paseoEvaluate, readFunction]", + ); + + expect(result).toMatchObject({ + ok: true, + result: { resultJson: JSON.stringify(Object.values(globals)) }, + }); + }); + + test("reports what the function throws", async () => { + const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval })); + + expect(await evaluate(browser, "() => { throw new Error('boom'); }")).toMatchObject({ + ok: false, + error: { code: "browser_unknown_error", message: "boom" }, + }); + }); + + test("reports an expression that is not a function", async () => { + const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval })); + + expect(await evaluate(browser, "document.title")).toMatchObject({ + ok: false, + error: { + code: "browser_unknown_error", + message: "browser_evaluate input must evaluate to a function.", + }, + }); + }); + + test("reports a syntax error with the compiler's own message, without touching the page", async () => { + const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval })); + + const result = await evaluate(browser, "() => {"); + + expect(result).toMatchObject({ + ok: false, + error: { code: "browser_unknown_error", message: expect.stringMatching(/^Unexpected /) }, + }); + expect(containsScript(browser.tab, "__PASEO_BROWSER_EVALUATE__")).toBe(false); + }); + }); + test("screenshot captures the painted viewport", async () => { const browser = new BrowserAutomationHarness(); diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index fd5b12f83b..34a01ed074 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -1,4 +1,5 @@ import { isAbsolute, relative, resolve as resolvePath } from "node:path"; +import { Script } from "node:vm"; import type { BrowserAutomationCommand, @@ -849,6 +850,11 @@ async function executeEvaluate( elementExpression = expression; } + const syntaxError = functionSyntaxError(functionSource); + if (syntaxError !== null) { + return fail(requestId, "browser_unknown_error", capEvaluateErrorMessage(syntaxError)); + } + let rawResult: unknown; try { rawResult = await target.contents.executeJavaScript( @@ -1550,14 +1556,36 @@ function capEvaluateErrorMessage(message: string): string { : message.slice(0, MAX_EVALUATE_ERROR_MESSAGE_LENGTH); } +/** + * The syntax error in the agent's function, or null. It is compiled here because a script that + * fails to compile in the page only reports "Script failed to execute", and a typo deserves the + * compiler's own message. Compiling runs nothing. + */ +function functionSyntaxError(functionSource: string): string | null { + try { + void new Script(`(${functionSource})`); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +/** + * The page script that runs the agent's function and returns its result as bounded JSON. + * + * The function goes into the script as code, not as a string for the page to eval(): a page whose + * Content Security Policy omits 'unsafe-eval' refuses eval() but still runs what the host injects. + * The function-producing thunk is an argument defined outside the wrapper, so its names resolve + * against the page's globals without capturing any wrapper parameters or locals. + */ function buildEvaluateScript( functionSource: string, elementExpression: string | undefined, ): string { - return String.raw`(async () => { + return String.raw`(async (readFunction) => { const __PASEO_BROWSER_EVALUATE__ = true; try { - const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)}); + const userFunction = readFunction(); if (typeof userFunction !== 'function') { throw new Error('browser_evaluate input must evaluate to a function.'); } @@ -1582,7 +1610,7 @@ function buildEvaluateScript( } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } - })()`; + })(() => (${functionSource}))`; } async function focusKeypressTarget( From 0e400225196a1fbbddcc174bc87eb56abbf019cf Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:04:10 +0000 Subject: [PATCH 30/32] Preserve linked resources when pasting prompts (#8972) * fix(app): preserve linked resources when pasting prompts Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command. * fix(app): keep pasted links undoable while drafting offline Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads. * fix(app): preserve copied Markdown with normalized links Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact. * fix(app): retain external linked tables when pasting prompts Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells. * test(app): name rich clipboard paste arguments * fix(app): preserve pasted links around rowless tables The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations. * test(app): wait for workspace restore before undo teardown The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/composer-focus.spec.ts | 83 +++++++++- .../browser/workspace-archive-undo.spec.ts | 1 + packages/app/e2e/support/helpers/composer.ts | 87 +++++++++-- packages/app/src/composer/input/input.tsx | 17 ++- .../src/composer/pasted-text.browser.test.ts | 144 ++++++++++++++++++ packages/app/src/composer/pasted-text.ts | 7 + packages/app/src/composer/pasted-text.web.ts | 59 +++++++ .../app/src/types/turndown-plugin-gfm.d.ts | 1 + 8 files changed, 378 insertions(+), 21 deletions(-) create mode 100644 packages/app/src/composer/pasted-text.browser.test.ts create mode 100644 packages/app/src/composer/pasted-text.ts create mode 100644 packages/app/src/composer/pasted-text.web.ts diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 11fe0bf50f..4f2610b8db 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,5 +1,11 @@ import { expect, test, type Page } from "../support/fixtures"; import { + pasteRichTextIntoComposer, + fillComposerDraft, + copyLatestTurnIntoComposer, + selectComposerDraftRange, + undoComposerEdit, + redoComposerEdit, blurComposerInput, roundTripComposerTabBeforePaint, composerLocator, @@ -28,6 +34,7 @@ import { openMobileAgentSidebarWithKeyboard, swipeOpenMobileExplorer, } from "../support/helpers/sidebar"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor"; import { expectTimelinePromptPositionPreserved, @@ -55,7 +62,9 @@ test("clicking chat text or background focuses the feed for keyboard paging", as await expect .poll(async () => (await readScrollMetrics(page)).offsetY) .toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2); - await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") }); + await page.screenshot({ + path: testInfo.outputPath("chat-keyboard-paging.png"), + }); await page.keyboard.press("PageDown"); await expect .poll(async () => (await readScrollMetrics(page)).distanceFromBottom) @@ -124,11 +133,15 @@ test("focusing the feed preserves assistant text selection and code copy control }); test("submitting a message leaves the composer ready for the next message", async ({ page }) => { + const copiedTable = + "| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space]() | **open** |\n| [ASCII](https://example.com/1) | closed |"; const agent = await seedMockAgentWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus", + featureValues: { mockAssistantResponse: copiedTable }, }); + const connection = await installDaemonWebSocketGate(page); try { await openAgentRoute(page, agent); await expectComposerVisible(page); @@ -136,8 +149,62 @@ test("submitting a message leaves the composer ready for the next message", asyn await submitMessage(page, "First message"); await expectComposerFocused(page); - await typeIntoFocusedComposer(page, "Second message"); - await expectComposerDraft(page, "Second message"); + await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks"); + await selectComposerDraftRange(page, 13, 25); + await pasteRichTextIntoComposer({ + page, + html: 'this FR', + text: "this FR", + }); + await expectComposerDraft( + page, + "Investigate: [this FR](https://example.com/issues/39150). Thanks", + ); + await undoComposerEdit(page); + await expectComposerDraft(page, "Investigate: replace this. Thanks"); + await redoComposerEdit(page); + await expectComposerDraft( + page, + "Investigate: [this FR](https://example.com/issues/39150). Thanks", + ); + await typeIntoFocusedComposer(page, " next"); + await expectComposerDraft( + page, + "Investigate: [this FR](https://example.com/issues/39150) next. Thanks", + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible(); + await connection.drop(); + await connection.waitForBlockedConnection(); + await pasteRichTextIntoComposer({ + page, + html: 'offline link', + text: "offline link", + }); + await expectComposerDraft( + page, + "Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks", + ); + await fillComposerDraft(page, ""); + await pasteRichTextIntoComposer({ + page, + html: '
ResourceStatus
Issueclosed
', + text: "Resource\tStatus\nIssue\tclosed", + }); + await expectComposerDraft( + page, + "| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |", + ); + await fillComposerDraft(page, ""); + await pasteRichTextIntoComposer({ + page, + html: 'Issue
', + text: "Issue", + }); + await expectComposerDraft(page, "[Issue](https://example.com/)"); + await copyLatestTurnIntoComposer(page); + await expectComposerDraft(page, copiedTable); + await expectComposerFocused(page); } finally { await agent.cleanup(); } @@ -168,7 +235,11 @@ async function waitForAnimationFrames(page: Page, count: number): Promise test.describe("touch screen wide enough for the desktop layout", () => { // A tablet or an unfolded foldable gets the desktop layout, but focusing the // composer there raises the on-screen keyboard over the conversation. - test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true }); + test.use({ + viewport: { width: 1024, height: 768 }, + isMobile: true, + hasTouch: true, + }); test("remounting an inline composer does not restore focus on touch", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -192,7 +263,9 @@ test.describe("touch screen wide enough for the desktop layout", () => { test("returning to an inactive inline composer does not restore focus on touch", async ({ page, }) => { - const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" }); + const workspace = await seedWorkspace({ + repoPrefix: "composer-touch-retained-", + }); try { const first = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts index 90e47aab3c..9bf8e4cafd 100644 --- a/packages/app/e2e/browser/workspace-archive-undo.spec.ts +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -112,6 +112,7 @@ test.describe("Workspace archive undo", () => { await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 }); await expectDirectoryPresence(worktree.workspaceDirectory, true); + await expectHostWorkspaceSettledActive(client, worktree.workspaceId); }); test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({ diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index bc0fef67f3..3c29bc24cc 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -31,7 +31,9 @@ export async function expectComposerVisible( page: Page, options?: { timeout?: number }, ): Promise { - await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 }); + await expect(composerInput(page)).toBeVisible({ + timeout: options?.timeout ?? 15_000, + }); } export async function expectComposerDisabled(page: Page): Promise { @@ -156,7 +158,9 @@ export async function attachImageFromMenu( } export async function expectAttachmentPill(page: Page, testID: string): Promise { - await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId(testID).first()).toBeVisible({ + timeout: 10_000, + }); } export async function attachFileFromMenu( @@ -218,18 +222,24 @@ export async function expectGithubAttachmentPill( export async function openImageLightbox(page: Page): Promise { await page.getByRole("button", { name: "Open image attachment" }).first().click(); - await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ + timeout: 5_000, + }); } export async function closeImageLightbox(page: Page): Promise { await page.keyboard.press("Escape"); - await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ + timeout: 5_000, + }); } export async function openGithubPickerFromMenu(page: Page): Promise { await openAttachmentMenu(page); await page.getByTestId("message-input-attachment-menu-item-github").click(); - await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ + timeout: 5_000, + }); } /** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */ @@ -283,7 +293,10 @@ export async function startRunningMockAgent( model: opts.model, featureValues: opts.featureValues, }); - const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; + const agentUrl = `${buildHostWorkspaceRoute( + serverId, + workspace.id, + )}?open=${encodeURIComponent(`agent:${agent.id}`)}`; await page.goto(agentUrl); await expectComposerVisible(page); await client.sendAgentMessage(agent.id, opts.prompt); @@ -347,7 +360,11 @@ export async function pasteComposerTestImage(page: Page): Promise { const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); const clipboardData = new DataTransfer(); clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" })); - const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); + const event = new ClipboardEvent("paste", { + clipboardData, + bubbles: true, + cancelable: true, + }); input.dispatchEvent(event); return event.defaultPrevented; }, COMPOSER_TEST_IMAGE_BASE64); @@ -505,7 +522,10 @@ export async function recordVisibleComposerGeometry(page: Page): Promise { export async function expectNoCollapsedVisibleComposer(page: Page): Promise { const frames = await page.evaluate(() => { Reflect.get(window, "__stopComposerGeometry")?.(); - return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[]; + return Reflect.get(window, "__composerGeometryFrames") as { + input: number; + root: number; + }[]; }); expect(frames.length).toBeGreaterThan(0); expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]); @@ -516,7 +536,10 @@ export async function roundTripComposerTabBeforePaint( page: Page, activeChatTitle: string, ): Promise { - const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true }); + const activeTab = page.getByRole("button", { + name: activeChatTitle, + exact: true, + }); await expect(activeTab).toHaveAttribute("aria-selected", "true"); const result = await activeTab.evaluate((tab) => { const editor = document.activeElement; @@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint( }); } }); - observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] }); + observer.observe(tab, { + attributes: true, + attributeFilter: ["aria-selected"], + }); shortcut("BracketLeft"); }); }); @@ -566,3 +592,44 @@ export async function roundTripComposerTabBeforePaint( ).toBe(true); expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false); } + +/** Paste both clipboard representations through the browser's normal paste command. */ +export async function pasteRichTextIntoComposer({ + page, + html, + text, +}: { + page: Page; + html: string; + text: string; +}): Promise { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.evaluate( + async ({ html: richHtml, text: plainText }) => { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([richHtml], { type: "text/html" }), + "text/plain": new Blob([plainText], { type: "text/plain" }), + }), + ]); + }, + { html, text }, + ); + await composerInput(page).press("ControlOrMeta+v"); +} + +export async function undoComposerEdit(page: Page): Promise { + await composerInput(page).press("ControlOrMeta+z"); +} +export async function redoComposerEdit(page: Page): Promise { + await composerInput(page).press("ControlOrMeta+Shift+z"); +} + +/** Reuse the actual response Copy action and the browser's native paste. */ +export async function copyLatestTurnIntoComposer(page: Page): Promise { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.getByRole("button", { name: "Copy turn", exact: true }).last().click(); + await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible(); + await composerInput(page).fill(""); + await composerInput(page).press("ControlOrMeta+v"); +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 3c4541181f..fc82a73e6d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -88,6 +88,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeight } from "./height"; import { ComposerInputPresentation } from "./presentation"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; +import { pasteLinkedClipboardText } from "@/composer/pasted-text"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { EditingTextInput, @@ -513,7 +514,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM return native instanceof HTMLElement ? native : null; } -interface PasteImagesEffectArgs { +interface PasteEffectArgs { textarea: HTMLElement | null; isConnected: boolean; disabled: boolean; @@ -522,7 +523,7 @@ interface PasteImagesEffectArgs { onAddImages: ((images: ImageAttachment[]) => void) | undefined; } -function usePasteImagesEffect(args: PasteImagesEffectArgs): void { +function usePasteEffect(args: PasteEffectArgs): void { const { textarea, isConnected, @@ -533,16 +534,20 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { } = args; useEffect(() => { - if (!isWeb || !onAddImages) return; + if (!isWeb) return; if (!textarea) return; let disposed = false; const handlePaste = (event: ClipboardEvent) => { - if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return; + if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return; const imageFiles = collectImageFilesFromClipboardData(event.clipboardData); - if (imageFiles.length === 0) return; + if (imageFiles.length === 0) { + if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault(); + return; + } + if (!isConnected || !onAddImages) return; event.preventDefault(); @@ -1811,7 +1816,7 @@ export const MessageInput = forwardRef( handleAlternateSendAction(); }, [alternateSendAction, handleAlternateSendAction]); - usePasteImagesEffect({ + usePasteEffect({ textarea: webTextArea, isConnected, disabled, diff --git a/packages/app/src/composer/pasted-text.browser.test.ts b/packages/app/src/composer/pasted-text.browser.test.ts new file mode 100644 index 0000000000..e492cd237b --- /dev/null +++ b/packages/app/src/composer/pasted-text.browser.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createMarkdownClipboardContent } from "@/utils/rich-clipboard"; +import { pasteLinkedClipboardText } from "./pasted-text.web"; + +const inputs: HTMLTextAreaElement[] = []; +function editor(): HTMLTextAreaElement { + const input = document.createElement("textarea"); + document.body.append(input); + inputs.push(input); + return input; +} +afterEach(() => { + for (const input of inputs.splice(0)) input.remove(); +}); + +function clipboard(html: string, text: string): DataTransfer { + const data = new DataTransfer(); + data.setData("text/html", html); + data.setData("text/plain", text); + return data; +} + +describe("pasted prompt links", () => { + it.each([ + "https://example.com/1", + "https://example.com/日本語", + "", + "https://例え.テスト/issue", + ])("keeps complete Markdown from Copy with destination %s", (destination) => { + const source = `| Resource | Status |\n| --- | --- |\n| [issue](${destination}) | ~~closed~~ |`; + const content = createMarkdownClipboardContent(source); + const input = editor(); + expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false); + expect(input.value).toBe(""); + }); + it("converts external links when plain text names the resource but omits a destination", () => { + const input = editor(); + expect( + pasteLinkedClipboardText( + input, + clipboard( + '日本語 and another', + "[日本語](https://example.com/日本語) and another", + ), + ), + ).toBe(true); + expect(input.value).toBe( + "[日本語](https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E) and [another](https://example.com/missing)", + ); + }); + it("preserves a labeled resource URL as Markdown", () => { + const data = clipboard( + 'Hey can you take a look at this FR and...', + "Hey can you take a look at this FR and...", + ); + const input = editor(); + expect(pasteLinkedClipboardText(input, data)).toBe(true); + expect(input.value).toBe( + "Hey can you take a look at [this FR](https://example.com/issues/39150) and...", + ); + }); + + it("preserves external linked table rows and columns", () => { + const input = editor(); + expect( + pasteLinkedClipboardText( + input, + clipboard( + '
ResourceStatus
Issueclosed
', + "Resource\tStatus\nIssue\tclosed", + ), + ), + ).toBe(true); + expect(input.value).toBe( + "| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |", + ); + }); + + it("keeps table cell separators and line breaks inside their cells", () => { + const input = editor(); + expect( + pasteLinkedClipboardText( + input, + clipboard( + '
ResourceStatus
Issue | trackerclosed
today
', + "Resource\tStatus\nIssue | tracker\tclosed today", + ), + ), + ).toBe(true); + expect(input.value).toBe( + "| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed
today |", + ); + }); + + it("retains headerless tables as HTML without inventing a heading", () => { + const html = + '
Issueclosed
Otheropen
'; + const input = editor(); + expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe( + true, + ); + expect(input.value).toBe(html); + }); + + it.each([ + ["
", ""], + ["
Context
", "\n\nContext"], + ])("keeps surrounding links when a table has no rows: %s", (table, caption) => { + const input = editor(); + expect( + pasteLinkedClipboardText( + input, + clipboard(`Issue${table}`, "Issue"), + ), + ).toBe(true); + expect(input.value).toBe(`[Issue](https://example.com/)${caption}`); + }); + + it("leaves plain text and rich text without links to native paste", () => { + const input = editor(); + expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe( + false, + ); + expect(pasteLinkedClipboardText(input, clipboard("hello", "hello"))).toBe(false); + expect(pasteLinkedClipboardText(input, null)).toBe(false); + expect(input.value).toBe(""); + }); + + it("keeps paragraphs and multiple links", () => { + const input = editor(); + expect( + pasteLinkedClipboardText( + input, + clipboard( + '

See one

and two

', + "See one\n\nand two", + ), + ), + ).toBe(true); + expect(input.value).toBe( + "See [one](https://example.com/a)\n\nand [two](https://example.com/b)", + ); + }); +}); diff --git a/packages/app/src/composer/pasted-text.ts b/packages/app/src/composer/pasted-text.ts new file mode 100644 index 0000000000..99e7b2cfb5 --- /dev/null +++ b/packages/app/src/composer/pasted-text.ts @@ -0,0 +1,7 @@ +/** Native inputs retain their platform clipboard handling. */ +export function pasteLinkedClipboardText( + _input: HTMLElement, + _clipboard: Pick | null, +): boolean { + return false; +} diff --git a/packages/app/src/composer/pasted-text.web.ts b/packages/app/src/composer/pasted-text.web.ts new file mode 100644 index 0000000000..b9b89d215e --- /dev/null +++ b/packages/app/src/composer/pasted-text.web.ts @@ -0,0 +1,59 @@ +import TurndownService from "turndown"; +import { tables } from "turndown-plugin-gfm"; +import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; + +const markdown = new TurndownService({ + codeBlockStyle: "fenced", + bulletListMarker: "-", +}); +// Heading tables become Markdown; headerless tables retain their HTML structure. +markdown.use(tables); +markdown.addRule("rowlessTable", { + filter: (node) => node.nodeName === "TABLE" && (node as HTMLTableElement).rows.length === 0, + replacement: (content) => (content ? `\n\n${content}\n\n` : ""), +}); +markdown.addRule("tableCell", { + filter: ["th", "td"], + replacement: (content, node) => { + const prefix = node.previousElementSibling ? " " : "| "; + const cell = content.trim().replace(/\|/g, "\\|").replace(/ *\n/g, "
"); + return `${prefix}${cell} |`; + }, +}); +const copiedMarkdown = createAssistantMarkdownParser(); + +function linkDestinations(fragment: Document): string[] { + return Array.from(fragment.querySelectorAll("a[href]"), (link) => link.getAttribute("href")!); +} + +function containsDestinations(plainText: string, destinations: string[]): boolean { + if (destinations.every((url) => plainText.includes(url))) return true; + + // Copy normalizes Markdown URLs in HTML (percent encoding, internationalized + // domains). Compare parsed destinations using that same parser, not URL spelling. + const rendered = new DOMParser().parseFromString(copiedMarkdown.render(plainText), "text/html"); + const links = new Set(linkDestinations(rendered)); + return destinations.every((url) => plainText.includes(url) || links.has(url)); +} + +/** Paste linked HTML as Markdown through the browser's undoable editing command. */ +export function pasteLinkedClipboardText( + input: HTMLElement, + clipboard: Pick | null, +): boolean { + const html = clipboard?.getData("text/html"); + if (!html) return false; + + // Parse in a detached document. Clipboard markup is never mounted in the app. + const fragment = new DOMParser().parseFromString(html, "text/html"); + const destinations = linkDestinations(fragment); + const plainText = clipboard!.getData("text/plain"); + // Copy actions already put complete Markdown on the clipboard. Keep that + // representation, including tables and formatting, whenever it carries the URLs. + if (containsDestinations(plainText, destinations)) return false; + const text = markdown.turndown(fragment.body); + input.focus(); + // Direct value assignment drops the textarea's undo history. This also emits + // the normal input event so the editor publishes its new draft as for typing. + return input.ownerDocument.execCommand("insertText", false, text); +} diff --git a/packages/app/src/types/turndown-plugin-gfm.d.ts b/packages/app/src/types/turndown-plugin-gfm.d.ts index a6c0855cf9..3562d8bfbd 100644 --- a/packages/app/src/types/turndown-plugin-gfm.d.ts +++ b/packages/app/src/types/turndown-plugin-gfm.d.ts @@ -1,5 +1,6 @@ declare module "turndown-plugin-gfm" { import type TurndownService from "turndown"; + export const tables: TurndownService.Plugin; export const gfm: TurndownService.Plugin; } From aac228e0a8ecb01cda1d56a4f2b4c3b2f00aa167 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:01:32 +0000 Subject: [PATCH 31/32] test(server): separate legacy plugin setup from migration deadline (#8983) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/plugins/index.posix.test.ts | 221 ++++++++++-------- 1 file changed, 123 insertions(+), 98 deletions(-) diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 5b6985a55e..1874e517b7 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -672,15 +672,26 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it.each([true, false])( - "updates the shipped legacy Git layout with enabled=%s", - async (enabled) => { - const home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-")); + describe("legacy Git migration", () => { + const id = "legacy-review"; + const pluginPath = "plugins/review"; + let home: string; + let repository: string; + let initial: string; + let remote: string; + let legacyRoot: string; + let directory: string; + let settingsDirectory: string; + let settingsFile: string; + let savedSettings: string; + + // Build the legacy installation before the migration workflow deadline starts. + // These seven real Git launches have their own fixture setup budget. + beforeEach(async () => { + home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-")); roots.push(home); - const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-")); + repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-")); roots.push(repository); - const id = "legacy-review"; - const pluginPath = "plugins/review"; await mkdir(path.join(repository, pluginPath), { recursive: true }); await writeFile( path.join(repository, pluginPath, "paseo-plugin.json"), @@ -703,13 +714,11 @@ export default function contribute(server: PluginServerContext) { ["tag", "v1"], ]) await runGitCommand(args, { cwd: repository }); - const initial = ( - await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }) - ).stdout.trim(); - const remote = pathToFileURL(repository).href; - const legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`); + initial = (await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })).stdout.trim(); + remote = pathToFileURL(repository).href; + legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`); const checkoutRoot = path.join(legacyRoot, "checkout"); - const directory = path.join(checkoutRoot, pluginPath); + directory = path.join(checkoutRoot, pluginPath); await mkdir(legacyRoot, { recursive: true }); await runGitCommand(["clone", remote, checkoutRoot], { cwd: home }); await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot }); @@ -726,95 +735,111 @@ export default function contribute(server: PluginServerContext) { }, }), ); - const settingsDirectory = path.join(home, "plugin-settings"); + settingsDirectory = path.join(home, "plugin-settings"); await mkdir(path.join(settingsDirectory, id), { recursive: true }); - const settingsFile = path.join(settingsDirectory, id, "preferences.json"); - const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } }); + settingsFile = path.join(settingsDirectory, id, "preferences.json"); + savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } }); await writeFile(settingsFile, savedSettings); - const store = createStore(home, { [id]: { source: "directory", path: directory, enabled } }); - const open = () => - bindTestSessionHost( - new PluginService(pino({ level: "silent" }), store, "0.8.0", { - managedSources: new ManagedPluginSources(home), - settingsDirectory, - }), - ); - let service = open(); - try { - await service.start(); - const expected = { - identity: { kind: "git", remote, pluginPath }, - currentRevision: initial, - }; - await rename(repository, `${repository}-offline`); - try { - expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected); - expect(await service.listPlugins()).toMatchObject([ - { id, enabled, status: enabled ? "running" : "disabled", installation: expected }, - ]); - } finally { - await rename(`${repository}-offline`, repository); - } - await service.stopAllPlugins(); - service = open(); - await service.start(); - await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository }); - await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default"); - for (const args of [ - ["add", "."], - ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"], - ]) - await runGitCommand(args, { cwd: repository }); - const latest = ( - await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }) - ).stdout.trim(); - const [preview] = await service.previewUpdates({ pluginId: id }); - expect(preview).toMatchObject({ - outcome: "update", - current: expected, - target: { kind: "git", commit: latest }, + }); + + it.each([true, false])( + "updates the shipped legacy Git layout with enabled=%s", + async (enabled) => { + const store = createStore(home, { + [id]: { source: "directory", path: directory, enabled }, }); - const [updated] = await service.applyUpdates([preview!.proposal!]); - expect(updated).toMatchObject({ - outcome: "updated", - plugin: { - id, - enabled, - status: enabled ? "running" : "disabled", - installation: { identity: expected.identity, currentRevision: latest }, - }, - }); - await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); - expect(await readFile(settingsFile, "utf8")).toBe(savedSettings); - await service.disablePlugin(id); - await service.enablePlugin(id); - expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" }); - expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({ - status: "ready", - values: { message: "keep my settings" }, - }); - await service.removePlugin(id); - await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({ - code: "ENOENT", - }); - expect(await stat(repository)).toBeDefined(); - const sources = new ManagedPluginSources(home); - // A record cannot confer ownership on another plugin's tree or a malformed version name. - sources.commit(id, { kind: "git", remote }); - for (const outside of [ - path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"), - path.join(home, "plugins", id, "f".repeat(36), "checkout"), - path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"), - ]) - await expect(sources.describe(id, outside)).rejects.toThrow( - "outside its managed installation", + const open = () => + bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.8.0", { + managedSources: new ManagedPluginSources(home), + settingsDirectory, + }), ); - } finally { - await service.stopAllPlugins(); - } - }, - 60_000, - ); + let service = open(); + try { + await service.start(); + const expected = { + identity: { kind: "git", remote, pluginPath }, + currentRevision: initial, + }; + await rename(repository, `${repository}-offline`); + try { + expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected); + expect(await service.listPlugins()).toMatchObject([ + { id, enabled, status: enabled ? "running" : "disabled", installation: expected }, + ]); + } finally { + await rename(`${repository}-offline`, repository); + } + await service.stopAllPlugins(); + service = open(); + await service.start(); + await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository }); + await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default"); + for (const args of [ + ["add", "."], + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-m", + "update", + ], + ]) + await runGitCommand(args, { cwd: repository }); + const latest = ( + await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }) + ).stdout.trim(); + const [preview] = await service.previewUpdates({ pluginId: id }); + expect(preview).toMatchObject({ + outcome: "update", + current: expected, + target: { kind: "git", commit: latest }, + }); + const [updated] = await service.applyUpdates([preview!.proposal!]); + expect(updated).toMatchObject({ + outcome: "updated", + plugin: { + id, + enabled, + status: enabled ? "running" : "disabled", + installation: { identity: expected.identity, currentRevision: latest }, + }, + }); + await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(settingsFile, "utf8")).toBe(savedSettings); + await service.disablePlugin(id); + await service.enablePlugin(id); + expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" }); + expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({ + status: "ready", + values: { message: "keep my settings" }, + }); + await service.removePlugin(id); + await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await stat(repository)).toBeDefined(); + const sources = new ManagedPluginSources(home); + // A record cannot confer ownership on another plugin's tree or a malformed version name. + sources.commit(id, { kind: "git", remote }); + for (const outside of [ + path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"), + path.join(home, "plugins", id, "f".repeat(36), "checkout"), + path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"), + ]) + await expect(sources.describe(id, outside)).rejects.toThrow( + "outside its managed installation", + ); + } finally { + await service.stopAllPlugins(); + } + }, + 60_000, + ); + }); it("keeps the running commit when a Git update build command fails", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); From a449271d57cd57671ad377417d9ec64d6b3d6e76 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Nov 2026 18:05:31 +0700 Subject: [PATCH 32/32] Let plugins provide custom sidebar views across hosts (#8906) * feat(plugin): expose stable sidebar entity data across hosts Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase. * feat(plugin): let users select a sidebar view An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery. * feat(plugin): add sidebar view examples and ref actions Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders. * feat(plugin): observe terminals across connected hosts Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp. --- docs/plugins.md | 7 + .../app/e2e/browser/sidebar-views.spec.ts | 18 + .../app/e2e/support/helpers/new-workspace.ts | 3 + .../app/e2e/support/helpers/sidebar-views.ts | 191 ++++++++ packages/app/src/components/left-sidebar.tsx | 105 ++--- packages/app/src/data/push-router.test.ts | 8 +- packages/app/src/data/query.ts | 7 +- packages/app/src/i18n/resources/ar.ts | 14 + packages/app/src/i18n/resources/en.ts | 14 + packages/app/src/i18n/resources/es.ts | 15 + packages/app/src/i18n/resources/fr.ts | 15 + packages/app/src/i18n/resources/ja.ts | 15 + packages/app/src/i18n/resources/ko.ts | 15 + packages/app/src/i18n/resources/pt-BR.ts | 15 + packages/app/src/i18n/resources/ru.ts | 14 + packages/app/src/i18n/resources/zh-CN.ts | 14 + packages/app/src/plugins/actions.ts | 3 +- .../app/src/plugins/buttons/model.test.ts | 1 + .../src/plugins/catalog-sync-model.test.ts | 3 +- packages/app/src/plugins/client-runtime.ts | 5 +- packages/app/src/plugins/client-state/api.ts | 68 +++ .../plugins/client-state/connected-hosts.ts | 72 +++ .../app/src/plugins/client-state/fixtures.ts | 53 +++ packages/app/src/plugins/client-state/refs.ts | 3 + .../plugins/client-state/snapshots.test.ts | 151 ++++--- .../app/src/plugins/client-state/snapshots.ts | 95 +++- .../client-state/source-retention.test.ts | 55 +++ .../client-state/source.browser.test.tsx | 420 ++++++++++++++++++ .../app/src/plugins/client-state/source.ts | 257 ++++++++++- .../app/src/plugins/client-state/terminals.ts | 154 +++++++ .../command-center/contributions.test.ts | 27 ++ packages/app/src/plugins/evaluate.test.ts | 23 + packages/app/src/plugins/evaluate.ts | 18 +- .../plugins/installation-provider.test.tsx | 14 +- .../app/src/plugins/installation-provider.tsx | 3 +- .../src/plugins/registry-lifecycle.test.ts | 3 +- .../src/plugins/registry-requirements.test.ts | 3 +- packages/app/src/plugins/registry.ts | 1 + .../app/src/plugins/settings/device.test.ts | 97 ++++ packages/app/src/plugins/settings/device.ts | 80 ++++ .../app/src/plugins/settings/use-settings.ts | 64 ++- .../app/src/plugins/sidebar-groups.test.ts | 1 + .../app/src/plugins/sidebar-view/catalog.ts | 25 ++ .../sidebar-view/frame.browser.test.tsx | 69 +++ .../app/src/plugins/sidebar-view/frame.tsx | 34 ++ .../app/src/plugins/sidebar-view/index.tsx | 173 ++++++++ .../app/src/plugins/sidebar-view/picker.tsx | 58 +++ .../app/src/plugins/sidebar-view/selection.ts | 47 ++ .../src/plugins/surface-contribution.test.ts | 1 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + .../app/src/plugins/timeline/view.test.tsx | 3 +- packages/app/src/plugins/types.ts | 2 + .../workspace-panels/resolution.test.ts | 1 + .../screens/settings/plugins-page.test.tsx | 3 +- .../settings/sidebar/sidebar-nav-section.tsx | 2 + packages/client/src/daemon-client.ts | 8 +- packages/client/src/index.ts | 36 ++ packages/plugin/src/boundaries.test.ts | 42 ++ packages/plugin/src/client/client-state.tsx | 157 +++++-- packages/plugin/src/client/contracts.ts | 24 +- packages/plugin/src/client/host.ts | 11 +- packages/plugin/src/client/index.ts | 18 +- packages/plugin/src/client/paseo-context.tsx | 16 +- packages/plugin/src/client/query.test.ts | 58 +++ packages/plugin/src/client/query.ts | 115 +++++ packages/plugin/src/client/reference-api.ts | 88 ++++ packages/plugin/src/client/refs.test.ts | 24 + packages/plugin/src/client/refs.ts | 26 ++ packages/plugin/src/contracts.ts | 40 ++ packages/plugin/src/index.ts | 3 + packages/plugin/src/refs.ts | 11 + packages/plugin/src/settings.ts | 7 +- packages/protocol/src/messages.test.ts | 40 ++ packages/protocol/src/messages.ts | 11 +- .../protocol/src/terminal-subscription-key.ts | 7 +- .../terminal-workspace-sdk.e2e.test.ts | 131 +++++- .../src/server/plugins/runtime.posix.test.ts | 2 +- .../src/server/plugins/settings/index.test.ts | 8 + .../src/server/plugins/settings/index.ts | 1 + packages/server/src/server/session.ts | 6 + .../server/src/server/websocket-server.ts | 1 + .../workspace-script-runtime-store.test.ts | 13 + .../server/workspace-script-runtime-store.ts | 14 + .../terminal-session-controller.test.ts | 50 ++- .../terminal/terminal-session-controller.ts | 62 ++- .../src/terminal/terminal-worker-process.ts | 7 +- .../terminal/terminal-worker-protocol.test.ts | 10 +- .../src/terminal/terminal-worker-protocol.ts | 4 +- packages/server/src/terminal/terminal.ts | 3 + .../terminal/worker-terminal-manager.test.ts | 6 +- .../src/terminal/worker-terminal-manager.ts | 7 + .../sidebar-views/client/agents.tsx | 34 ++ .../sidebar-views/client/labels.tsx | 191 ++++++++ .../sidebar-views/client/nested.tsx | 109 +++++ plugin-examples/sidebar-views/client/rows.tsx | 139 ++++++ .../sidebar-views/client/terminals.tsx | 131 ++++++ .../sidebar-views/client/workspaces.tsx | 37 ++ .../sidebar-views/index.client.tsx | 18 + .../sidebar-views/paseo-plugin.json | 4 + .../sidebar-views/shared/preferences.ts | 8 + public-docs/plugins/migration.md | 1 + public-docs/plugins/reference.md | 21 +- public-docs/plugins/reference.zh-CN.md | 18 +- public-docs/plugins/sidebar-views.md | 177 ++++++++ public-docs/plugins/sidebar-views.zh-CN.md | 135 ++++++ 106 files changed, 4385 insertions(+), 269 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-views.spec.ts create mode 100644 packages/app/e2e/support/helpers/sidebar-views.ts create mode 100644 packages/app/src/plugins/client-state/api.ts create mode 100644 packages/app/src/plugins/client-state/connected-hosts.ts create mode 100644 packages/app/src/plugins/client-state/fixtures.ts create mode 100644 packages/app/src/plugins/client-state/refs.ts create mode 100644 packages/app/src/plugins/client-state/source-retention.test.ts create mode 100644 packages/app/src/plugins/client-state/source.browser.test.tsx create mode 100644 packages/app/src/plugins/client-state/terminals.ts create mode 100644 packages/app/src/plugins/settings/device.test.ts create mode 100644 packages/app/src/plugins/settings/device.ts create mode 100644 packages/app/src/plugins/sidebar-view/catalog.ts create mode 100644 packages/app/src/plugins/sidebar-view/frame.browser.test.tsx create mode 100644 packages/app/src/plugins/sidebar-view/frame.tsx create mode 100644 packages/app/src/plugins/sidebar-view/index.tsx create mode 100644 packages/app/src/plugins/sidebar-view/picker.tsx create mode 100644 packages/app/src/plugins/sidebar-view/selection.ts create mode 100644 packages/plugin/src/client/query.test.ts create mode 100644 packages/plugin/src/client/query.ts create mode 100644 packages/plugin/src/client/reference-api.ts create mode 100644 packages/plugin/src/client/refs.test.ts create mode 100644 packages/plugin/src/client/refs.ts create mode 100644 packages/plugin/src/refs.ts create mode 100644 plugin-examples/sidebar-views/client/agents.tsx create mode 100644 plugin-examples/sidebar-views/client/labels.tsx create mode 100644 plugin-examples/sidebar-views/client/nested.tsx create mode 100644 plugin-examples/sidebar-views/client/rows.tsx create mode 100644 plugin-examples/sidebar-views/client/terminals.tsx create mode 100644 plugin-examples/sidebar-views/client/workspaces.tsx create mode 100644 plugin-examples/sidebar-views/index.client.tsx create mode 100644 plugin-examples/sidebar-views/paseo-plugin.json create mode 100644 plugin-examples/sidebar-views/shared/preferences.ts create mode 100644 public-docs/plugins/sidebar-views.md create mode 100644 public-docs/plugins/sidebar-views.zh-CN.md diff --git a/docs/plugins.md b/docs/plugins.md index db6151f9de..2da369f4a5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource( When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. +## Replace sidebar content + +The sidebar view slot is global across hosts, while header and footer contributions retain their +host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns +its layout. This keeps plugin failures from removing navigation or recovery controls. The +[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API. + ## Contribute sidebar items Sidebar header and footer items are plugin components, not descriptors. The diff --git a/packages/app/e2e/browser/sidebar-views.spec.ts b/packages/app/e2e/browser/sidebar-views.spec.ts new file mode 100644 index 0000000000..665b682b53 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-views.spec.ts @@ -0,0 +1,18 @@ +import { test } from "../support/fixtures"; +import { withSidebarViews } from "../support/helpers/sidebar-views"; + +test("sidebar views replace the content, preserve chrome, and recover to the default", async ({ + page, +}) => { + test.setTimeout(150_000); + await withSidebarViews(page, async (views) => { + await views.expectDefault(); + await views.chooseWorkspaces(); + await views.exerciseExamples(); + await views.exerciseTerminals(); + await views.restoreDefault(); + await views.recoverFromFailure(); + await views.chooseWorkspaces(); + await views.disableChosenView(); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 63034d52a3..23696cf4ce 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "createWorkspace" + | "createTerminal" + | "killTerminal" | "fetchAgents" | "fetchWorkspaces" | "getPaseoWorktreeList" @@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick< | "removePlugin" | "reloadPlugin" | "setWorkspaceTitle" + | "setWorkspaceLabel" >; type CreateWorkspacePayload = Awaited>; diff --git a/packages/app/e2e/support/helpers/sidebar-views.ts b/packages/app/e2e/support/helpers/sidebar-views.ts new file mode 100644 index 0000000000..74952d3ff6 --- /dev/null +++ b/packages/app/e2e/support/helpers/sidebar-views.ts @@ -0,0 +1,191 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { copyPluginExample } from "./plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { seedWorkspace } from "./seed-client"; +import { gotoWorkspace } from "./launcher"; +import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings"; +import { visibleTestId } from "./plugin-sidebar-items"; +import { getServerId } from "./server-id"; + +export async function withSidebarViews( + page: Page, + run: (views: ReturnType) => Promise, +) { + const example = await copyPluginExample("sidebar-views"); + const entry = path.join(example.directory, "index.client.tsx"); + const original = await readFile(entry, "utf8"); + await writeFile( + entry, + original.replace( + "const cleanups = [", + 'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [', + ), + ); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const workspace = await seedWorkspace({ + repoPrefix: "sidebar-views-", + title: "Sidebar view workspace", + }); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await run(sidebarViews(page, workspace.workspaceId, client)); + } finally { + await client.removePlugin("sidebar-views"); + await client.close(); + await workspace.cleanup(); + await example.cleanup(); + } +} +function sidebarViews( + page: Page, + workspaceId: string, + client: Awaited>, +) { + const builtinRow = () => + visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`); + async function choose(title: string) { + await openSidebarNavSettings(page); + await page.getByRole("button", { name: /^View:/ }).click(); + await page.getByRole("menuitem", { name: title, exact: true }).click(); + await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible(); + await leaveSettings(page); + } + async function expectDefault() { + await expect(builtinRow()).toBeVisible(); + } + async function expectPlugin() { + await expect( + visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", { + exact: true, + }), + ).toBeVisible(); + await expect(builtinRow()).toHaveCount(0); + await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible(); + await expect(visibleTestId(page, "sidebar-settings")).toBeVisible(); + } + return { + expectDefault, + async chooseWorkspaces() { + await choose("Example workspaces"); + await expectPlugin(); + }, + async exerciseExamples() { + await choose("Example agents"); + await expect( + visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }), + ).toBeVisible(); + await choose("Example nested agents"); + await expectPlugin(); + await choose("Example label folders"); + const slot = visibleTestId(page, "plugin-sidebar-view"); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toContain("Inbox"); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Later", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Later"]); + await client.setWorkspaceLabel({ + workspaceId, + label: { name: "Unlabelled", color: "red" }, + assigned: true, + }); + await client.setWorkspaceLabel({ + workspaceId, + label: { name: "Later", color: "red" }, + assigned: false, + }); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Unlabelled"]); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Inbox"]); + }, + async exerciseTerminals() { + await choose("Example terminals"); + const slot = visibleTestId(page, "plugin-sidebar-view"); + await slot + .getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true }) + .click(); + await expect(slot.getByText("No terminals", { exact: true })).toBeVisible(); + const workspace = (await client.fetchWorkspaces()).entries.find( + (item) => item.id === workspaceId, + )!; + const created = await client.createTerminal( + workspace.workspaceDirectory, + "Sidebar terminal", + undefined, + { workspaceId }, + ); + if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed"); + try { + await expect( + slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`), + ).toBeVisible(); + await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click(); + await expect(page.getByTestId("terminal-surface").first()).toBeVisible(); + await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click(); + await expect + .poll(async () => (await client.listTerminals()).terminals.map((item) => item.id)) + .not.toContain(created.terminal!.id); + await expect(slot.getByText("No terminals", { exact: true })).toBeVisible(); + } finally { + await client.killTerminal(created.terminal.id); + } + }, + async restoreDefault() { + await choose("Default view"); + await expectDefault(); + }, + async recoverFromFailure() { + await choose("Broken example"); + await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Use default view", exact: true }).click(); + await expectDefault(); + }, + async disableChosenView() { + await client.disablePlugin("sidebar-views"); + await expectDefault(); + await openSidebarNavSettings(page); + await expect( + page.getByText("The selected view is unavailable or disabled. Showing Default view.", { + exact: true, + }), + ).toBeVisible(); + await leaveSettings(page); + }, + }; +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 1b2df08302..7504b175aa 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,3 +1,4 @@ +import { SidebarViewSlot } from "@/plugins/sidebar-view"; import { useAddHost } from "@/hosts/add-host"; import { router } from "expo-router"; import { @@ -642,31 +643,33 @@ function MobileSidebar({ )} - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} +
- {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 6599c28467..d6d9011259 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} subscriptionId: string; }>; unsubscribeCheckoutDiffCalls: string[]; - subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; - unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; + subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>; + unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>; } { const handlers: Record = { providers_snapshot_update: [], @@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} subscriptionId: string; }> = []; const unsubscribeCheckoutDiffCalls: string[] = []; - const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = []; - const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = []; + const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = []; + const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = []; function on( type: K, diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index e2b25d7b29..e80ee5f32a 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -41,8 +41,11 @@ export function useReplicaQuery< TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(input: ReplicaQueryInput): UseQueryResult { - return useQuery(replicaQueryOptions(input)); +>( + input: ReplicaQueryInput, + queryClient?: QueryClient, +): UseQueryResult { + return useQuery(replicaQueryOptions(input), queryClient); } export function useFetchQuery< diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9b3c41f663..4b93a54632 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1179,6 +1179,14 @@ export const ar: TranslationResources = { }, }, sidebar: { + view: { + failed: "تعذر تحميل عرض الشريط الجانبي.", + useDefault: "استخدام العرض الافتراضي", + readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.", + agentUnavailable: "لم يعد الوكيل متاحًا", + terminalUnavailable: "لم تعد الطرفية متاحة", + projectUnavailable: "لم يعد المشروع متاحًا", + }, statusGroupAccessibility: "مجموعة {{label}}", statusBucket: { needsInput: "تحتاج إدخالاً", @@ -2607,6 +2615,12 @@ export const ar: TranslationResources = { description: "عرض مخطط للتنقل بين المطالبات", }, sidebar: { + view: { + title: "عرض الشريط الجانبي", + label: "العرض", + default: "العرض الافتراضي", + unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.", + }, header: { title: "الرأس", description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ecb2890c93..9ad175d5b9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1187,6 +1187,14 @@ export const en = { }, }, sidebar: { + view: { + failed: "Sidebar view failed to load.", + useDefault: "Use default view", + readFailed: "Could not load your sidebar view preference.", + agentUnavailable: "Agent is no longer available", + terminalUnavailable: "Terminal is no longer available", + projectUnavailable: "Project is no longer available", + }, statusGroupAccessibility: "{{label}} group", statusBucket: { needsInput: "Needs input", @@ -2698,6 +2706,12 @@ export const en = { description: "Show an outline for jumping between prompts", }, sidebar: { + view: { + title: "Sidebar view", + label: "View", + default: "Default view", + unavailable: "The selected view is unavailable or disabled. Showing Default view.", + }, header: { title: "Header", description: "Choose which items appear at the top of the sidebar and in what order", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 11968541d4..38ba72c77f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1215,6 +1215,14 @@ export const es: TranslationResources = { }, }, sidebar: { + view: { + failed: "No se pudo cargar la vista de la barra lateral.", + useDefault: "Usar vista predeterminada", + readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.", + agentUnavailable: "El agente ya no está disponible", + terminalUnavailable: "La terminal ya no está disponible", + projectUnavailable: "El proyecto ya no está disponible", + }, statusGroupAccessibility: "Grupo {{label}}", statusBucket: { needsInput: "Necesita datos", @@ -2627,6 +2635,13 @@ export const es: TranslationResources = { description: "Muestra un esquema para saltar entre instrucciones", }, sidebar: { + view: { + title: "Vista de la barra lateral", + label: "Vista", + default: "Vista predeterminada", + unavailable: + "La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.", + }, header: { title: "Encabezado", description: diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4f31acf15a..01b438b7e3 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1208,6 +1208,14 @@ export const fr: TranslationResources = { }, }, sidebar: { + view: { + failed: "Impossible de charger la vue de la barre latérale.", + useDefault: "Utiliser la vue par défaut", + readFailed: "Impossible de charger votre préférence de vue de la barre latérale.", + agentUnavailable: "Cet agent n’est plus disponible", + terminalUnavailable: "Ce terminal n’est plus disponible", + projectUnavailable: "Ce projet n’est plus disponible", + }, statusGroupAccessibility: "Groupe {{label}}", statusBucket: { needsInput: "Attend une réponse", @@ -2628,6 +2636,13 @@ export const fr: TranslationResources = { description: "Afficher un plan pour naviguer entre les prompts", }, sidebar: { + view: { + title: "Vue de la barre latérale", + label: "Vue", + default: "Vue par défaut", + unavailable: + "La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.", + }, header: { title: "En-tête", description: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4913934cfd..52419d9851 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1192,6 +1192,14 @@ export const ja: TranslationResources = { }, }, sidebar: { + view: { + failed: "サイドバーのビューを読み込めませんでした。", + useDefault: "デフォルトのビューを使用", + readFailed: "サイドバーのビュー設定を読み込めませんでした。", + agentUnavailable: "このエージェントは利用できなくなりました", + terminalUnavailable: "このターミナルは利用できなくなりました", + projectUnavailable: "このプロジェクトは利用できなくなりました", + }, statusGroupAccessibility: "{{label}} グループ", statusBucket: { needsInput: "入力待ち", @@ -2594,6 +2602,13 @@ export const ja: TranslationResources = { description: "プロンプト間を移動するためのアウトラインを表示します", }, sidebar: { + view: { + title: "サイドバーのビュー", + label: "ビュー", + default: "デフォルトのビュー", + unavailable: + "選択したビューは利用できないか無効です。デフォルトのビューを表示しています。", + }, header: { title: "ヘッダー", description: "サイドバー上部に表示する項目とその順序を選択します", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 76c101c1d1..173dc82268 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1186,6 +1186,14 @@ export const ko: TranslationResources = { }, }, sidebar: { + view: { + failed: "사이드바 보기를 불러오지 못했습니다.", + useDefault: "기본 보기 사용", + readFailed: "사이드바 보기 설정을 불러오지 못했습니다.", + agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다", + terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다", + projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다", + }, statusGroupAccessibility: "{{label}} 그룹", statusBucket: { needsInput: "입력 필요", @@ -2585,6 +2593,13 @@ export const ko: TranslationResources = { description: "프롬프트 사이를 이동하기 위한 개요 표시", }, sidebar: { + view: { + title: "사이드바 보기", + label: "보기", + default: "기본 보기", + unavailable: + "선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.", + }, header: { title: "헤더", description: "사이드바 상단에 표시할 항목과 순서를 선택하세요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index cbd13dfd60..4f38145a6d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1205,6 +1205,14 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + view: { + failed: "Não foi possível carregar a visualização da barra lateral.", + useDefault: "Usar visualização padrão", + readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.", + agentUnavailable: "O agente não está mais disponível", + terminalUnavailable: "O terminal não está mais disponível", + projectUnavailable: "O projeto não está mais disponível", + }, statusGroupAccessibility: "Grupo {{label}}", statusBucket: { needsInput: "Precisa de resposta", @@ -2609,6 +2617,13 @@ export const ptBR: TranslationResources = { description: "Mostrar uma estrutura para navegar entre prompts", }, sidebar: { + view: { + title: "Visualização da barra lateral", + label: "Visualização", + default: "Visualização padrão", + unavailable: + "A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.", + }, header: { title: "Cabeçalho", description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ee788167f5..d06d2f3154 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1196,6 +1196,14 @@ export const ru: TranslationResources = { }, }, sidebar: { + view: { + failed: "Не удалось загрузить вид боковой панели.", + useDefault: "Использовать стандартный вид", + readFailed: "Не удалось загрузить настройку вида боковой панели.", + agentUnavailable: "Агент больше недоступен", + terminalUnavailable: "Терминал больше недоступен", + projectUnavailable: "Проект больше недоступен", + }, statusGroupAccessibility: "Группа «{{label}}»", statusBucket: { needsInput: "Ожидает ввода", @@ -2615,6 +2623,12 @@ export const ru: TranslationResources = { description: "Показывать оглавление для перехода между запросами", }, sidebar: { + view: { + title: "Вид боковой панели", + label: "Вид", + default: "Стандартный вид", + unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.", + }, header: { title: "Верх", description: diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ea9d34fd4f..cacc0ea57c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1171,6 +1171,14 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + view: { + failed: "无法加载侧边栏视图。", + useDefault: "使用默认视图", + readFailed: "无法加载侧边栏视图偏好设置。", + agentUnavailable: "此智能体已不可用", + terminalUnavailable: "此终端已不可用", + projectUnavailable: "此项目已不可用", + }, statusGroupAccessibility: "{{label}} 分组", statusBucket: { needsInput: "需要输入", @@ -2544,6 +2552,12 @@ export const zhCN: TranslationResources = { description: "显示用于在提示词之间跳转的大纲", }, sidebar: { + view: { + title: "侧边栏视图", + label: "视图", + default: "默认视图", + unavailable: "所选视图不可用或已禁用。正在显示默认视图。", + }, header: { title: "顶部", description: "选择侧边栏顶部显示的项目及其顺序", diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index b61eae7c1d..257d198d24 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { PluginAgentCommandContext, @@ -32,7 +33,7 @@ export function createPluginCapabilities( navigation.openSurface(plugin.id, screenId, params); } return { - paseo: plugin.paseo, + paseo: getPluginClientApi(plugin).paseo, rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input), openSettings(screenId) { if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index e8f604bb50..6c2bbe91af 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -23,6 +23,7 @@ function installation(): InstalledPlugin { cleanup: () => undefined, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 55cae64e5d..7d9ad09e41 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import appPackage from "../../package.json"; import { afterEach, expect, it } from "vitest"; import { QueryObserver } from "@tanstack/react-query"; @@ -139,7 +140,7 @@ function harness() { }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected runtime RPC"); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index d9439ff8ad..184d24a8f6 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,6 +1,5 @@ import { createPlayAudio, type AudioEngine } from "@/audio"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { createPluginHosts } from "./hosts"; +import { getPluginClientApi } from "./client-state/api"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { subscribePluginRpc } from "@getpaseo/plugin/client/host"; import { @@ -27,7 +26,7 @@ export function createPluginClientRuntime( ...capabilities, i18n: installation.i18n, playAudio: createPlayAudio(audio, installation.lifetime.signal), - hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), + hosts: getPluginClientApi(installation).hosts, addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); }, diff --git a/packages/app/src/plugins/client-state/api.ts b/packages/app/src/plugins/client-state/api.ts new file mode 100644 index 0000000000..4ce3425f9a --- /dev/null +++ b/packages/app/src/plugins/client-state/api.ts @@ -0,0 +1,68 @@ +import { createPluginReferenceApi } from "@getpaseo/plugin/client/host"; +import { router } from "expo-router"; +import { i18n } from "@/i18n/i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import { createPluginHosts } from "../hosts"; +import type { InstalledPlugin } from "../types"; +import { createPluginClientStateSource } from "./source"; + +interface InstallationApi { + paseo: ReturnType; + hosts: ReturnType; +} +// Registry presentation clones share the installation lifetime and its client pool. +const apis = new WeakMap(); + +export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi { + const cached = apis.get(plugin.lifetime.signal); + if (cached) return cached; + const state = createPluginClientStateSource(); + const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal); + const paseo = createPluginReferenceApi({ + paseo: plugin.paseo, + resolveHost: hosts.getPaseoClient, + readProject: state.getProject, + navigation: { + openWorkspace(ref) { + navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id }); + }, + openAgent(ref) { + const agent = state.getAgent(ref); + if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable")); + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: agent.workspace.id, + target: { kind: "agent", agentId: ref.id }, + }); + }, + openTerminal(ref) { + const terminal = state.getTerminal(ref); + if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable")); + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: terminal.workspace.id, + target: { kind: "terminal", terminalId: ref.id }, + }); + }, + openProject(ref) { + const project = state.getProject(ref); + if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable")); + usePanelStore.getState().showMobileAgent(); + router.push( + buildNewWorkspaceRoute({ + serverId: ref.hostId, + projectId: ref.id, + sourceDirectory: project.rootPath, + displayName: project.displayName, + }), + ); + }, + }, + }); + const api = { paseo, hosts }; + apis.set(plugin.lifetime.signal, api); + return api; +} diff --git a/packages/app/src/plugins/client-state/connected-hosts.ts b/packages/app/src/plugins/client-state/connected-hosts.ts new file mode 100644 index 0000000000..4dfffa6b8d --- /dev/null +++ b/packages/app/src/plugins/client-state/connected-hosts.ts @@ -0,0 +1,72 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import type { PluginConnectedHostsSource } from "./source"; + +interface PluginHostsRuntime { + getHosts(): readonly { serverId: string }[]; + getSnapshot(serverId: string): { connectionStatus: string } | null | undefined; + subscribeAll(listener: () => void): () => void; + subscribeHostList(listener: () => void): () => void; + acquireDirectoryDemand(serverId: string): () => void; +} + +export function createConnectedPluginHosts( + getRuntime: () => PluginHostsRuntime, +): PluginConnectedHostsSource { + let snapshot: readonly string[] = []; + const listeners = new Set<() => void>(); + let release: (() => void) | null = null; + + const source: PluginConnectedHostsSource = { + getSnapshot() { + const runtime = getRuntime(); + const next = runtime + .getHosts() + .filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online") + .map((host) => host.serverId); + if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index])) + snapshot = Object.freeze(next); + return snapshot; + }, + subscribe(listener) { + listeners.add(listener); + if (!release) { + const runtime = getRuntime(); + const demands = new Map void>(); + const reconcile = () => { + const ids = new Set(source.getSnapshot()); + for (const [id, releaseDemand] of demands) { + if (!ids.has(id)) { + demands.delete(id); + releaseDemand(); + } + } + for (const id of ids) { + if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id)); + } + }; + const notify = () => { + reconcile(); + for (const subscriber of listeners) subscriber(); + }; + const releaseRuntime = runtime.subscribeAll(notify); + const releaseHosts = runtime.subscribeHostList(notify); + reconcile(); + release = () => { + releaseRuntime(); + releaseHosts(); + for (const releaseDemand of demands.values()) releaseDemand(); + demands.clear(); + }; + } + return () => { + listeners.delete(listener); + if (listeners.size === 0 && release) { + release(); + release = null; + } + }; + }, + }; + return source; +} +export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore); diff --git a/packages/app/src/plugins/client-state/fixtures.ts b/packages/app/src/plugins/client-state/fixtures.ts new file mode 100644 index 0000000000..4439e385f4 --- /dev/null +++ b/packages/app/src/plugins/client-state/fixtures.ts @@ -0,0 +1,53 @@ +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +export const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + workspaceDirectory: "/repo/paseo/review", + projectKind: "git", + workspaceKind: "worktree", + name: "Review", + title: "Plugin review", + pinnedAt: null, + status: "running", + statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"), + archivingAt: null, + diffStat: { additions: 12, deletions: 3 }, + scripts: [], +}; + +export const agent: Agent = { + serverId: "host-1", + id: "agent-1", + provider: "codex", + status: "running", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date("2026-08-16T10:01:00.000Z"), + updatedAt: new Date("2026-08-16T10:02:00.000Z"), + lastUserMessageAt: null, + lastActivityAt: new Date("2026-08-16T10:03:00.000Z"), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "code", + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Implement plugin panels", + cwd: "/repo/paseo/review", + workspaceId: "workspace-1", + model: "gpt-5.6", + thinkingOptionId: "high", + requiresAttention: true, + attentionReason: "permission", + parentAgentId: null, + labels: { phase: "implementation" }, +}; diff --git a/packages/app/src/plugins/client-state/refs.ts b/packages/app/src/plugins/client-state/refs.ts new file mode 100644 index 0000000000..edfdd9562c --- /dev/null +++ b/packages/app/src/plugins/client-state/refs.ts @@ -0,0 +1,3 @@ +import { createEntityRefs } from "@getpaseo/plugin/client/host"; + +export const entityRefs = createEntityRefs(); diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 0c90185e53..7d63b178ed 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -1,66 +1,20 @@ +import { i18n } from "@/i18n/i18next"; +import { fr } from "@/i18n/resources/fr"; import { describe, expect, it } from "vitest"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; -import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; - -const workspace: WorkspaceDescriptor = { - createdAt: null, - lastUserMessageAt: null, - id: "workspace-1", - projectId: "project-1", - projectDisplayName: "Paseo", - projectRootPath: "/repo/paseo", - workspaceDirectory: "/repo/paseo/review", - projectKind: "git", - workspaceKind: "worktree", - name: "Review", - title: "Plugin review", - pinnedAt: null, - status: "running", - statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"), - archivingAt: null, - diffStat: { additions: 12, deletions: 3 }, - scripts: [], -}; - -const agent: Agent = { - serverId: "host-1", - id: "agent-1", - provider: "codex", - status: "running", - turn: { phase: "idle", cancellationRequestId: null }, - createdAt: new Date("2026-08-16T10:01:00.000Z"), - updatedAt: new Date("2026-08-16T10:02:00.000Z"), - lastUserMessageAt: null, - lastActivityAt: new Date("2026-08-16T10:03:00.000Z"), - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: "code", - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Implement plugin panels", - cwd: "/repo/paseo/review", - workspaceId: "workspace-1", - model: "gpt-5.6", - thinkingOptionId: "high", - requiresAttention: true, - attentionReason: "permission", - parentAgentId: null, - labels: { phase: "implementation" }, -}; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + createPluginAgentSnapshot, + createPluginWorkspaceSnapshot, + createPluginProjectSnapshot, +} from "./snapshots"; +import { workspace, agent } from "./fixtures"; describe("plugin context snapshots", () => { it("commits a deeply immutable normalized workspace snapshot synchronously", () => { - const snapshot = createPluginWorkspaceSnapshot(workspace); - expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot); + const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1"); + expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot); - expect(snapshot).toEqual({ + expect(snapshot).toMatchObject({ id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", @@ -90,7 +44,7 @@ describe("plugin context snapshots", () => { const snapshot = createPluginAgentSnapshot(agent, workspace.id); expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot); - expect(snapshot).toEqual({ + expect(snapshot).toMatchObject({ id: "agent-1", workspaceId: "workspace-1", provider: "codex", @@ -119,3 +73,82 @@ describe("plugin context snapshots", () => { expect(agent.labels).toEqual({ phase: "implementation" }); }); }); + +describe("sidebar entity snapshots", () => { + it("interns cross-host references and preserves identity across unrelated reads", () => { + const first = createPluginWorkspaceSnapshot(workspace, "host-1"); + const second = createPluginWorkspaceSnapshot(workspace, "host-2"); + expect(first.ref).not.toBe(second.ref); + expect(first.ref.hostId).toBe("host-1"); + expect(first.project.id).toBe(workspace.projectId); + expect(Object.isFrozen(first.labels)).toBe(true); + expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first); + expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref); + }); + + it("invalidates a done workspace when its effective root-agent status changes", () => { + const idle: WorkspaceDescriptor = { ...workspace, status: "done" }; + const before = createPluginWorkspaceSnapshot(idle, "host-1"); + const activity = new Map([ + [idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }], + ]); + const after = createPluginWorkspaceSnapshot(idle, "host-1", { + workspaceAgentActivity: activity, + }); + expect(before.indicator).toBe("done"); + expect(after.status).toBe("done"); + expect(after.indicator).toBe("needs_input"); + expect(after.indicatorLabel).toBe("Needs input"); + expect(after.ref).toBe(before.ref); + expect( + createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }), + ).toBe(after); + }); + + it("reads projects without requiring a workspace", () => { + const project = { + projectId: "empty", + projectDisplayName: "Empty", + projectCustomName: null, + projectRootPath: "/empty", + projectKind: "git" as const, + }; + const snapshot = createPluginProjectSnapshot(project, "host-1"); + expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot); + expect(snapshot.displayName).toBe("Empty"); + expect(snapshot.ref.id).toBe("empty"); + expect(Object.isFrozen(snapshot)).toBe(true); + }); +}); + +it("uses the app status labels and invalidates a snapshot on language change", async () => { + i18n.addResourceBundle("fr", "translation", fr); + const previousLanguage = i18n.language; + try { + await i18n.changeLanguage("en"); + const english = createPluginWorkspaceSnapshot(workspace, "host-1"); + expect(english.indicatorLabel).toBe("Working"); + await i18n.changeLanguage("fr"); + const french = createPluginWorkspaceSnapshot(workspace, "host-1"); + expect(french).not.toBe(english); + expect(french.ref).toBe(english.ref); + expect(french.indicatorLabel).toBe("En cours"); + expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french); + } finally { + await i18n.changeLanguage(previousLanguage); + } +}); + +it("preserves the host-qualified parent of a remotely delegated agent", () => { + const child = { + ...agent, + serverId: "host-2", + parentAgentId: null, + labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" }, + }; + expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({ + hostId: "host-1", + id: "parent", + kind: "agent", + }); +}); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index 67a08830cd..ac810cf8f0 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -1,16 +1,57 @@ -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; +import { i18n } from "@/i18n/i18next"; +import type { + PluginAgentSnapshot, + PluginWorkspaceSnapshot, + PluginProjectSnapshot, +} from "@getpaseo/plugin"; +import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store"; -const workspaceSnapshots = new WeakMap(); +import { entityRefs } from "./refs"; +import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; + +interface CachedWorkspace { + language: string; + snapshot: PluginWorkspaceSnapshot; +} +const workspaceSnapshots = new WeakMap>(); const agentSnapshots = new WeakMap>(); export function createPluginWorkspaceSnapshot( workspace: WorkspaceDescriptor, + hostId: string, + context: Omit[0], "workspace" | "serverId"> & { + parentAgent?: PluginWorkspaceSnapshot["parentAgent"]; + } = {}, ): PluginWorkspaceSnapshot { - const cached = workspaceSnapshots.get(workspace); - if (cached) return cached; + const indicator = deriveEffectiveWorkspaceStatus({ + ...context, + workspace, + serverId: hostId, + }).status; + const parentAgent = context.parentAgent ?? null; + const cached = workspaceSnapshots.get(workspace)?.get(hostId); + if ( + cached && + cached.language === i18n.language && + cached.snapshot.indicator === indicator && + cached.snapshot.parentAgent === parentAgent + ) + return cached.snapshot; const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null; const snapshot = Object.freeze({ + ref: entityRefs.workspace(hostId, workspace.id), + hostId, + project: entityRefs.project(hostId, workspace.projectId), + pinnedAt: workspace.pinnedAt ?? null, + labels: Object.freeze([...(workspace.labels ?? [])]), + background: workspace.background ?? false, + branch: workspace.gitRuntime?.currentBranch ?? null, + createdAt: serializeTimestamp(workspace.createdAt), + lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt), + parentAgent, + indicator, + indicatorLabel: i18n.t(indicatorLabelKeys[indicator]), id: workspace.id, projectId: workspace.projectId, projectDisplayName: workspace.projectDisplayName, @@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot( name: workspace.name, title: workspace.title ?? null, status: workspace.status, - statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null, + statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt), archivingAt: workspace.archivingAt, diffStat, }); - workspaceSnapshots.set(workspace, snapshot); + const byHost = workspaceSnapshots.get(workspace) ?? new Map(); + byHost.set(hostId, { language: i18n.language, snapshot }); + workspaceSnapshots.set(workspace, byHost); return snapshot; } export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot { const cached = agentSnapshots.get(agent)?.get(workspaceId); if (cached) return cached; + const parent = getParentAddressFromLabels(agent.labels, agent.serverId); const snapshot = Object.freeze({ + ref: entityRefs.agent(agent.serverId, agent.id), + hostId: agent.serverId, + workspace: entityRefs.workspace(agent.serverId, workspaceId), + parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null, id: agent.id, workspaceId, provider: agent.provider, @@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl agentSnapshots.set(agent, byWorkspace); return snapshot; } + +const indicatorLabelKeys = { + needs_input: "sidebar.statusBucket.needsInput", + failed: "sidebar.statusBucket.failed", + running: "sidebar.statusBucket.working", + attention: "sidebar.statusBucket.readyToReview", + done: "sidebar.statusBucket.done", +} as const; +const projectSnapshots = new WeakMap>(); + +export function createPluginProjectSnapshot( + project: ProjectDescriptor, + hostId: string, +): PluginProjectSnapshot { + const cached = projectSnapshots.get(project)?.get(hostId); + if (cached) return cached; + const snapshot = Object.freeze({ + ref: entityRefs.project(hostId, project.projectId), + hostId, + displayName: project.projectCustomName ?? project.projectDisplayName, + rootPath: project.projectRootPath, + kind: project.projectKind, + iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null, + }); + const byHost = projectSnapshots.get(project) ?? new Map(); + byHost.set(hostId, snapshot); + projectSnapshots.set(project, byHost); + return snapshot; +} + +function serializeTimestamp(value: Date | null): string | null { + return value ? value.toISOString() : null; +} diff --git a/packages/app/src/plugins/client-state/source-retention.test.ts b/packages/app/src/plugins/client-state/source-retention.test.ts new file mode 100644 index 0000000000..b11fc5d731 --- /dev/null +++ b/packages/app/src/plugins/client-state/source-retention.test.ts @@ -0,0 +1,55 @@ +import { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { setImmediate } from "node:timers/promises"; +import { expect, it } from "vitest"; +import { useSessionStore } from "@/stores/session-store"; +import { createPluginClientStateSource } from "./source"; +import { workspace } from "./fixtures"; + +setFlagsFromString("--expose_gc"); +const collectGarbage: () => void = runInNewContext("gc"); +setFlagsFromString("--no-expose_gc"); + +function observeHost() { + const hostId = "removed-plugin-host"; + let hosts = [hostId]; + const store = useSessionStore.getState(); + store.initializeSession(hostId, null); + store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]])); + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => hosts, subscribe: () => () => {} }, + }); + const release = source.subscribe(() => source.getSnapshot()); + source.getSnapshot(); + const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces); + const remove = () => { + hosts = []; + store.clearSession(hostId); + }; + return { reference, release, remove }; +} + +it("releases removed hosts' projected data while other plugin readers remain", async () => { + const { reference, release, remove } = observeHost(); + remove(); + try { + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + collectGarbage(); + } + expect(reference.deref()).toBeUndefined(); + } finally { + release(); + } +}); + +it("releases previous projections when the last plugin reader leaves", async () => { + const { reference, release, remove } = observeHost(); + release(); + remove(); + for (let attempt = 0; attempt < 3; attempt++) { + await setImmediate(); + collectGarbage(); + } + expect(reference.deref()).toBeUndefined(); +}); diff --git a/packages/app/src/plugins/client-state/source.browser.test.tsx b/packages/app/src/plugins/client-state/source.browser.test.tsx new file mode 100644 index 0000000000..46e31c7cab --- /dev/null +++ b/packages/app/src/plugins/client-state/source.browser.test.tsx @@ -0,0 +1,420 @@ +import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested"; +import { lightTheme } from "@/styles/theme"; +import { toPluginTheme } from "../theme"; +import { createConnectedPluginHosts } from "./connected-hosts"; +import { i18n } from "@/i18n/i18next"; +import { fr } from "@/i18n/resources/fr"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import { + useAgents, + useWorkspaces, + useWorkspace, + useAgent, + type WorkspaceRef, + type AgentRef, +} from "@getpaseo/plugin/client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source"; +import { agent, workspace } from "./fixtures"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; +const renders = new Map(); +function count(key: string) { + renders.set(key, (renders.get(key) ?? 0) + 1); +} +function WorkspaceRow({ reference }: { reference: WorkspaceRef }) { + const item = useWorkspace(reference); + count(reference.key); + return ( +
+ {item?.name}:{item?.indicator} +
+ ); +} +function Workspaces() { + count("workspaces"); + const items = useWorkspaces({ orderBy: "name" }); + return ( +
+ {items.map((reference) => ( + + ))} +
+ ); +} +function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) { + const name = useWorkspace(reference, (item) => ({ name: item.name })); + const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name); + count("selected"); + return ( +
+ {name?.name}:{custom?.name} +
+ ); +} +function AgentRow({ reference }: { reference: AgentRef }) { + const item = useAgent(reference); + return
{item?.title}
; +} +function AgentSection({ section }: { section: string }) { + const agents = useAgents({ + where: (item) => (item.requiresAttention ? "waiting" : item.status) === section, + }); + count(section); + return ( +
+ {agents.map((reference) => ( + + ))} +
+ ); +} +function Agents() { + return ( +
+ + + +
+ ); +} +function seedHost(hostId: string) { + const store = useSessionStore.getState(); + store.initializeSession(hostId, null); + store.setWorkspaces( + hostId, + new Map([ + ["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }], + ["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }], + ]), + ); + store.setProjects(hostId, [ + { + projectId: "empty", + projectDisplayName: "Empty project", + projectCustomName: null, + projectRootPath: "/empty", + projectKind: "git", + }, + ]); + store.setAgents( + hostId, + new Map([ + [ + "agent-1", + { + ...agent, + serverId: hostId, + id: "agent-1", + workspaceId: "workspace-1", + status: "running", + requiresAttention: false, + attentionReason: null, + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, + ], + [ + "agent-2", + { + ...agent, + serverId: hostId, + id: "agent-2", + workspaceId: "workspace-2", + status: "running", + requiresAttention: false, + attentionReason: null, + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, + ], + ]), + ); +} +it("keeps archived agents readable individually while excluding them from collections", () => { + seedHost("host-1"); + const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() }); + const reference = source.getSnapshot().agents[0]!.ref; + useSessionStore.getState().setAgents( + "host-1", + new Map([ + [ + agent.id, + { + ...agent, + archivedAt: new Date("2026-11-01T00:00:00.000Z"), + }, + ], + ]), + ); + expect(source.getSnapshot().agents).toEqual([]); + expect(source.getAgent(reference)?.title).toBe(agent.title); + expect(source.getAgent(agent.id)).toBe(source.getAgent(reference)); +}); + +it("demands directories for connected hosts and releases them with its last reader", () => { + const hostId = "plugin-demand-host"; + let online = true; + let demands = 0; + const listeners = new Set<() => void>(); + const hosts = createConnectedPluginHosts(() => ({ + getHosts: () => [{ serverId: hostId }], + getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }), + subscribeAll: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + subscribeHostList: () => () => {}, + acquireDirectoryDemand: () => { + demands++; + seedHost(hostId); + return () => { + demands--; + }; + }, + })); + const source = createPluginClientStateSource(undefined, { hosts }); + const first = source.subscribe(() => {}); + const second = source.subscribe(() => {}); + expect(demands).toBe(1); + expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]); + useSessionStore + .getState() + .setWorkspaces( + hostId, + (previous) => + new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])), + ); + expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([ + "Live update", + "Live update", + ]); + online = false; + for (const listener of listeners) listener(); + expect(demands).toBe(0); + expect(source.getSnapshot().workspaces).toEqual([]); + online = true; + for (const listener of listeners) listener(); + expect(demands).toBe(1); + first(); + expect(demands).toBe(1); + second(); + expect(demands).toBe(0); + expect(listeners.size).toBe(0); +}); + +function waitForInput() { + useSessionStore.getState().setAgents("host-1", (previous) => { + const current = previous.get("agent-1"); + if (!current) throw new Error("Missing test agent"); + const next = new Map(previous); + next.set(current.id, { + ...current, + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + requiresAttention: true, + attentionReason: "permission", + }); + return next; + }); +} +class ConnectedHosts implements PluginConnectedHostsSource { + private ids: readonly string[] = ["host-1"]; + private listeners = new Set<() => void>(); + getSnapshot = () => this.ids; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + connect(id: string) { + this.ids = [...this.ids, id]; + for (const listener of this.listeners) listener(); + } +} +function mount(children: React.ReactNode, hosts = new ConnectedHosts()) { + const source = createPluginClientStateSource("host-1", { hosts }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render({children}), + ); + return source; +} +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + useSessionStore.getState().clearSession("host-1"); + useSessionStore.getState().clearSession("host-2"); + renders.clear(); +}); + +it("shows a restored child in the nested example while its cached parent remains archived", () => { + seedHost("host-1"); + const parent = { + ...agent, + id: "archived-parent", + title: "Archived parent", + archivedAt: new Date("2026-11-01T00:00:00Z"), + }; + const child = { + ...agent, + id: "restored-child", + title: "Restored child", + parentAgentId: parent.id, + labels: { "paseo.parent-agent-id": parent.id }, + archivedAt: parent.archivedAt, + }; + const store = useSessionStore.getState(); + store.setProjects("host-1", [ + { + projectId: workspace.projectId, + projectDisplayName: "Project", + projectCustomName: null, + projectRootPath: workspace.projectRootPath, + projectKind: "git", + }, + ]); + store.setAgents( + "host-1", + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const view = { + theme: toPluginTheme(lightTheme), + layout: { compact: false, platform: "web" as const }, + active: { workspace: null, agent: null }, + navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} }, + dismiss() {}, + }; + const source = mount(); + expect(source.getAgent(parent.id)?.title).toBe(parent.title); + expect(container?.textContent).not.toContain(child.title); + act(() => + store.setAgents( + "host-1", + new Map([ + [parent.id, parent], + [child.id, { ...child, archivedAt: null }], + ]), + ), + ); + expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull(); + expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull(); +}); + +describe("sidebar data hooks through the app store and plugin provider", () => { + it("renders only one workspace row when its root agent starts waiting", () => { + seedHost("host-1"); + const source = mount(); + const first = source.getSnapshot().workspaces[0]!; + const second = source.getSnapshot().workspaces[1]!; + const before = source.getSnapshot(); + act(waitForInput); + expect(renders.get("workspaces")).toBe(1); + expect(renders.get(first.ref.key)).toBe(2); + expect(renders.get(second.ref.key)).toBe(1); + expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input"); + expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]); + }); + it("moves the agent and renders only its two affected sections without memo", () => { + seedHost("host-1"); + mount(); + act(waitForInput); + expect(renders.get("running")).toBe(2); + expect(renders.get("waiting")).toBe(2); + expect(renders.get("idle")).toBe(1); + }); + it("uses shallow selected equality and custom equality without a selector", () => { + seedHost("host-1"); + const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() }); + mount(); + act(waitForInput); + expect(renders.get("selected")).toBe(1); + }); + it("refreshes translated labels through the subscribed source when language changes", async () => { + seedHost("host-1"); + i18n.addResourceBundle("fr", "translation", fr); + const source = mount(); + const before = source.getSnapshot().workspaces[0]!; + const previousLanguage = i18n.language; + try { + await act(async () => { + await i18n.changeLanguage("fr"); + }); + const after = source.getSnapshot().workspaces[0]!; + expect(after).not.toBe(before); + expect(after.indicatorLabel).toBe("En cours"); + expect(after.ref).toBe(before.ref); + expect(renders.get(before.ref.key)).toBe(2); + expect(renders.get("workspaces")).toBe(1); + } finally { + await act(async () => { + await i18n.changeLanguage(previousLanguage); + }); + } + }); + it("adds a newly connected host and exposes projects with no workspaces", () => { + seedHost("host-1"); + seedHost("host-2"); + const hosts = new ConnectedHosts(); + const source = mount(, hosts); + expect(source.getSnapshot().workspaces).toHaveLength(2); + expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project"); + act(() => hosts.connect("host-2")); + expect(source.getSnapshot().workspaces).toHaveLength(4); + expect(source.getSnapshot().projects).toHaveLength(2); + expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4); + }); +}); + +it("observes terminals only while a terminal reader is subscribed", () => { + let active = 0; + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => [], subscribe: () => () => {} }, + terminals: { + getSnapshot: () => [], + subscribe: () => { + active++; + return () => { + active--; + }; + }, + }, + }); + const releaseWorkspace = source.subscribe(() => {}); + expect(active).toBe(0); + const releaseTerminal = source.subscribe(() => {}, { terminals: true }); + const releaseSecond = source.subscribe(() => {}, { terminals: true }); + expect(active).toBe(1); + releaseTerminal(); + expect(active).toBe(1); + releaseSecond(); + expect(active).toBe(0); + releaseWorkspace(); +}); + +it("terminal row reads propagate only the referenced host's observation error", () => { + const error = new Error("Update the host"); + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => [], subscribe: () => () => {} }, + terminals: { + getSnapshot: () => [], + subscribe: () => () => {}, + getErrors: () => new Map([["unsupported", error]]), + }, + }); + const refs = createEntityRefs(); + expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error); + expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull(); +}); diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index 142c1f3575..137c6624cb 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -1,21 +1,248 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; -import { useSessionStore } from "@/stores/session-store"; -import { selectWorkspace } from "@/stores/session-store-hooks/selectors"; +import { i18n } from "@/i18n/i18next"; +import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host"; +import type { PluginTerminalSnapshot } from "@getpaseo/plugin"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; -import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; +import { connectedPluginHosts } from "./connected-hosts"; +import { pluginTerminals } from "./terminals"; +import { entityRefs } from "./refs"; +import { + createPluginAgentSnapshot, + createPluginProjectSnapshot, + createPluginWorkspaceSnapshot, +} from "./snapshots"; -export function createPluginClientStateSource(serverId: string): PluginClientStateSource { - return { - subscribe: useSessionStore.subscribe, - getWorkspace(workspaceId) { - const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId); - return workspace ? createPluginWorkspaceSnapshot(workspace) : null; +export interface PluginConnectedHostsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly string[]; +} + +export interface PluginTerminalsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly PluginTerminalSnapshot[]; + getErrors?(): ReadonlyMap; +} + +const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]); +const listeners = new Set<() => void>(); +let releaseStores: (() => void) | null = null; + +// Every plugin host shares one subscription to each app store. +function subscribeStores(listener: () => void): () => void { + if (!releaseStores) { + const notify = () => { + for (const subscriber of listeners) subscriber(); + }; + i18n.on("languageChanged", notify); + const releaseSession = useSessionStore.subscribe((state) => { + for (const hostId of hostProjections.keys()) { + if (!state.sessions[hostId]) hostProjections.delete(hostId); + } + notify(); + }); + const releaseCreate = useCreateFlowStore.subscribe(notify); + releaseStores = () => { + i18n.off("languageChanged", notify); + releaseSession(); + releaseCreate(); + }; + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && releaseStores) { + releaseStores(); + releaseStores = null; + hostProjections.clear(); + } + }; +} + +interface HostProjection { + language: string; + workspaces: SessionState["workspaces"]; + agents: SessionState["agents"]; + projects: SessionState["projects"]; + activity: SessionState["workspaceAgentActivity"]; + pending: ReturnType["pendingByDraftId"]; + snapshot: Omit; + workspaceById: Map; + agentById: Map; + projectById: Map; +} +const hostProjections = new Map(); + +function projectHost(hostId: string): HostProjection | null { + const session = useSessionStore.getState().sessions[hostId]; + if (!session) { + hostProjections.delete(hostId); + return null; + } + const pending = useCreateFlowStore.getState().pendingByDraftId; + const previous = hostProjections.get(hostId); + const unchanged = + previous && + previous.language === i18n.language && + previous.workspaces === session.workspaces && + previous.agents === session.agents && + previous.projects === session.projects && + previous.activity === session.workspaceAgentActivity && + previous.pending === pending; + if (unchanged) return previous; + const workspaces = Array.from(session.workspaces.values(), (workspace) => { + const activity = session.workspaceAgentActivity.get(workspace.id); + const root = activity ? session.agents.get(activity.agentId) : undefined; + return createPluginWorkspaceSnapshot(workspace, hostId, { + pendingCreateAttempts: pending, + workspaceAgentActivity: session.workspaceAgentActivity, + parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null, + }); + }); + const agents: PluginClientSnapshot["agents"][number][] = []; + for (const agent of session.agents.values()) { + const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (!workspaceId || agent.archivedAt) continue; + agents.push(createPluginAgentSnapshot(agent, workspaceId)); + } + const projects = Array.from(session.projects.values(), (project) => + createPluginProjectSnapshot(project, hostId), + ); + const projection: HostProjection = { + language: i18n.language, + workspaces: session.workspaces, + agents: session.agents, + projects: session.projects, + activity: session.workspaceAgentActivity, + pending, + snapshot: { + workspaces: Object.freeze(workspaces), + agents: Object.freeze(agents), + projects: Object.freeze(projects), }, - getAgent(agentId) { - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; - const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId); - return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null; + workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])), + agentById: new Map(agents.map((agent) => [agent.id, agent])), + projectById: new Map(projects.map((project) => [project.ref.id, project])), + }; + hostProjections.set(hostId, projection); + return projection; +} + +function sameSequence(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */ +export function createPluginClientStateSource( + serverId?: string, + options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {}, +): PluginClientStateSource { + const terminals = options.terminals ?? pluginTerminals; + const hosts = options.hosts ?? connectedPluginHosts; + const subscribers = new Set<() => void>(); + const terminalSubscribers = new Set<() => void>(); + let releaseTerminals: (() => void) | null = null; + let release: (() => void) | null = null; + let previousProjections: readonly HostProjection[] = []; + let previousTerminals = emptyTerminals; + let previousTerminalErrors: ReadonlyMap | undefined; + let snapshot: PluginClientSnapshot = { + workspaces: [], + agents: [], + projects: [], + terminals: emptyTerminals, + }; + function getSnapshot(): PluginClientSnapshot { + const ids = hosts.getSnapshot(); + const projections: HostProjection[] = []; + for (const id of ids) { + const projection = projectHost(id); + if (projection) projections.push(projection); + } + const terminalSnapshots = terminals.getSnapshot(); + const terminalErrors = terminals.getErrors?.(); + if ( + sameSequence(previousProjections, projections) && + previousTerminals === terminalSnapshots && + previousTerminalErrors === terminalErrors + ) + return snapshot; + previousProjections = projections; + previousTerminals = terminalSnapshots; + previousTerminalErrors = terminalErrors; + snapshot = Object.freeze({ + workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)), + agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)), + projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)), + terminals: terminalSnapshots, + terminalErrors, + }); + return snapshot; + } + return { + subscribe(listener, subscriptionOptions) { + subscribers.add(listener); + if (subscriptionOptions?.terminals) { + terminalSubscribers.add(listener); + releaseTerminals ??= terminals.subscribe(() => { + for (const subscriber of terminalSubscribers) subscriber(); + }); + } + if (!release) { + const notify = () => { + for (const subscriber of subscribers) subscriber(); + }; + const releaseStore = subscribeStores(notify); + const releaseHosts = hosts.subscribe(notify); + release = () => { + releaseStore(); + releaseHosts(); + }; + } + return () => { + subscribers.delete(listener); + terminalSubscribers.delete(listener); + if (terminalSubscribers.size === 0 && releaseTerminals) { + releaseTerminals(); + releaseTerminals = null; + } + if (subscribers.size === 0 && release) { + release(); + release = null; + previousProjections = []; + previousTerminals = emptyTerminals; + previousTerminalErrors = undefined; + snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals }; + } + }; + }, + getSnapshot, + getWorkspace(reference) { + const ref = + typeof reference === "string" && serverId + ? entityRefs.workspace(serverId, reference) + : reference; + if (typeof ref === "string") return null; + return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null; + }, + getAgent(reference) { + const ref = + typeof reference === "string" && serverId + ? entityRefs.agent(serverId, reference) + : reference; + if (typeof ref === "string") return null; + const projected = projectHost(ref.hostId)?.agentById.get(ref.id); + if (projected) return projected; + const session = useSessionStore.getState().sessions[ref.hostId]; + const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id); + const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId); + return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null; + }, + getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null, + getTerminal(ref) { + const error = terminals.getErrors?.().get(ref.hostId); + if (error) throw error; + return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null; }, }; } diff --git a/packages/app/src/plugins/client-state/terminals.ts b/packages/app/src/plugins/client-state/terminals.ts new file mode 100644 index 0000000000..6b2630d7e7 --- /dev/null +++ b/packages/app/src/plugins/client-state/terminals.ts @@ -0,0 +1,154 @@ +import type { PluginTerminalSnapshot } from "@getpaseo/plugin"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { TerminalsChanged } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { entityRefs } from "./refs"; +import type { PluginTerminalsSource } from "./source"; + +interface TerminalHost { + serverId: string; + client: DaemonClient; + epoch: number; +} +export interface TerminalHostsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly TerminalHost[]; +} +interface Observation { + client: DaemonClient; + epoch: number; + items: readonly PluginTerminalSnapshot[]; + release(): void; +} + +/** One owned observation per connected host, shared by every plugin surface. */ +export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource { + const observations = new Map(); + const listeners = new Set<() => void>(); + let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]); + let errors: ReadonlyMap = new Map(); + let releaseHosts: (() => void) | null = null; + function publish() { + const next = [...observations.values()].flatMap((item) => item.items); + if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index])) + snapshot = Object.freeze(next); + for (const listener of listeners) listener(); + } + function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) { + if (observations.get(hostId) !== observation) return; + const previous = new Map(observation.items.map((item) => [item.ref.id, item])); + observation.items = Object.freeze( + payload.terminals.flatMap((terminal) => { + // Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace. + if (!terminal.workspaceId) return []; + const ref = entityRefs.terminal(hostId, terminal.id); + const workspace = entityRefs.workspace(hostId, terminal.workspaceId); + const title = terminal.title ?? terminal.name; + const createdAt = terminal.createdAt ?? null; + const isScript = terminal.isScript === true; + const cached = previous.get(terminal.id); + if ( + cached && + cached.workspace === workspace && + cached.title === title && + cached.createdAt === createdAt && + cached.isScript === isScript + ) + return [cached]; + return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })]; + }), + ); + errors = new Map([...errors].filter(([id]) => id !== hostId)); + publish(); + } + function reconcile() { + const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host])); + for (const [id, observation] of observations) { + if ( + active.get(id)?.client !== observation.client || + active.get(id)?.epoch !== observation.epoch + ) { + observations.delete(id); + observation.release(); + } + } + errors = new Map([...errors].filter(([id]) => active.has(id))); + for (const [hostId, { client, epoch }] of active) { + if (observations.has(hostId)) continue; + const observation: Observation = { client, epoch, items: [], release() {} }; + observations.set(hostId, observation); + try { + // The SDK owns the capability gate; a failed host is reported only to terminal readers. + const subscription = client.observeTerminals({}); + const unsubscribe = subscription.subscribe({ + snapshot: (payload) => receive(hostId, observation, payload), + update: (message) => { + if (message.type === "terminals_changed") receive(hostId, observation, message.payload); + }, + error: (error) => { + if (observations.get(hostId) !== observation) return; + errors = new Map(errors).set( + hostId, + error instanceof Error ? error : new Error(String(error)), + ); + publish(); + }, + }); + void subscription.ready.catch(() => {}); // Observer owns error delivery. + observation.release = () => { + unsubscribe(); + void subscription.release().catch(() => {}); + }; + } catch (error) { + errors = new Map(errors).set( + hostId, + error instanceof Error ? error : new Error(String(error)), + ); + } + } + publish(); + } + return { + getSnapshot: () => snapshot, + getErrors: () => errors, + subscribe(listener) { + listeners.add(listener); + if (!releaseHosts) { + releaseHosts = hosts.subscribe(reconcile); + reconcile(); + } + return () => { + listeners.delete(listener); + if (!listeners.size && releaseHosts) { + releaseHosts(); + releaseHosts = null; + for (const observation of observations.values()) observation.release(); + observations.clear(); + snapshot = Object.freeze([]); + errors = new Map(); + } + }; + }, + }; +} + +export const pluginTerminals = createPluginTerminalsSource({ + getSnapshot() { + const runtime = getHostRuntimeStore(); + return runtime.getHosts().flatMap((host) => { + const state = runtime.getSnapshot(host.serverId); + return state?.connectionStatus === "online" && state.client + ? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }] + : []; + }); + }, + subscribe(listener) { + const runtime = getHostRuntimeStore(); + const releaseRuntime = runtime.subscribeAll(listener); + const releaseHosts = runtime.subscribeHostList(listener); + return () => { + releaseRuntime(); + releaseHosts(); + }; + }, +}); diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index e8aea62ede..37a71ff255 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -13,7 +13,20 @@ import { z } from "zod"; import type { InstalledPlugin } from "../types"; import { buildPluginCommandCenterContributions } from "./contributions"; +import { entityRefs } from "../client-state/refs"; const workspace: PluginWorkspaceSnapshot = { + ref: entityRefs.workspace("host-1", "workspace-1"), + hostId: "host-1", + project: entityRefs.project("host-1", "project-1"), + pinnedAt: null, + labels: [], + background: false, + branch: null, + createdAt: null, + lastPromptAt: null, + parentAgent: null, + indicator: "running", + indicatorLabel: "Running", id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", @@ -30,6 +43,10 @@ const workspace: PluginWorkspaceSnapshot = { }; const agent: PluginAgentSnapshot = { + ref: entityRefs.agent("host-1", "agent-1"), + hostId: "host-1", + workspace: workspace.ref, + parentAgent: null, id: "agent-1", workspaceId: workspace.id, provider: "codex", @@ -82,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { cleanup: () => {}, settingsScreens: [], surfaces: [{ id: "main", title: "Main", Component: () => null }], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], @@ -131,6 +149,15 @@ function stateSource() { subscribe() { return () => {}; }, + getSnapshot() { + return { workspaces: [workspace], agents: [agent], projects: [], terminals: [] }; + }, + getProject() { + return null; + }, + getTerminal() { + return null; + }, getWorkspace(id: string) { return id === workspace.id ? workspace : null; }, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 1aae11f63d..0621d0ce22 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -55,6 +55,29 @@ describe("evaluatePluginClientBundle", () => { expect(active).toBe(0); }); + it("registers sidebar views without selecting one and removes them on cleanup", async () => { + const plugin = evaluatePluginClientBundle( + "sidebar", + bundle(` + plugin.addSidebarView({ id: "workspaces", title: "Workspaces", Component() { return null; } }); + `), + ); + expect(plugin.sidebarViews.map((view) => view.id)).toEqual(["workspaces"]); + await plugin.cleanup(); + expect(plugin.sidebarViews).toEqual([]); + }); + it("rejects duplicate sidebar view ids", () => { + expect(() => + evaluatePluginClientBundle( + "sidebar", + bundle(` + const view = { id: "workspaces", title: "Workspaces", Component() { return null; } }; + plugin.addSidebarView(view); plugin.addSidebarView(view); + `), + ), + ).toThrow("Duplicate sidebar view"); + }); + it("accepts memoized settings screens", () => { const plugin = evaluatePluginClientBundle( "settings", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 56e296efd6..b5b0b71d13 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -129,6 +129,7 @@ export function runPluginClientBundle( const collector: Omit = { surfaces: [], settingsScreens: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], @@ -141,6 +142,7 @@ export function runPluginClientBundle( timelineRenderers: [], }; const surfaceIds = new Set(); + const sidebarViewIds = new Set(); const settingsScreenIds = new Set(); const sidebarItemIds: Record> = { header: new Set(), @@ -270,6 +272,18 @@ export function runPluginClientBundle( () => surfaceIds.delete(normalizedId), ); }, + addSidebarView(contribution) { + const normalizedId = requireId(contribution.id, "sidebar view id"); + if (sidebarViewIds.has(normalizedId)) + throw new Error(`Duplicate sidebar view: ${normalizedId}`); + if (!isComponentType(contribution.Component)) + throw new Error(`Sidebar view ${normalizedId} is not a component`); + const title = requirePluginText(contribution.title); + sidebarViewIds.add(normalizedId); + return register(collector.sidebarViews, { ...contribution, id: normalizedId, title }, () => + sidebarViewIds.delete(normalizedId), + ); + }, addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { return addSidebarItem("header", contribution); }, @@ -518,7 +532,8 @@ export function runPluginClientBundle( if (name === "@getpaseo/plugin/client") return { ...pluginClientRuntime, - useSettings, + useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) => + useSettings(definition, id), openExternalUrl, getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), useHosts: () => @@ -583,6 +598,7 @@ export function runPluginClientBundle( cleanup, surfaces: collector.surfaces, settingsScreens: collector.settingsScreens, + sidebarViews: collector.sidebarViews, sidebarItems: collector.sidebarItems, legacySidebarItems: collector.legacySidebarItems, filePanels: collector.filePanels, diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index b15a68d5e0..737c614c8c 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -1,7 +1,8 @@ import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { usePaseo, useRpc } from "@getpaseo/plugin/client"; import { defineRpc } from "@getpaseo/plugin"; -import type { PaseoApi } from "@getpaseo/client"; +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin { lifetime: new AbortController(), locale: "en", i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), - paseo: { dispose: async () => undefined } as PaseoApi, + paseo: createPaseoApi( + new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "installation-provider", + reconnect: { enabled: false }, + }), + ), invoke: async (method) => { invoked.push(method); return { state: "green" }; @@ -36,6 +43,7 @@ function installation(invoked: string[]): InstalledPlugin { cleanup: () => undefined, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], @@ -75,4 +83,6 @@ it("gives every surface the installation's client and RPCs on its first frame", expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true); await expect(rpc!({})).resolves.toEqual({ state: "green" }); expect(invoked).toEqual(["deploys.status"]); + plugin.lifetime.abort(); + await plugin.paseo.dispose(); }); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx index ba3b159f99..a51875fa88 100644 --- a/packages/app/src/plugins/installation-provider.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, @@ -17,7 +18,7 @@ export function PluginInstallationProvider({ }) { return ( - + {children} diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index a1a766e5c8..ad85e7aec2 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { filePanelTitle } from "@/file-panels/choice/presentation"; import { i18n } from "@/i18n/i18next"; import { createPluginHosts } from "./hosts"; @@ -50,7 +51,7 @@ function registry( }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected plugin RPC"); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 8628c0ebfb..6add4ee6d6 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { createPluginHosts } from "./hosts"; import { afterEach, expect, it } from "vitest"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -25,7 +26,7 @@ function registry(version: string) { }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("No RPC in this plugin"); diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index fe21dc785f..6c1a3730f6 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -122,6 +122,7 @@ export class PluginRegistry { cleanup: () => undefined, surfaces: [], settingsScreens: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/settings/device.test.ts b/packages/app/src/plugins/settings/device.test.ts new file mode 100644 index 0000000000..607e66976b --- /dev/null +++ b/packages/app/src/plugins/settings/device.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { createDeviceSettings } from "./device"; + +function device(records = new Map()) { + return createDeviceSettings({ + getItem: async (key) => records.get(key) ?? null, + setItem: async (key, value) => { + records.set(key, value); + }, + }); +} +const definition = defineSettings({ + id: "layout", + scope: "device", + version: 1, + schema: z.object({ collapsed: z.array(z.string()).default([]) }), +}); + +describe("device plugin settings", () => { + it("shares one installation across hosts and keeps other installations separate", async () => { + const settings = device(); + const first = settings("plugin", definition); + const second = settings("plugin", definition); + const before = await first.read(); + expect(before).toEqual({ status: "ready", revision: "missing", values: { collapsed: [] } }); + const saved = await first.write({ revision: before.revision, values: { collapsed: ["work"] } }); + expect(saved.status).toBe("saved"); + expect(await second.read()).toMatchObject({ status: "ready", values: { collapsed: ["work"] } }); + expect(await settings("other", definition).read()).toEqual(before); + }); + it("serializes competing writes and resets through the same revision contract", async () => { + const settings = device()("plugin", definition); + const initial = await settings.read(); + const results = await Promise.all([ + settings.write({ revision: initial.revision, values: { collapsed: ["a"] } }), + settings.write({ revision: initial.revision, values: { collapsed: ["b"] } }), + ]); + expect(results.map((result) => result.status)).toEqual(["saved", "conflict"]); + const current = await settings.read(); + expect(await settings.reset({ revision: current.revision })).toMatchObject({ + status: "saved", + values: { collapsed: [] }, + }); + }); + it("validates writes and migrates saved layout values", async () => { + const settings = device(); + const first = settings("plugin", definition); + expect(await first.write({ revision: "missing", values: { collapsed: 3 } })).toMatchObject({ + status: "invalid", + }); + await first.write({ revision: "missing", values: { collapsed: ["a"] } }); + const upgraded = settings( + "plugin", + defineSettings({ + ...definition, + version: 2, + migrate: (_values, from) => ({ collapsed: [`v${from}`] }), + }), + ); + expect(await upgraded.read()).toMatchObject({ status: "ready", values: { collapsed: ["v1"] } }); + }); +}); + +it("persists a validated migration once and returns the persisted revision across restarts", async () => { + const records = new Map(); + const settings = device(records); + await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } }); + let migrations = 0; + const upgraded = defineSettings({ + ...definition, + version: 2, + migrate: () => { + migrations++; + return { collapsed: ["migrated"] }; + }, + }); + const first = await settings("plugin", upgraded).read(); + const restarted = device(records)("plugin", upgraded); + expect(await restarted.read()).toEqual(first); + expect(migrations).toBe(1); + expect(first).toEqual({ + status: "ready", + revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }), + values: { collapsed: ["migrated"] }, + }); + expect( + await restarted.write({ + revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }), + values: { collapsed: [] }, + }), + ).toMatchObject({ status: "conflict" }); + expect( + await restarted.write({ revision: first.revision, values: { collapsed: [] } }), + ).toMatchObject({ status: "saved" }); +}); diff --git a/packages/app/src/plugins/settings/device.ts b/packages/app/src/plugins/settings/device.ts new file mode 100644 index 0000000000..523eed599f --- /dev/null +++ b/packages/app/src/plugins/settings/device.ts @@ -0,0 +1,80 @@ +import { + settingsRpc, + type SettingsDefinition, + type RpcOutput, + type RpcInput, +} from "@getpaseo/plugin"; +import { z } from "zod"; + +interface DeviceStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} +type ReadResult = RpcOutput["read"]>; +type WriteResult = RpcOutput["write"]>; +type WriteInput = RpcInput["write"]>; +const envelope = z.object({ version: z.number().int().positive(), values: z.json() }); + +/** One writer per device; installations with the same id share their layout settings across hosts. */ +export function createDeviceSettings(storage: DeviceStorage) { + let queue: Promise = Promise.resolve(); + function serial(work: () => Promise): Promise { + const result = queue.then(work); + queue = result.catch(() => undefined); + return result; + } + return function settings(installationId: string, definition: SettingsDefinition) { + const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`; + async function read(): Promise { + const raw = await storage.getItem(key); + let revision = raw ?? "missing"; + try { + let values: unknown = {}; + let migrated = false; + if (raw !== null) { + const stored = envelope.parse(JSON.parse(raw)); + if (stored.version > definition.version) + throw new Error("Update this plugin to read its newer settings"); + values = stored.values; + if (stored.version < definition.version) { + if (!definition.migrate) + throw new Error("This plugin has no migration for its saved settings"); + values = await definition.migrate(values, stored.version); + migrated = true; + } + } + const parsed = z.json().parse(await definition.schema.parseAsync(values)); + if (migrated) { + const serialized = JSON.stringify({ version: definition.version, values: parsed }); + await storage.setItem(key, serialized); + revision = serialized; + } + return { status: "ready", revision, values: parsed }; + } catch (error) { + return { + status: "invalid", + revision, + error: error instanceof Error ? error.message : String(error), + }; + } + } + function write(input: WriteInput): Promise { + return serial(async () => { + const raw = await storage.getItem(key); + if ((raw ?? "missing") !== input.revision) + return { status: "conflict", error: "Settings changed. Reload before saving." }; + const parsed = await definition.schema.safeParseAsync(input.values); + if (!parsed.success) return { status: "invalid", error: parsed.error.message }; + const values = z.json().parse(parsed.data); + const serialized = JSON.stringify({ version: definition.version, values }); + await storage.setItem(key, serialized); + return { status: "saved", revision: serialized, values }; + }); + } + return { + read: () => serial(read), + write, + reset: (input: { revision: string }) => write({ revision: input.revision, values: {} }), + }; + }; +} diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts index decf4bfe05..b65dc4dc03 100644 --- a/packages/app/src/plugins/settings/use-settings.ts +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -1,3 +1,6 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { queryClient as appQueryClient } from "@/data/query-client"; +import { createDeviceSettings } from "./device"; import type { SettingsState } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -8,6 +11,8 @@ import { useReplicaQuery } from "@/data/query"; import { z, type ZodType } from "zod"; import { pluginSettingsKey } from "./query"; +const deviceSettings = createDeviceSettings(AsyncStorage); + function message(error: unknown): string { if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); return error instanceof Error ? error.message : String(error); @@ -15,34 +20,49 @@ function message(error: unknown): string { export function useSettings( definition: SettingsDefinition, + installationId: string, ): SettingsState { const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]); const read = useRpc(rpc.read); const write = useRpc(rpc.write); const reset = useRpc(rpc.reset); - const client = useQueryClient(); - const key = pluginSettingsKey(definition.id); - const query = useReplicaQuery({ - queryKey: key, - pushEvent: "plugin_settings_changed", - queryFn: () => read({}), - retry: false, - }); - const mutation = useMutation({ - mutationFn: async ( - input: { revision: string; values: z.output } | { revision: string }, - ) => { - const result = - "values" in input - ? await write({ - revision: input.revision, - values: z.json().parse(await definition.schema.parseAsync(input.values)), - }) - : await reset(input); - if (result.status !== "saved") throw new Error(result.error); - client.setQueryData(key, { ...result, status: "ready" }); + const installationClient = useQueryClient(); + const device = definition.scope === "device"; + const client = device ? appQueryClient : installationClient; + const key = device + ? ["plugin-device-settings", installationId, definition.id, definition.version] + : pluginSettingsKey(definition.id); + const local = useMemo( + () => deviceSettings(installationId, definition), + [installationId, definition], + ); + const query = useReplicaQuery( + { + queryKey: key, + pushEvent: device ? "plugin_device_settings_saved" : "plugin_settings_changed", + queryFn: () => (device ? local.read() : read({})), + retry: false, }, - }); + client, + ); + const mutation = useMutation( + { + mutationFn: async ( + input: { revision: string; values: z.output } | { revision: string }, + ) => { + const result = + "values" in input + ? await (device ? local.write : write)({ + revision: input.revision, + values: z.json().parse(await definition.schema.parseAsync(input.values)), + }) + : await (device ? local.reset : reset)(input); + if (result.status !== "saved") throw new Error(result.error); + client.setQueryData(key, { ...result, status: "ready" }); + }, + }, + client, + ); async function save(values: z.output, revision: string) { try { await mutation.mutateAsync({ values, revision }); diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index bc9517d4b4..a64af934d8 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -31,6 +31,7 @@ function installed( }, settingsScreens: [], surfaces: [{ id: "surface", title: "Surface", Component: () => null }], + sidebarViews: [], sidebarItems: { header: [{ id: contributionId, title: "Example", Component: () => null }], footer: [{ id: "status", title: "Status", Component: () => null }], diff --git a/packages/app/src/plugins/sidebar-view/catalog.ts b/packages/app/src/plugins/sidebar-view/catalog.ts new file mode 100644 index 0000000000..3711cb8414 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/catalog.ts @@ -0,0 +1,25 @@ +import type { InstalledPlugin } from "../types"; + +export interface SidebarViewEntry { + id: string; + title: string; + plugin: InstalledPlugin; + view: InstalledPlugin["sidebarViews"][number]; +} + +export function sidebarViews(plugins: readonly InstalledPlugin[]): SidebarViewEntry[] { + const entries = new Map(); + for (const plugin of plugins) { + for (const view of plugin.sidebarViews) { + const id = `${plugin.id}/${view.id}`; + if (!entries.has(id)) + entries.set(id, { + id, + title: plugin.i18n.resolve(view.title, plugin.locale), + plugin, + view, + }); + } + } + return [...entries.values()]; +} diff --git a/packages/app/src/plugins/sidebar-view/frame.browser.test.tsx b/packages/app/src/plugins/sidebar-view/frame.browser.test.tsx new file mode 100644 index 0000000000..730b80e292 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/frame.browser.test.tsx @@ -0,0 +1,69 @@ +import React, { act, useMemo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it } from "vitest"; +import { SidebarViewFrame } from "./frame"; +import { useSidebarViewSelection, type SidebarViewStorage } from "./selection"; + +const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +let root: Root | null = null; +let container: HTMLDivElement | null = null; +let defaultRenders = 0; +function DefaultView() { + defaultRenders++; + return
Default sidebar
; +} +function PluginView() { + return
Stored plugin sidebar
; +} +function Sidebar({ storage }: { storage: SidebarViewStorage }) { + const selection = useSidebarViewSelection(storage); + const fallback = useMemo(() => , []); + const selected = + selection.state.status === "ready" && selection.state.selection === "example/workspaces"; + const view = selected ? : null; + return ( + + {view} + + ); +} +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + defaultRenders = 0; + client.clear(); +}); + +function deferredStorage() { + let finishRead: (value: string) => void = () => { + throw new Error("Read not started"); + }; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + const storage: SidebarViewStorage = { getItem: () => read, setItem: async () => {} }; + return { storage, finishRead }; +} + +it("never renders the default before a stored plugin selection is known", async () => { + const { storage, finishRead } = deferredStorage(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => + root?.render( + + + , + ), + ); + expect(defaultRenders).toBe(0); + await act(async () => { + finishRead("example/workspaces"); + }); + await expect.poll(() => container?.textContent).toContain("Stored plugin sidebar"); + expect(defaultRenders).toBe(0); +}); diff --git a/packages/app/src/plugins/sidebar-view/frame.tsx b/packages/app/src/plugins/sidebar-view/frame.tsx new file mode 100644 index 0000000000..c059530be8 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/frame.tsx @@ -0,0 +1,34 @@ +import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton"; +import type { ReactNode } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import type { SidebarViewSelectionState } from "./selection"; + +export function SidebarViewFrame({ + state, + fallback, + children, + reload, +}: { + state: SidebarViewSelectionState; + fallback: ReactNode; + children: ReactNode; + reload(): void; +}) { + const { t } = useTranslation(); + if (state.status === "loading") return ; + if (state.status === "error") + return ( + + {t("sidebar.view.readFailed")} + + + ); + return children ?? fallback; +} +const styles = StyleSheet.create((theme) => ({ + failure: { padding: theme.spacing[4], gap: theme.spacing[3] }, + text: { color: theme.colors.foreground }, +})); diff --git a/packages/app/src/plugins/sidebar-view/index.tsx b/packages/app/src/plugins/sidebar-view/index.tsx new file mode 100644 index 0000000000..266ae9b1c6 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/index.tsx @@ -0,0 +1,173 @@ +import { useTranslation } from "react-i18next"; +import { SidebarViewFrame } from "./frame"; +import type { PluginSidebarViewProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { router } from "expo-router"; +import { useMemo, useCallback, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { + navigateToWorkspace, + useActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { useSessionStore } from "@/stores/session-store"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "../client-state/source"; +import { entityRefs } from "../client-state/refs"; +import { PluginEnvironmentProvider } from "../popover"; +import { resolvePluginPlatform } from "../platform"; +import { useInstalledPlugins } from "../registry"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import { sidebarViews, type SidebarViewEntry } from "./catalog"; +import { useSidebarViewSelection } from "./selection"; + +const source = createPluginClientStateSource(); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +export function SidebarViewSlot({ children, dismiss }: { children: ReactNode; dismiss(): void }) { + const { t } = useTranslation(); + const installed = useInstalledPlugins(); + const { state, reload, setSelection, pending, error } = useSidebarViewSelection(); + const restoreDefault = useCallback(() => setSelection("default"), [setSelection]); + const renderError = useCallback( + () => ( + + {t("sidebar.view.failed")} + + {error ? {error.message} : null} + + ), + [restoreDefault, pending, error, t], + ); + const entry = + state.status === "ready" + ? sidebarViews(installed).find((view) => view.id === state.selection) + : undefined; + const view = entry ? ( + + + + + + ) : null; + return ( + + {view} + + ); +} + +function SidebarView({ + entry, + dismiss, + theme, +}: { + entry: SidebarViewEntry; + dismiss(): void; + theme: PluginTheme; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const compact = useIsCompactFormFactor(); + const selection = useActiveWorkspaceSelection(); + const focusedAgentId = useSessionStore((state) => + selection ? (state.sessions[selection.serverId]?.focusedAgentId ?? null) : null, + ); + const activeWorkspace = selection + ? entityRefs.workspace(selection.serverId, selection.workspaceId) + : null; + const activeAgent = + selection && focusedAgentId ? entityRefs.agent(selection.serverId, focusedAgentId) : null; + const environment = useMemo( + () => ({ installation: entry.plugin, state: source, toast }), + [entry.plugin, toast], + ); + const navigation = useMemo( + () => ({ + openWorkspace(ref) { + navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id }); + dismiss(); + }, + openAgent(ref) { + const agent = source.getAgent(ref); + if (!agent) { + toast.error(t("sidebar.view.agentUnavailable")); + return; + } + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: agent.workspace.id, + target: { kind: "agent", agentId: ref.id }, + }); + dismiss(); + }, + openTerminal(ref) { + const terminal = source.getTerminal(ref); + if (!terminal) { + toast.error(t("sidebar.view.terminalUnavailable")); + return; + } + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: terminal.workspace.id, + target: { kind: "terminal", terminalId: ref.id }, + }); + dismiss(); + }, + openProject(ref) { + const project = source.getProject(ref); + if (!project) { + toast.error(t("sidebar.view.projectUnavailable")); + return; + } + router.push( + buildNewWorkspaceRoute({ + serverId: ref.hostId, + projectId: ref.id, + sourceDirectory: project.rootPath, + displayName: project.displayName, + }), + ); + dismiss(); + }, + }), + [dismiss, toast, t], + ); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const active = useMemo( + () => ({ workspace: activeWorkspace, agent: activeAgent }), + [activeWorkspace, activeAgent], + ); + const Component = entry.view.Component; + return ( + + + + ); +} +const ThemedSidebarView = withUnistyles(SidebarView); + +const styles = StyleSheet.create((theme) => ({ + content: { flex: 1 }, + placeholder: { padding: theme.spacing[4], gap: theme.spacing[3] }, + text: { color: theme.colors.foreground }, +})); + +export { SidebarViewPicker } from "./picker"; diff --git a/packages/app/src/plugins/sidebar-view/picker.tsx b/packages/app/src/plugins/sidebar-view/picker.tsx new file mode 100644 index 0000000000..920df1c797 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/picker.tsx @@ -0,0 +1,58 @@ +import { Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { SettingsCard, SettingsRow, SettingsSection, SettingsSelect } from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { useInstalledPlugins } from "../registry"; +import { sidebarViews } from "./catalog"; +import { useSidebarViewSelection } from "./selection"; + +export function SidebarViewPicker() { + const { t } = useTranslation(); + const views = sidebarViews(useInstalledPlugins()); + const { state, reload, setSelection, pending, error } = useSidebarViewSelection(); + let content; + if (state.status === "loading") { + content = ( + + {t("common.loading")} + + ); + } else if (state.status === "error") { + content = ( + + + + ); + } else { + const missing = + state.selection !== "default" && !views.some((view) => view.id === state.selection); + const value = missing ? "default" : state.selection; + let hint: string | undefined; + if (missing) hint = t("settings.appearance.sidebar.view.unavailable"); + if (error) hint = error.message; + const options = [ + { value: "default", label: t("settings.appearance.sidebar.view.default") }, + ...views.map((view) => ({ value: view.id, label: view.title })), + ]; + content = ( + + ); + } + return ( + + {content} + + ); +} +const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } })); diff --git a/packages/app/src/plugins/sidebar-view/selection.ts b/packages/app/src/plugins/sidebar-view/selection.ts new file mode 100644 index 0000000000..f1c8eb08a2 --- /dev/null +++ b/packages/app/src/plugins/sidebar-view/selection.ts @@ -0,0 +1,47 @@ +import { useCallback } from "react"; +import { useFetchQuery } from "@/data/query"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +const key = "sidebar.view"; +const queryKey = [key]; + +export interface SidebarViewStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} +export type SidebarViewSelectionState = + | { status: "loading" } + | { status: "error"; error: Error } + | { status: "ready"; selection: string }; + +export function useSidebarViewSelection(storage: SidebarViewStorage = AsyncStorage) { + const client = useQueryClient(); + const query = useFetchQuery({ + queryKey, + queryFn: async () => (await storage.getItem(key)) ?? "default", + dataShape: "value", + immutableWhen: () => true, + }); + const save = useMutation({ + mutationFn: (selection: string) => storage.setItem(key, selection), + onSuccess: (_result, selection) => { + client.setQueryData(queryKey, selection); + }, + }); + let state: SidebarViewSelectionState; + if (query.isPending) state = { status: "loading" }; + else if (query.isError) state = { status: "error", error: query.error }; + else state = { status: "ready", selection: query.data }; + const { refetch } = query; + const reload = useCallback(() => { + void refetch(); + }, [refetch]); + return { + state, + reload, + setSelection: save.mutate, + pending: save.isPending, + error: save.error, + }; +} diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 4973b34f5b..fff830f7e7 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -49,6 +49,7 @@ function installation( cleanup: () => undefined, settingsScreens: [], surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })), + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: sidebarItems.map((item) => ({ ...item, diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index c75d6b5b01..8a208b37c0 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install }, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 2a07c1cf47..ef8d68d50c 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -24,6 +24,7 @@ function plugin(input: { cleanup: () => {}, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index ff3ba9fd6b..7d96747279 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null })); const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null })); -vi.mock("@/runtime/host-runtime", () => ({ +vi.mock("@/runtime/host-runtime", async (importOriginal) => ({ + ...(await importOriginal()), useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 29c0470e5d..81d29e0279 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -13,6 +13,7 @@ import type { PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, PluginComposerPillContribution as SDKPluginComposerPillContribution, PluginSidebarContribution as SDKPluginSidebarContribution, + PluginSidebarViewContribution, PluginSidebarItemContribution as SDKPluginSidebarItemContribution, PluginScreenContribution as SDKPluginScreenContribution, PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution, @@ -48,6 +49,7 @@ export interface EvaluatedPlugin { cleanup: PluginCleanup; surfaces: PluginScreenContribution[]; settingsScreens: PluginSettingsScreenContribution[]; + sidebarViews: PluginSidebarViewContribution[]; sidebarItems: Record; // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 /** `addSidebarItem` registrations, so `/plugin//sidebar/` routes keep resolving. */ diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 15282266b7..546a8916ac 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -21,6 +21,7 @@ function installed(): InstalledPlugin { cleanup: () => {}, settingsScreens: [], surfaces: [], + sidebarViews: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 8a502749db..dfb16ccfa7 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -20,7 +20,8 @@ const runtime = vi.hoisted(() => ({ client: null as DaemonClient | null, })); -vi.mock("@/runtime/host-runtime", () => ({ +vi.mock("@/runtime/host-runtime", async (importOriginal) => ({ + ...(await importOriginal()), useHostRuntimeClient: () => runtime.client, useHostRuntimeIsConnected: () => runtime.connected, })); diff --git a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx index c7fbcd7d06..94f336fdea 100644 --- a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx @@ -1,3 +1,4 @@ +import { SidebarViewPicker } from "@/plugins/sidebar-view"; import { useCallback, useMemo, type ComponentType, type ReactElement } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -183,6 +184,7 @@ function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElemen export function SidebarNavSection(): ReactElement { return ( <> + diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 017c302f7d..c8fbff81e1 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6578,10 +6578,16 @@ export class DaemonClient { // ============================================================================ observeTerminals(input: { - cwd: string; + cwd?: string; workspaceId?: string; signal?: AbortSignal; }): OwnedSubscription> { + // COMPAT(terminalCollection): added in v0.16, remove after 2027-06. + if ( + input.cwd === undefined && + this.getLastServerInfoMessage()?.features?.terminalCollection !== true + ) + throw new Error("Update the host to list terminals across workspaces."); const { signal, ...query } = input; return this.observe( "terminals_changed", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 79a2de50a1..390c9310f3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js"; import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; @@ -190,6 +191,8 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; + setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>; + setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>; setBaseBranch(options: { baseRef: string; requestId?: string; @@ -899,6 +902,14 @@ function createWorkspaceHandleFactory( current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), + setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned), + setLabel: (name, options) => + assignWorkspaceLabel({ + client: daemonClient, + workspaceId: id, + name, + assigned: options.assigned, + }), setBaseBranch: (options) => daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }), archive: async (requestId) => { @@ -1288,3 +1299,28 @@ function createGeneratedClientId(): string { : Math.random().toString(36).slice(2); return `paseo-sdk-${randomId}`; } + +interface WorkspaceLabelAssignment { + client: DaemonClient; + workspaceId: string; + name: string; + assigned: boolean; +} +async function assignWorkspaceLabel( + input: WorkspaceLabelAssignment, +): Promise<{ labels: string[] }> { + input.client.ensureConnected(); + // COMPAT(workspaceLabels): added in v0.5.0, remove gate after 2027-08-14. + if (input.client.getLastServerInfoMessage()?.features?.workspaceLabels !== true) + throw new Error("Update the host to assign workspace labels."); + const catalog = await input.client.listWorkspaceLabels(); + const key = workspaceLabelKey(input.name); + const existing = catalog.labels.find((label) => workspaceLabelKey(label.name) === key); + const label = existing ?? { name: input.name, color: "violet" as const }; + const result = await input.client.setWorkspaceLabel({ + workspaceId: input.workspaceId, + label, + assigned: input.assigned, + }); + return { labels: result.workspaceLabels }; +} diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index c28bbc8d68..24385a3a16 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -388,3 +388,45 @@ describe("plugin subscription SDK boundary", () => { ).toEqual([]); }); }); + +describe("sidebar data SDK boundary", () => { + it("accepts references and preserves string-id panel consumers", () => { + expect( + typeErrors(` + import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client"; + import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin"; + function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) { + const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace); + const title: string | null = useWorkspace("workspace", item => item.name); + const status = useAgent(agent, item => ({ status: item.status }), (a, b) => a?.status === b?.status); + const agentSnapshot = useAgent(agent); + const custom = useWorkspace(workspace, undefined, (a, b) => a?.name === b?.name); + const workspaces: readonly WorkspaceRef[] = useWorkspaces({ orderBy: "name" }); + const groups: readonly Group[] = useAgents({ groupBy: item => item.status }); + const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] }); + const terminals: readonly TerminalRef[] = useTerminals(); + const paseo = usePaseo(); + void paseo.workspaces.ref(workspace).setPinned(true); + void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true }); + void paseo.workspaces.ref("old-id").setTitle("Title"); + if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); } + if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); } + void paseo.agents.ref(agent); + if (projects[0]) useProject(projects[0], item => item.displayName); + if (terminals[0]) useTerminal(terminals[0]); + function View(props: PluginSidebarViewProps) { + props.navigation.openWorkspace(workspace); + props.navigation.openAgent(agent); + props.dismiss(); + return null; + } + function contribute(client: PluginClientContext) { + client.addSidebarView({ id: "workspaces", title: "Workspaces", Component: View }); + } + const query: Query = { hosts: [workspace.hostId], orderBy: ["name", "desc"] }; + return null; + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/client-state.tsx b/packages/plugin/src/client/client-state.tsx index b1c853ecec..db2a6b5f5a 100644 --- a/packages/plugin/src/client/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -1,12 +1,30 @@ -import { createContext, useContext, type ReactNode } from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js"; +import type { AgentRef, WorkspaceRef, ProjectRef, TerminalRef } from "../refs.js"; +import type { + PluginAgentSnapshot, + PluginWorkspaceSnapshot, + PluginProjectSnapshot, + PluginTerminalSnapshot, +} from "../contracts.js"; +import { createCollectionQuery, type Query, type Group } from "./query.js"; import { shallowEqual } from "./shallow.js"; export interface PluginClientStateSource { - subscribe(listener: () => void): () => void; - getWorkspace(id: string): PluginWorkspaceSnapshot | null; - getAgent(id: string): PluginAgentSnapshot | null; + subscribe(listener: () => void, options?: { terminals?: boolean }): () => void; + getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null; + getAgent(id: string | AgentRef): PluginAgentSnapshot | null; + getProject(ref: ProjectRef): PluginProjectSnapshot | null; + getTerminal(ref: TerminalRef): PluginTerminalSnapshot | null; + getSnapshot(): PluginClientSnapshot; +} + +export interface PluginClientSnapshot { + readonly workspaces: readonly PluginWorkspaceSnapshot[]; + readonly agents: readonly PluginAgentSnapshot[]; + readonly projects: readonly PluginProjectSnapshot[]; + readonly terminals: readonly PluginTerminalSnapshot[]; + readonly terminalErrors?: ReadonlyMap; } const PluginClientStateContext = createContext(null); @@ -15,44 +33,113 @@ export function usePluginClientStateSource(): PluginClientStateSource | null { return useContext(PluginClientStateContext); } -function usePluginEntity(input: { - id: string; - read(source: PluginClientStateSource, id: string): Entity | null; - selector(entity: Entity): Selection; -}): Selection | null { +/** Shared selection semantics for host surfaces and plugin entities. */ +export function usePaseoSelector( + source: { subscribe(listener: () => void): () => void }, + read: () => Entity, + selector: (entity: Entity) => Selection, + equals: (left: Selection, right: Selection) => boolean = shallowEqual, +): Selection { + return useSyncExternalStoreWithSelector(source.subscribe, read, read, selector, equals); +} + +type Equality = { equals(left: T, right: T): boolean }["equals"]; + +function useStateSource(terminals: boolean) { const source = usePluginClientStateSource(); - if (!source) throw new Error("Plugin state hooks must run inside a workspace panel"); - return useSyncExternalStoreWithSelector( - source.subscribe, - () => input.read(source, input.id), - () => input.read(source, input.id), - (entity) => (entity ? input.selector(entity) : null), - shallowEqual, + if (!source) throw new Error("Plugin state hooks must run inside a plugin host"); + return useMemo( + () => + terminals + ? { + ...source, + subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }), + } + : source, + [source, terminals], ); } -export function useWorkspace( - workspaceId: string, - selector: (workspace: PluginWorkspaceSnapshot) => Selection, -): Selection | null { - return usePluginEntity({ - id: workspaceId, - read: (source, id) => source.getWorkspace(id), - selector, - }); +function createEntityHook( + read: (source: PluginClientStateSource, ref: Ref) => Entity | null, + terminals = false, +) { + function useEntity( + ref: Ref, + selector?: undefined, + equals?: Equality, + ): Entity | null; + function useEntity( + ref: Ref, + selector: (entity: Entity) => Selection, + equals?: Equality, + ): Selection | null; + function useEntity( + ref: Ref, + selector?: (entity: Entity) => Selection, + equals?: Equality, + ): Entity | Selection | null { + const source = useStateSource(terminals); + function select(entity: Entity | null): Entity | Selection | null { + return entity !== null && selector ? selector(entity) : entity; + } + const equality = equals ?? (selector ? shallowEqual : Object.is); + return usePaseoSelector(source, () => read(source, ref), select, equality); + } + return useEntity; } -export function useAgent( - agentId: string, - selector: (agent: PluginAgentSnapshot) => Selection, -): Selection | null { - return usePluginEntity({ - id: agentId, - read: (source, id) => source.getAgent(id), - selector, - }); +// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03. +export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string) => + source.getWorkspace(ref), +); +// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03. +export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref)); +export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref)); +export const useTerminal = createEntityHook( + (source, ref: TerminalRef) => source.getTerminal(ref), + true, +); + +function createCollectionHook( + read: (source: PluginClientStateSource, query: Query) => readonly Snapshot[], + terminals = false, +) { + function useCollection( + query: Query & { groupBy: NonNullable["groupBy"]> }, + ): readonly Group[]; + function useCollection( + query?: Query & { groupBy?: undefined }, + ): readonly Snapshot["ref"][]; + function useCollection( + query: Query, + ): readonly Snapshot["ref"][] | readonly Group[]; + function useCollection( + query: Query = {}, + ): readonly Snapshot["ref"][] | readonly Group[] { + const source = useStateSource(terminals); + const project = useMemo(() => createCollectionQuery(), []); + return usePaseoSelector( + source, + () => read(source, query), + (snapshots) => project(snapshots, query), + Object.is, + ); + } + return useCollection; } +export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces); +export const useAgents = createCollectionHook((source) => source.getSnapshot().agents); +export const useProjects = createCollectionHook((source) => source.getSnapshot().projects); +export const useTerminals = createCollectionHook((source, query) => { + const snapshot = source.getSnapshot(); + for (const [hostId, error] of snapshot.terminalErrors ?? []) { + if (!query.hosts || query.hosts.includes(hostId)) throw error; + } + return snapshot.terminals; +}, true); + export function PluginClientStateProvider({ children, source, diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 76c163af53..229e8ec478 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,6 +1,7 @@ +import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js"; import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; -import type { PaseoApi } from "@getpaseo/client"; +import type { PluginPaseoApi } from "./reference-api.js"; import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginFile } from "./file.js"; @@ -96,6 +97,24 @@ export interface PluginSidebarItemContribution Component: ComponentType; } +export interface PluginSidebarViewProps extends Pick { + active: { workspace: WorkspaceRef | null; agent: AgentRef | null }; + navigation: { + openWorkspace(ref: WorkspaceRef): void; + openAgent(ref: AgentRef): void; + openTerminal(ref: TerminalRef): void; + openProject(ref: ProjectRef): void; + }; + dismiss(): void; +} + +export interface PluginSidebarViewContribution { + id: string; + title: Text; + description?: Text; + Component: ComponentType; +} + /** * A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size * and color; buttons widen the props with their host and target context. @@ -185,6 +204,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl playAudio(source: { base64: string; mimeType: string }): Promise; addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; addScreen(contribution: PluginScreenContribution): PluginCleanup; + addSidebarView(contribution: PluginSidebarViewContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; addAgentPanel(contribution: PluginAgentPanelContribution): PluginCleanup; @@ -308,7 +328,7 @@ export interface PluginTimelineRendererContribution( contract: PluginRpcContract, input: PluginWireInput>, diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index 2ec1f57b2e..19cf91f46d 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,6 +1,11 @@ import type { PluginText } from "../text.js"; import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; -export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; +export { + PluginClientStateProvider, + usePaseoSelector, + type PluginClientStateSource, + type PluginClientSnapshot, +} from "./client-state.js"; export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, @@ -31,3 +36,7 @@ export { isPluginMessage, } from "./translations.js"; export type { PluginLocaleSource } from "./translations.js"; + +export { createEntityRefs } from "./refs.js"; + +export { createPluginReferenceApi } from "./reference-api.js"; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index ffaf0782d5..1be686d922 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -15,6 +15,8 @@ export type { PluginOpenScreenInput, PluginScreenLocation, PluginSidebarItemProps, + PluginSidebarViewProps, + PluginSidebarViewContribution, PluginSidebarItemContribution, PluginIcon, PluginPanelLocation, @@ -70,7 +72,16 @@ export { useFileUrl, } from "./file.js"; export { usePaseo } from "./paseo-context.js"; -export { useAgent, useWorkspace } from "./client-state.js"; +export { + useAgent, + useWorkspace, + useProject, + useTerminal, + useWorkspaces, + useAgents, + useProjects, + useTerminals, +} from "./client-state.js"; export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js"; export type { PluginSubscribeOptions, @@ -102,3 +113,8 @@ export type { PluginI18n } from "./translations.js"; export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; export { useTabState } from "./tab.js"; + +export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js"; +export type { Query, Group, GroupKey } from "./query.js"; + +export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js"; diff --git a/packages/plugin/src/client/paseo-context.tsx b/packages/plugin/src/client/paseo-context.tsx index fe1a0b9c5c..8ec6910bb2 100644 --- a/packages/plugin/src/client/paseo-context.tsx +++ b/packages/plugin/src/client/paseo-context.tsx @@ -1,17 +1,23 @@ -import type { PaseoApi } from "@getpaseo/client"; +import type { PluginPaseoApi } from "./reference-api.js"; import { createContext, useContext, type ReactNode } from "react"; -const PaseoApiContext = createContext(null); +const PaseoApiContext = createContext(null); -export function usePaseoContextValue(): PaseoApi | null { +export function usePaseoContextValue(): PluginPaseoApi | null { return useContext(PaseoApiContext); } -export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) { +export function PaseoApiProvider({ + children, + paseo, +}: { + children: ReactNode; + paseo: PluginPaseoApi; +}) { return {children}; } -export function usePaseo(): PaseoApi { +export function usePaseo(): PluginPaseoApi { const paseo = usePaseoContextValue(); if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface"); return paseo; diff --git a/packages/plugin/src/client/query.test.ts b/packages/plugin/src/client/query.test.ts new file mode 100644 index 0000000000..ce0dd9bd3c --- /dev/null +++ b/packages/plugin/src/client/query.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createCollectionQuery, type GroupKey } from "./query.js"; + +const first = Object.freeze({ key: "a" }); +const second = Object.freeze({ key: "b" }); +const work: GroupKey = "work"; +const snapshots = [ + { ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] }, + { ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] }, +]; + +describe("collection query structural sharing", () => { + it("preserves collection identity when a row changes without changing membership", () => { + const project = createCollectionQuery<(typeof snapshots)[number]>(); + const before = project(snapshots); + const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]); + expect(after).toBe(before); + expect(after).toEqual([first, second]); + }); + + it("shares unaffected groups and deduplicates membership", () => { + const project = createCollectionQuery<(typeof snapshots)[number]>(); + const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels }; + const before = project(snapshots, query); + const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query); + expect(after[1]).toBe(before[1]); + expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] }); + expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] }); + expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after); + }); + + it("filters hosts and predicates before sorting", () => { + const project = createCollectionQuery<(typeof snapshots)[number]>(); + expect(project(snapshots, { hosts: ["two"] })).toEqual([second]); + expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]); + expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]); + expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([ + first, + second, + ]); + }); + + it("keeps nulls last in either direction and metadata from the first group", () => { + const project = createCollectionQuery<{ + ref: typeof first; + hostId: string; + title: string | null; + }>(); + const items = [ + { ref: first, hostId: "one", title: null }, + { ref: second, hostId: "one", title: "Z" }, + ]; + expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]); + expect( + project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }), + ).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]); + }); +}); diff --git a/packages/plugin/src/client/query.ts b/packages/plugin/src/client/query.ts new file mode 100644 index 0000000000..8e291602ca --- /dev/null +++ b/packages/plugin/src/client/query.ts @@ -0,0 +1,115 @@ +interface GroupDescriptor { + readonly key: string; + readonly title?: string; + readonly color?: string; +} + +export type GroupKey = string | GroupDescriptor; + +export interface Query { + readonly hosts?: readonly string[]; + readonly where?: (item: Snapshot) => boolean; + readonly orderBy?: + | keyof Snapshot + | readonly [keyof Snapshot, "asc" | "desc"] + | ((a: Snapshot, b: Snapshot) => number); + readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[]; +} + +export interface Group { + readonly key: string; + readonly title: string | null; + readonly color: string | null; + readonly items: readonly Ref[]; +} + +function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] { + if (typeof value === "string") return [value]; + if ("key" in value) return [value]; + return value; +} + +interface Referenced { + readonly hostId: string; + readonly ref: unknown; +} + +function sameSequence(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]); +} + +function compareValues(left: unknown, right: unknown, direction: number): number { + if (Object.is(left, right)) return 0; + if (left === null || left === undefined) return 1; + if (right === null || right === undefined) return -1; + if (typeof left === "number" && typeof right === "number") return (left - right) * direction; + return String(left).localeCompare(String(right)) * direction; +} + +/** A hook owns one projection; sharing survives both source and query changes. */ +export function createCollectionQuery() { + type Ref = Snapshot["ref"]; + let previousItems: readonly Ref[] = Object.freeze([]); + let previousGroups: readonly Group[] = Object.freeze([]); + + return function project( + snapshots: readonly Snapshot[], + query: Query = {}, + ): readonly Ref[] | readonly Group[] { + const items = snapshots.filter((item) => { + if (query.hosts && !query.hosts.includes(item.hostId)) return false; + return !query.where || query.where(item); + }); + const order = query.orderBy; + if (typeof order === "function") items.sort(order); + else if (order !== undefined) { + const field = typeof order === "object" ? order[0] : order; + const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1; + items.sort((a, b) => compareValues(a[field], b[field], direction)); + } + if (!query.groupBy) { + const refs = items.map((item) => item.ref); + if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs); + return previousItems; + } + const groups = new Map< + string, + { key: string; title: string | null; color: string | null; items: Ref[] } + >(); + for (const item of items) { + const grouping = query.groupBy(item); + const keys = groupKeys(grouping); + const seen = new Set(); + for (const value of keys) { + const descriptor = typeof value === "string" ? { key: value } : value; + if (seen.has(descriptor.key)) continue; + seen.add(descriptor.key); + let group = groups.get(descriptor.key); + if (!group) { + group = { + key: descriptor.key, + title: descriptor.title ?? null, + color: descriptor.color ?? null, + items: [], + }; + groups.set(descriptor.key, group); + } + group.items.push(item.ref); + } + } + const oldGroups = new Map(previousGroups.map((group) => [group.key, group])); + const nextGroups = Array.from(groups.values(), (group) => { + const previous = oldGroups.get(group.key); + if ( + previous && + previous.title === group.title && + previous.color === group.color && + sameSequence(previous.items, group.items) + ) + return previous; + return Object.freeze({ ...group, items: Object.freeze(group.items) }); + }); + if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups); + return previousGroups; + }; +} diff --git a/packages/plugin/src/client/reference-api.ts b/packages/plugin/src/client/reference-api.ts new file mode 100644 index 0000000000..b782e26438 --- /dev/null +++ b/packages/plugin/src/client/reference-api.ts @@ -0,0 +1,88 @@ +import type { + PaseoApi, + PaseoAgent, + PaseoAgentHandle, + PaseoWorkspace, + PaseoWorkspaceHandle, + PaseoWorkspaceCreateOptions, +} from "@getpaseo/client"; +import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client"; +import type { PluginProjectSnapshot } from "../contracts.js"; +import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js"; +import type { PluginSidebarViewProps } from "./contracts.js"; + +export interface PluginProjectHandle { + readonly ref: ProjectRef; + open(): void; + createWorkspace( + input?: Omit & { + source?: PaseoWorkspaceCreateOptions["source"]; + }, + ): Promise; +} +export interface PluginTerminalHandle extends PaseoTerminalHandle { + focus(): void; +} +export interface PluginPaseoApi extends PaseoApi { + workspaces: Omit & { + ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle; + }; + agents: Omit & { + ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle; + }; + projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle }; + terminals: Omit & { + ref(terminal: TerminalRef): PluginTerminalHandle; + ref(terminal: string | PaseoTerminal): PaseoTerminalHandle; + }; +} + +/** Host adapter; the installation retains its single API identity across every surface. */ +export function createPluginReferenceApi(input: { + paseo: PaseoApi; + resolveHost(hostId: string): PaseoApi; + readProject(ref: ProjectRef): PluginProjectSnapshot | null; + navigation: PluginSidebarViewProps["navigation"]; +}): PluginPaseoApi { + const { paseo } = input; + const workspaceRef = paseo.workspaces.ref; + const agentRef = paseo.agents.ref; + const terminalRef = paseo.terminals.ref; + function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) { + if (typeof workspace !== "string" && "hostId" in workspace) + return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id); + return workspaceRef(workspace); + } + function refAgent(agent: string | PaseoAgent | AgentRef) { + if (typeof agent !== "string" && "hostId" in agent) + return input.resolveHost(agent.hostId).agents.ref(agent.id); + return agentRef(agent); + } + function terminal(ref: TerminalRef): PluginTerminalHandle; + function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle; + function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle { + if (typeof ref !== "string" && "hostId" in ref) { + const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id); + return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) }); + } + return terminalRef(ref); + } + function project(ref: ProjectRef): PluginProjectHandle { + return { + ref, + open: () => input.navigation.openProject(ref), + async createWorkspace(options = {}) { + const snapshot = input.readProject(ref); + if (!snapshot) throw new Error("Project is no longer available"); + const source = options.source ?? { kind: "directory", path: snapshot.rootPath }; + return input.resolveHost(ref.hostId).workspaces.create({ ...options, source }); + }, + }; + } + return Object.assign(paseo, { + workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }), + agents: Object.assign(paseo.agents, { ref: refAgent }), + projects: Object.assign(paseo.projects, { ref: project }), + terminals: Object.assign(paseo.terminals, { ref: terminal }), + }); +} diff --git a/packages/plugin/src/client/refs.test.ts b/packages/plugin/src/client/refs.test.ts new file mode 100644 index 0000000000..93407414fc --- /dev/null +++ b/packages/plugin/src/client/refs.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createEntityRefs } from "./refs.js"; + +describe("session entity references", () => { + it("interns frozen references separately by kind, host and entity", () => { + const refs = createEntityRefs(); + for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) { + const first = reference("host", "entity"); + expect(reference("host", "entity")).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(reference("other", "entity")).not.toBe(first); + expect(reference("host", "other")).not.toBe(first); + } + expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity")); + }); + + it("keeps opaque ids unambiguous and interning scoped to one app session", () => { + const refs = createEntityRefs(); + expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key); + expect(createEntityRefs().workspace("host", "entity")).not.toBe( + refs.workspace("host", "entity"), + ); + }); +}); diff --git a/packages/plugin/src/client/refs.ts b/packages/plugin/src/client/refs.ts new file mode 100644 index 0000000000..0dbdd69428 --- /dev/null +++ b/packages/plugin/src/client/refs.ts @@ -0,0 +1,26 @@ +import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js"; + +/** Owned by the app session, shared by every plugin installation. */ +export function createEntityRefs() { + function intern(kind: Kind) { + const references = new Map< + string, + Readonly<{ kind: Kind; key: string; hostId: string; id: string }> + >(); + return function reference(hostId: string, id: string) { + const key = JSON.stringify([hostId, id]); + let ref = references.get(key); + if (!ref) { + ref = Object.freeze({ kind, key, hostId, id }); + references.set(key, ref); + } + return ref; + }; + } + return { + workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef, + agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef, + project: intern("project") satisfies (hostId: string, id: string) => ProjectRef, + terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef, + }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 7e71cb22c5..d99d61e8aa 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,3 +1,4 @@ +import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js"; import type { PluginText } from "./text.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginSerializableIcon } from "./icons.js"; @@ -19,7 +20,22 @@ export interface PluginTheme { }; } +export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done"; + export interface PluginWorkspaceSnapshot { + readonly ref: WorkspaceRef; + readonly hostId: string; + readonly project: ProjectRef; + readonly pinnedAt: string | null; + readonly labels: readonly string[]; + readonly background: boolean; + readonly branch: string | null; + /** Null when an older host has not supplied the workspace creation time. */ + readonly createdAt: string | null; + readonly lastPromptAt: string | null; + readonly parentAgent: AgentRef | null; + readonly indicator: WorkspaceIndicator; + readonly indicatorLabel: string | null; readonly id: string; readonly projectId: string; readonly projectDisplayName: string; @@ -29,6 +45,7 @@ export interface PluginWorkspaceSnapshot { readonly kind: "directory" | "local_checkout" | "checkout" | "worktree"; readonly name: string; readonly title: string | null; + /** Raw daemon status; prefer indicator for presentation. */ readonly status: "needs_input" | "failed" | "running" | "attention" | "done"; readonly statusEnteredAt: string | null; readonly archivingAt: string | null; @@ -36,6 +53,10 @@ export interface PluginWorkspaceSnapshot { } export interface PluginAgentSnapshot { + readonly ref: AgentRef; + readonly hostId: string; + readonly workspace: WorkspaceRef; + readonly parentAgent: AgentRef | null; readonly id: string; readonly workspaceId: string; readonly provider: string; @@ -96,3 +117,22 @@ export interface PluginTimelineTransformResult { } export type PluginCleanup = () => void | Promise; + +export interface PluginProjectSnapshot { + readonly ref: ProjectRef; + readonly hostId: string; + readonly displayName: string; + readonly rootPath: string; + readonly kind: PluginWorkspaceSnapshot["projectKind"]; + readonly iconRevision: string | null; +} + +export interface PluginTerminalSnapshot { + readonly ref: TerminalRef; + readonly hostId: string; + readonly workspace: WorkspaceRef; + readonly title: string; + readonly isScript: boolean; + /** Null for terminals owned by workers started before timestamps were tracked. */ + readonly createdAt: string | null; +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 839b5003df..8680ae949f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -3,6 +3,9 @@ export type { PluginTheme, PluginWorkspaceSnapshot, PluginAgentSnapshot, + PluginProjectSnapshot, + PluginTerminalSnapshot, + WorkspaceIndicator, PluginThemeColors, PluginThemeContribution, PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/refs.ts b/packages/plugin/src/refs.ts new file mode 100644 index 0000000000..e0293bfe09 --- /dev/null +++ b/packages/plugin/src/refs.ts @@ -0,0 +1,11 @@ +interface EntityRef { + readonly kind: Kind; + readonly key: string; + readonly hostId: string; + readonly id: string; +} + +export interface WorkspaceRef extends EntityRef<"workspace"> {} +export interface AgentRef extends EntityRef<"agent"> {} +export interface ProjectRef extends EntityRef<"project"> {} +export interface TerminalRef extends EntityRef<"terminal"> {} diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts index 30e890b3ef..e670885678 100644 --- a/packages/plugin/src/settings.ts +++ b/packages/plugin/src/settings.ts @@ -4,10 +4,10 @@ import { defineRpc } from "./rpc.js"; export interface SettingsDefinition { id: string; - scope: "host"; + scope: "host" | "device"; version: number; schema: Schema; - /** Convert a previous stored version to the current schema. Runs on the host. */ + /** Convert a previous stored version to the current schema. Runs in the selected scope. */ migrate?: (values: unknown, fromVersion: number) => unknown | Promise; } @@ -15,7 +15,8 @@ export function defineSettings( definition: SettingsDefinition, ): SettingsDefinition { if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID"); - if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported"); + if (definition.scope !== "host" && definition.scope !== "device") + throw new Error("Invalid settings scope"); if (!Number.isSafeInteger(definition.version) || definition.version < 1) throw new Error("Invalid settings version"); return definition; diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 373cef409b..ebe7d9b7d8 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -571,3 +571,43 @@ test.each(["all", "base", "uncommitted"] as const)( expect(parsed.attachments).toEqual([attachment]); }, ); + +describe("terminal collection protocol compatibility", () => { + test("accepts released directory observations and their snapshots", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "subscribe_terminals_request", + cwd: "/repo", + requestId: "old", + }), + ).toMatchObject({ cwd: "/repo" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "terminals_changed", + payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] }, + }), + ).toMatchObject({ payload: { cwd: "/repo" } }); + }); + test("accepts capability-gated host observations and additive metadata", () => { + expect( + SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }), + ).toMatchObject({ requestId: "host" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "terminals_changed", + payload: { + terminals: [ + { + id: "terminal", + name: "Shell", + cwd: "/repo", + workspaceId: "workspace", + createdAt: null, + isScript: true, + }, + ], + }, + }), + ).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2899bf56cb..a6d05ca509 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3093,13 +3093,13 @@ export const ListTerminalsRequestSchema = z.object({ export const SubscribeTerminalsRequestSchema = z.object({ type: z.literal("subscribe_terminals_request"), requestId: z.string().optional(), - cwd: z.string(), + cwd: z.string().optional(), workspaceId: z.string().optional(), }); export const UnsubscribeTerminalsRequestSchema = z.object({ type: z.literal("unsubscribe_terminals_request"), - cwd: z.string(), + cwd: z.string().optional(), workspaceId: z.string().optional(), }); @@ -3770,6 +3770,7 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), + terminalCollection: z.boolean().optional(), // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. workspaceBaseBranch: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. @@ -6684,6 +6685,8 @@ export const ListCommandsResponseSchema = z.object({ // ============================================================================ const TerminalInfoSchema = z.object({ + createdAt: z.string().nullable().optional(), + isScript: z.boolean().optional(), id: z.string(), name: z.string(), cwd: z.string(), @@ -6747,8 +6750,8 @@ export const TerminalsChangedSchema = z.object({ subscriptionId: z.string().optional(), requestId: z.string().optional(), workspaceId: z.string().optional(), - cwd: z.string(), - terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + cwd: z.string().optional(), + terminals: z.array(TerminalInfoSchema.partial({ cwd: true })), }), }); diff --git a/packages/protocol/src/terminal-subscription-key.ts b/packages/protocol/src/terminal-subscription-key.ts index 648ff96f71..4d64dfff83 100644 --- a/packages/protocol/src/terminal-subscription-key.ts +++ b/packages/protocol/src/terminal-subscription-key.ts @@ -3,6 +3,11 @@ // and tear down its own live subscription without disturbing the other. An // absent workspaceId (old clients) keys to the cwd on its own. The `::` // separator cannot collide with a workspace id, which is always `wks_`. -export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string { +export function terminalSubscriptionKey( + cwd: string | undefined, + workspaceId: string | undefined, +): string { + if (cwd === undefined) + return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`; return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`; } diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 80c9409cef..b0c38bc985 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -1,5 +1,5 @@ import { resolveDaemonVersion } from "../daemon-version.js"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -269,3 +269,132 @@ test.runIf(process.platform !== "win32")( expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); }, ); + +test("host-wide terminal observation tracks every workspace, metadata and release", async () => { + const first = await createWorkspace("Observe first"); + const second = await createWorkspace("Observe second"); + expect(client.getLastServerInfoMessage()?.features?.terminalCollection).toBe(true); + const observation = client.observeTerminals({}); + let terminals = (await observation.ready).terminals; + const update = observation.subscribe({ + snapshot: (value) => { + terminals = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") terminals = message.payload.terminals; + }, + }); + try { + expect(terminals).toEqual([]); + const scoped = client.observeTerminals({ workspaceId: first }); + const scopedSnapshot = await scoped.ready; + let owned = scopedSnapshot.terminals; + const releaseScoped = scoped.subscribe({ + snapshot: (value) => { + owned = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") owned = message.payload.terminals; + }, + }); + // Use a controlled process: interactive shells publish their own OSC title on startup. + const command = { + command: process.execPath, + args: ["-e", "console.log('READY'); process.stdin.resume()"], + }; + const a = await sdk.terminals.create({ + workspaceId: first, + name: "First terminal", + ...command, + }); + const b = await sdk.terminals.create({ + workspaceId: second, + name: "Second terminal", + ...command, + }); + await expect + .poll(async () => (await a.capture({ stripAnsi: true })).lines.join("\n")) + .toContain("READY"); + await expect + .poll(async () => (await b.capture({ stripAnsi: true })).lines.join("\n")) + .toContain("READY"); + await expect.poll(() => terminals.map((item) => item.id).sort()).toEqual([a.id, b.id].sort()); + expect(terminals.every((item) => item.cwd === cwd && item.createdAt && !item.isScript)).toBe( + true, + ); + expect(terminals.map((item) => item.workspaceId).sort()).toEqual([first, second].sort()); + await expect.poll(() => owned.map((item) => item.id)).toEqual([a.id]); + releaseScoped(); + await scoped.release(); + await client.renameTerminal({ terminalId: a.id, title: "Renamed terminal" }); + await expect + .poll(() => terminals.find((item) => item.id === a.id)?.title) + .toBe("Renamed terminal"); + await b.kill(); + await expect.poll(() => terminals.map((item) => item.id)).toEqual([a.id]); + await a.kill(); + await expect.poll(() => terminals).toEqual([]); + } finally { + update(); + await observation.release(); + } +}, 60_000); + +test("terminal collection reports workspace script ownership after runtime registration", async () => { + await writeFile( + path.join(cwd, "paseo.json"), + JSON.stringify({ + scripts: { + sleeper: { + type: "script", + command: `node -e "const fs = require('node:fs'); fs.writeFileSync('sleeper.pid', String(process.pid)); setInterval(() => { if (fs.existsSync('finish-sleeper')) process.exit(0); }, 50)"`, + }, + }, + }), + ); + const workspaceId = await createWorkspace("Script observation"); + const observation = client.observeTerminals({}); + let terminals = (await observation.ready).terminals; + const release = observation.subscribe({ + snapshot: (value) => { + terminals = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") terminals = message.payload.terminals; + }, + }); + try { + expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect + .poll(() => + terminals.some((terminal) => terminal.workspaceId === workspaceId && terminal.isScript), + ) + .toBe(true); + const terminalId = terminals.find((terminal) => terminal.workspaceId === workspaceId)!.id; + const pidFile = path.join(cwd, "sleeper.pid"); + await expect + .poll(async () => Number(await readFile(pidFile, "utf8")), { timeout: 10_000 }) + .toBeGreaterThan(0); + const pid = Number(await readFile(pidFile, "utf8")); + await writeFile(path.join(cwd, "finish-sleeper"), "done"); + await expect.poll(() => isFixtureProcessRunning(pid), { timeout: 10_000 }).toBe(false); + // The program has exited; now close its interactive shell as well. + await sdk.terminals.ref(terminalId).kill(); + await expect + .poll(() => terminals.filter((terminal) => terminal.workspaceId === workspaceId)) + .toEqual([]); + } finally { + release(); + await observation.release(); + } +}, 60_000); + +function isFixtureProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 1b2e7192c7..1792a9d7ff 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -2122,7 +2122,7 @@ it.each(examples)("builds and loads the %s plugin example", async (example) => { const runtime = createTestRuntime( { settingsDirectory }, undefined, - resolveDaemonVersion(import.meta.url), + example === "sidebar-views" ? "0.16.0" : resolveDaemonVersion(import.meta.url), ); try { await runtime.startPlugin(example, path.join(examplesDirectory, example)); diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts index de81919eae..f47a8fb6ba 100644 --- a/packages/server/src/server/plugins/settings/index.test.ts +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -175,3 +175,11 @@ test("installation namespaces and definition IDs remain separate", async () => { expect(() => store.register(definition)).toThrow("Duplicate settings"); expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID"); }); + +test("device-scoped settings are owned by the client", async () => { + const { directory } = await setup(); + const store = new PluginSettingsStore(directory, () => {}); + expect(() => store.register(defineSettings({ ...definition, scope: "device" }))).toThrow( + "Device settings belong to the client", + ); +}); diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index 775a47f6a4..683eb3ab82 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -45,6 +45,7 @@ export class PluginSettingsStore { register(definition: SettingsDefinition) { defineSettings(definition); + if (definition.scope !== "host") throw new Error("Device settings belong to the client"); if (this.definitions.has(definition.id)) throw new Error(`Duplicate settings: ${definition.id}`); this.definitions.set(definition.id, definition); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 55d6c58401..43710fa35d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1187,6 +1187,12 @@ export class Session { isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), + isScriptTerminal: (terminal) => + this.scriptRuntimeStore + ?.listForWorkspace(terminal.workspaceId) + .some((script) => script.terminalId === terminal.id) ?? false, + subscribeTerminalMetadata: (listener) => + this.scriptRuntimeStore?.subscribe(listener) ?? (() => {}), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentDirectory = new AgentDirectory({ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 1bbc171f4a..c5f1303f77 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2038,6 +2038,7 @@ export class VoiceAssistantWebSocketServer { directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. ...(this.workspaceLabelService ? { workspaceLabels: true } : {}), + terminalCollection: true, // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, workspaceSetupObservation: true, diff --git a/packages/server/src/server/workspace-script-runtime-store.test.ts b/packages/server/src/server/workspace-script-runtime-store.test.ts index 625d1f2fe9..8b33d4ea53 100644 --- a/packages/server/src/server/workspace-script-runtime-store.test.ts +++ b/packages/server/src/server/workspace-script-runtime-store.test.ts @@ -97,3 +97,16 @@ describe("WorkspaceScriptRuntimeStore", () => { ]); }); }); + +it("notifies terminal metadata consumers when script ownership changes", () => { + const store = new WorkspaceScriptRuntimeStore(); + const observed: string[][] = []; + const release = store.subscribe(() => + observed.push(store.listForWorkspace("workspace-101").map((entry) => entry.terminalId)), + ); + store.set(createEntry()); + store.remove({ workspaceId: "workspace-101", scriptName: "web" }); + release(); + store.set(createEntry()); + expect(observed).toEqual([["terminal-1"], []]); +}); diff --git a/packages/server/src/server/workspace-script-runtime-store.ts b/packages/server/src/server/workspace-script-runtime-store.ts index a2f48199b6..f8f0ca0698 100644 --- a/packages/server/src/server/workspace-script-runtime-store.ts +++ b/packages/server/src/server/workspace-script-runtime-store.ts @@ -13,6 +13,17 @@ interface RuntimeEntryKey { } export class WorkspaceScriptRuntimeStore { + private readonly listeners = new Set<() => void>(); + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + private changed(): void { + for (const listener of this.listeners) listener(); + } + private readonly entries = new Map(); private readonly scriptsByWorkspace = new Map>(); @@ -31,6 +42,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.set(entryKey, { ...entry }); this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName); + this.changed(); } remove(key: RuntimeEntryKey): void { @@ -42,6 +54,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.delete(entryKey); this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName); + this.changed(); } listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] { @@ -70,6 +83,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.delete(this.toEntryKey(entry)); } this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId)); + this.changed(); } isRunning(key: RuntimeEntryKey): boolean { diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index d91eae6f06..c81cd2fbb5 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -392,8 +392,25 @@ describe("terminal-session-controller with terminals held by a newer Paseo", () }; } + test("reports incompatible workers to a legacy subscription without a request id", async () => { + const outboundMessages: SessionOutboundMessage[] = []; + const controller = createController({ + terminalManager: incompatibleManager(), + emit: (message) => outboundMessages.push(message), + emitBinary: () => {}, + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + }); + await controller.dispatch({ type: "subscribe_terminals_request" }); + expect(outboundMessages).toEqual([ + { type: "status", payload: { status: "error", message: incompatible.message } }, + ]); + }); + test.each([ { type: "list_terminals_request" as const, requestId: "req-list" }, + { type: "subscribe_terminals_request" as const, requestId: "req-observe" }, { type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" }, { type: "create_terminal_request" as const, @@ -505,8 +522,22 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, - { id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null }, + { + id: "root-term", + name: "Terminal 1", + workspaceId: "ws-test", + activity: null, + createdAt: null, + isScript: false, + }, + { + id: "subdir-term", + name: "Mobile", + workspaceId: "ws-test", + activity: null, + createdAt: null, + isScript: false, + }, ], }, }, @@ -576,6 +607,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { cwd: rootCwd, workspaceId: "ws-test", activity: null, + createdAt: null, + isScript: false, }, ], requestId: "req-root", @@ -592,6 +625,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { cwd: worktreeCwd, workspaceId: "ws-test", activity: null, + createdAt: null, + isScript: false, }, ], requestId: "req-worktree", @@ -677,7 +712,16 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { type: "terminals_changed", payload: { cwd, - terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }], + terminals: [ + { + id: "a", + name: "A", + workspaceId: "ws-a", + activity: null, + createdAt: null, + isScript: false, + }, + ], }, }, ]); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 653524e54a..d047ee0c20 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -38,13 +38,12 @@ import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { applyTerminalSize } from "./terminal-size-ownership.js"; import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; -import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; const MAX_TERMINAL_STREAM_SLOTS = 256; interface TerminalDirectorySubscription { - cwd: string; + cwd: string | undefined; workspaceId: string | undefined; owner: OwnedSubscription; refresh: Promise | null; @@ -87,6 +86,8 @@ export interface TerminalSessionControllerOptions { supportsChunkedReplay?: (source: object) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; + isScriptTerminal?: (terminal: TerminalSession) => boolean; + subscribeTerminalMetadata?: (listener: () => void) => () => void; listTerminalWorkspaceRefs?: () => Promise; listTerminalWorkspaceRoots?: () => Promise; // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and @@ -163,9 +164,17 @@ export class TerminalSessionController { start(): void { if (!this.terminalManager || this.unsubscribeTerminalsChanged) return; - this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => { + const releaseManager = this.terminalManager.subscribeTerminalsChanged((event) => { void this.handleTerminalsChanged(event); }); + const releaseMetadata = this.options.subscribeTerminalMetadata?.(() => { + for (const subscription of this.subscribedDirectories.values()) + void this.emitTerminalsSnapshotForSubscription(subscription); + }); + this.unsubscribeTerminalsChanged = () => { + releaseManager(); + releaseMetadata?.(); + }; } getMetrics(): TerminalSessionControllerMetrics { @@ -191,6 +200,7 @@ export class TerminalSessionController { ) { return false; } + if (subscription.cwd === undefined) return true; return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots); }); } @@ -302,15 +312,7 @@ export class TerminalSessionController { } } - private toTerminalInfo( - terminal: Pick, - ): { - id: string; - name: string; - workspaceId: string; - title?: string; - activity: TerminalActivity | null; - } { + private toTerminalInfo(terminal: TerminalSession) { const title = terminal.getTitle(); const activity = terminal.getActivity(); return { @@ -319,6 +321,8 @@ export class TerminalSessionController { workspaceId: terminal.workspaceId, ...(title ? { title } : {}), activity, + createdAt: terminal.createdAt ?? null, + isScript: this.options.isScriptTerminal?.(terminal) ?? false, }; } @@ -329,7 +333,8 @@ export class TerminalSessionController { // aggregated list — so the client's cache replacement doesn't drop the // terminals that live directly at the root. const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter( - (subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd), + (subscription) => + subscription.cwd === undefined || this.isPathWithinRoot(subscription.cwd, event.cwd), ); for (const subscription of matchingSubscriptions) { await this.emitTerminalsSnapshotForSubscription(subscription); @@ -368,6 +373,10 @@ export class TerminalSessionController { await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId); } catch (error) { await owner.release(); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } throw error; } } @@ -383,17 +392,26 @@ export class TerminalSessionController { const refresh = (async () => { do { subscription.pending = false; - const terminals = await this.getTerminalsForWorkspaceRoot( - subscription.cwd, - subscription.workspaceId, - ); + const terminals = + subscription.cwd === undefined + ? (await this.getAllTerminalSessions()).filter( + (terminal) => + subscription.workspaceId === undefined || + terminal.workspaceId === subscription.workspaceId, + ) + : await this.getTerminalsForWorkspaceRoot(subscription.cwd, subscription.workspaceId); if (subscription.owner.signal.aborted) return; subscription.owner.emit({ type: "terminals_changed", payload: { cwd: subscription.cwd, workspaceId: subscription.workspaceId, - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + terminals: terminals.map((terminal) => + Object.assign( + this.toTerminalInfo(terminal), + subscription.cwd === undefined ? { cwd: terminal.cwd } : {}, + ), + ), ...(requestId ? { requestId } : {}), }, }); @@ -464,9 +482,13 @@ export class TerminalSessionController { // An empty list or a generic failure would read as "no terminals"; the client needs to know // they exist and why it cannot reach them. private emitIncompatible( - msg: { type: string; requestId: string }, + msg: { type: string; requestId?: string }, error: TerminalWorkerIncompatibleError, ): void { + if (msg.requestId === undefined) { + this.emit({ type: "status", payload: { status: "error", message: error.message } }); + return; + } this.emit({ type: "rpc_error", payload: { @@ -482,6 +504,8 @@ export class TerminalSessionController { if (!this.terminalManager) { return []; } + const unavailable = this.terminalManager.getUnavailableError?.(); + if (unavailable) throw unavailable; const directories = this.terminalManager.listDirectories(); const manager = this.terminalManager; const terminalsByDirectory = await Promise.all( diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index b9eb4698c0..c61ead0628 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -263,7 +263,12 @@ function runWorker(argv: string[]): void { } function terminalMetadata(session: LocalTerminalSession) { - return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() }; + return { + ...session.getSize(), + createdAt: session.createdAt, + hasOutput: session.hasOutput(), + title: session.getTitle(), + }; } function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo { diff --git a/packages/server/src/terminal/terminal-worker-protocol.test.ts b/packages/server/src/terminal/terminal-worker-protocol.test.ts index 6ea9a823db..9193a5dabd 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.test.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; +import { + classifyTerminalWorkerProtocol, + TERMINAL_WORKER_PROTOCOL_VERSION, +} from "./terminal-worker-protocol.js"; describe("classifyTerminalWorkerProtocol", () => { it("rejects the unreleased cell protocol without a draining adapter", () => { @@ -23,3 +26,8 @@ describe("classifyTerminalWorkerProtocol", () => { expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit); }); }); + +it("drains v5 terminals while v6 supplies creation timestamps", () => { + expect(TERMINAL_WORKER_PROTOCOL_VERSION).toBe(6); + expect(classifyTerminalWorkerProtocol(5)).toBe("previous"); +}); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index e781543697..62e731a4dc 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -1,6 +1,8 @@ import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js"; import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js"; export interface WorkerTerminalState { + /** COMPAT(terminal-created-at): v6; v5 draining workers omit this, remove after 2027-06. */ + createdAt?: string; rows: number; cols: number; hasOutput: boolean; @@ -43,7 +45,7 @@ export interface WorkerCreateTerminalOptions { // `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new // terminals; it exits after its last terminal), shuts down anything older, and leaves a newer // worker running untouched. See docs/terminal-worker.md. -export const TERMINAL_WORKER_PROTOCOL_VERSION = 5; +export const TERMINAL_WORKER_PROTOCOL_VERSION = 6; // Test-only: the protocol version a worker reports, so tests can stand up older and newer // workers from one build. diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b09bc8bbf6..183be5e8bf 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -88,6 +88,8 @@ export interface TerminalActivityTransition { } export interface TerminalSession { + /** Absent on terminal workers started before creation timestamps were tracked. */ + createdAt?: string; id: string; name: string; cwd: string; @@ -1378,6 +1380,7 @@ export async function createTerminal( return { id, + createdAt: new Date().toISOString(), pid: ptyProcess.pid, name, cwd, diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 26cbb031d0..b13e63b074 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1666,19 +1666,19 @@ it.each([1, 4])( expect(manager.getTerminal(old.terminalId)).toBeUndefined(); const workers = await workerRecords(paseoHome); expect(workers).toHaveLength(1); - expect(workers[0]!.protocolVersion).toBe(5); + expect(workers[0]!.protocolVersion).toBe(6); }, ); it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => { const paseoHome = createTemporaryHome(); - const newer = await leaveWorkerWithTerminal(paseoHome, 6); + const newer = await leaveWorkerWithTerminal(paseoHome, 7); manager = await startRealWorkerManager({ paseoHome }); const unavailable = manager.getUnavailableError?.(); expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError); - expect(unavailable?.message).toContain("worker protocol 6"); + expect(unavailable?.message).toContain("worker protocol 7"); await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf( TerminalWorkerIncompatibleError, ); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 2592b0c89c..097def78d3 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -265,6 +265,9 @@ export function createWorkerTerminalManager( }; const session: TerminalSession = { + get createdAt() { + return record.state.createdAt; + }, get id() { return record.info.id; }, @@ -370,6 +373,10 @@ export function createWorkerTerminalManager( for (const listener of Array.from(record.titleChangeListeners)) { listener(manualTitle); } + emitTerminalsChanged({ + cwd: record.info.cwd, + terminals: listTerminalItemsForCwd(record.info.cwd), + }); }, getExitInfo(): TerminalExitInfo | null { return record.exitInfo; diff --git a/plugin-examples/sidebar-views/client/agents.tsx b/plugin-examples/sidebar-views/client/agents.tsx new file mode 100644 index 0000000000..8d0d469121 --- /dev/null +++ b/plugin-examples/sidebar-views/client/agents.tsx @@ -0,0 +1,34 @@ +import type { PluginAgentSnapshot } from "@getpaseo/plugin"; +import { ScrollView, View } from "react-native"; +import { useAgents, type PluginSidebarViewProps } from "@getpaseo/plugin/client"; +import { AgentRow, Heading } from "./rows"; +export function agentTask(agent: PluginAgentSnapshot) { + if (agent.attentionReason === "permission") return "Respond"; + if (agent.requiresAttention || agent.status === "error") return "Review"; + if (agent.status === "running" || agent.status === "initializing") return "Wait"; + return "Start"; +} +function Task({ title, view }: { title: string; view: PluginSidebarViewProps }) { + const agents = useAgents({ + where: (item) => agentTask(item) === title, + orderBy: ["lastActivityAt", "desc"], + }); + return ( + + + {agents.map((reference) => ( + + ))} + + ); +} +export function Agents(view: PluginSidebarViewProps) { + return ( + + + + + + + ); +} diff --git a/plugin-examples/sidebar-views/client/labels.tsx b/plugin-examples/sidebar-views/client/labels.tsx new file mode 100644 index 0000000000..da2e1ae7ab --- /dev/null +++ b/plugin-examples/sidebar-views/client/labels.tsx @@ -0,0 +1,191 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { + usePaseo, + useSettings, + useWorkspaces, + type WorkspaceRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { folders } from "../shared/preferences"; +import { Heading, WorkspaceRow } from "./rows"; +interface Move { + reference: WorkspaceRef; + from: string | null; +} +function folderLabel(key: string): string | null { + return key === "unlabelled" ? null : key.slice("label:".length); +} +function Movable({ + reference, + folder, + view, + select, +}: { + reference: WorkspaceRef; + folder: string | null; + view: PluginSidebarViewProps; + select: (move: Move) => void; +}) { + const pick = useCallback(() => select({ reference, from: folder }), [select, reference, folder]); + return ( + + + + + + + ); +} +export function Labels(view: PluginSidebarViewProps) { + const paseo = usePaseo(); + const settings = useSettings(folders); + const [selected, select] = useState(null); + const groups = useWorkspaces({ + orderBy: "name", + groupBy: (item) => + item.labels.length ? item.labels.map((label) => `label:${label}`) : ["unlabelled"], + }); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, paddingHorizontal: 12 }), + [view.theme], + ); + const move = useMutation({ + mutationFn: async (destination: string | null) => { + if (!selected) return; + const workspace = paseo.workspaces.ref(selected.reference); + // Add first: if adding fails the workspace keeps its original folder. + if (destination && destination !== selected.from) + await workspace.setLabel(destination, { assigned: true }); + if (selected.from && selected.from !== destination) + await workspace.setLabel(selected.from, { assigned: false }); + select(null); + }, + }); + const toggle = useCallback( + (folder: string) => { + if (settings.status !== "ready") return; + void settings.save( + { + collapsed: settings.values.collapsed.includes(folder) + ? settings.values.collapsed.filter((item) => item !== folder) + : [...settings.values.collapsed, folder], + }, + settings.revision, + ); + }, + [settings], + ); + const cancel = useCallback(() => select(null), []); + if (settings.status === "loading") return Loading folders…; + if (settings.status !== "ready") + return ( + + {settings.error} + + + + {settings.status === "invalid" ? ( + + + + ) : null} + + ); + return ( + + {selected ? ( + + Choose a destination folder + + + + + ) : ( + Long press a workspace or choose Move… + )} + {move.error ? {move.error.message} : null} + {Array.from( + new Set(["label:Inbox", "label:Later", "unlabelled", ...groups.map((group) => group.key)]), + ).map((folder) => ( + + {groups + .find((group) => group.key === folder) + ?.items.map((reference) => ( + + ))} + + ))} + {settings.saveError ? {settings.saveError} : null} + + ); +} +function Folder({ + folder, + view, + selected, + pending, + move, + collapsed, + toggle, + children, +}: { + folder: string; + view: PluginSidebarViewProps; + selected: boolean; + pending: boolean; + move: (folder: string | null) => void; + collapsed: boolean; + toggle: (folder: string) => void; + children: import("react").ReactNode; +}) { + const toggleFolder = useCallback(() => toggle(folder), [toggle, folder]); + const label = folderLabel(folder); + const title = label ?? "Unlabelled"; + const destination = useCallback(() => move(label), [move, label]); + return ( + + + + + {selected ? ( + + + + ) : null} + {collapsed ? null : children} + + ); +} diff --git a/plugin-examples/sidebar-views/client/nested.tsx b/plugin-examples/sidebar-views/client/nested.tsx new file mode 100644 index 0000000000..b6f4b84eb3 --- /dev/null +++ b/plugin-examples/sidebar-views/client/nested.tsx @@ -0,0 +1,109 @@ +import { ScrollView, View } from "react-native"; +import { + useProjects, + useWorkspaces, + useAgents, + useAgent, + type AgentRef, + type WorkspaceRef, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { AgentRow, ProjectHeading, WorkspaceRow } from "./rows"; +const indent = { paddingLeft: 12 }; +function AgentTree({ reference, view }: { reference: AgentRef; view: PluginSidebarViewProps }) { + const agent = useAgent(reference); + const children = useAgents({ + where: (item) => + item.parentAgent?.key === reference.key && item.workspace.key === agent?.workspace.key, + orderBy: "createdAt", + }); + return ( + + + + {children.map((child) => ( + + ))} + + + ); +} +function RootWithParent({ + reference, + parent, + workspace, + view, +}: { + reference: AgentRef; + parent: AgentRef; + workspace: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const parents = useAgents({ + where: (item) => item.ref.key === parent.key && item.workspace.key === workspace.key, + }); + return parents.length > 0 ? null : ; +} +function Root({ + reference, + workspace, + view, +}: { + reference: AgentRef; + workspace: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const agent = useAgent(reference); + if (!agent) return null; + return agent.parentAgent ? ( + + ) : ( + + ); +} +function Workspace({ reference, view }: { reference: WorkspaceRef; view: PluginSidebarViewProps }) { + const agents = useAgents({ + where: (item) => item.workspace.key === reference.key, + orderBy: "createdAt", + }); + return ( + + + + {agents.map((agent) => ( + + ))} + + + ); +} +function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) { + const workspaces = useWorkspaces({ + where: (item) => item.project.key === reference.key, + orderBy: "name", + }); + return ( + + + {workspaces.map((workspace) => ( + + ))} + + ); +} +export function Nested(view: PluginSidebarViewProps) { + const projects = useProjects({ orderBy: "displayName" }); + return ( + + {projects.map((reference) => ( + + ))} + + ); +} diff --git a/plugin-examples/sidebar-views/client/rows.tsx b/plugin-examples/sidebar-views/client/rows.tsx new file mode 100644 index 0000000000..ef55333f15 --- /dev/null +++ b/plugin-examples/sidebar-views/client/rows.tsx @@ -0,0 +1,139 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { + useWorkspace, + useAgent, + useProject, + type WorkspaceRef, + type AgentRef, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; + +export function Heading({ title, view }: { title: string; view: PluginSidebarViewProps }) { + const style = useMemo( + () => ({ + color: view.theme.colors.foregroundMuted, + fontSize: 11, + paddingHorizontal: 12, + paddingTop: 12, + paddingBottom: 4, + }), + [view.theme], + ); + return ( + + {title} + + ); +} +export function WorkspaceRow({ + reference, + view, + onLongPress, +}: { + reference: WorkspaceRef; + view: PluginSidebarViewProps; + onLongPress?: () => void; +}) { + const workspace = useWorkspace(reference); + const open = useCallback( + () => view.navigation.openWorkspace(reference), + [view.navigation, reference], + ); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, lineHeight: 16 }), + [view.theme], + ); + const detail = useMemo( + () => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }), + [view.theme], + ); + const background = useMemo( + () => ({ + ...rowStyle, + backgroundColor: + view.active.workspace?.key === reference.key ? view.theme.colors.surface2 : undefined, + }), + [view.active.workspace, view.theme, reference], + ); + if (!workspace) return null; + return ( + + + {workspace.title ?? workspace.name} + + + {workspace.indicatorLabel} + + + ); +} +export function AgentRow({ + reference, + view, +}: { + reference: AgentRef; + view: PluginSidebarViewProps; +}) { + const agent = useAgent(reference); + const open = useCallback( + () => view.navigation.openAgent(reference), + [view.navigation, reference], + ); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12 }), + [view.theme], + ); + if (!agent) return null; + return ( + + + {agent.title ?? agent.provider} + + + ); +} +export function ProjectHeading({ + reference, + view, +}: { + reference: ProjectRef; + view: PluginSidebarViewProps; +}) { + const project = useProject(reference); + const open = useCallback( + () => view.navigation.openProject(reference), + [view.navigation, reference], + ); + if (!project) return null; + return ( + + + + + + + ); +} +const rowStyle = { + minHeight: 28, + paddingHorizontal: 12, + paddingVertical: 4, + justifyContent: "center", +} as const; diff --git a/plugin-examples/sidebar-views/client/terminals.tsx b/plugin-examples/sidebar-views/client/terminals.tsx new file mode 100644 index 0000000000..2106e12130 --- /dev/null +++ b/plugin-examples/sidebar-views/client/terminals.tsx @@ -0,0 +1,131 @@ +import { Component, useCallback, useMemo, useState, type ReactNode } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { + useHosts, + useTerminals, + useTerminal, + usePaseo, + type TerminalRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { Heading } from "./rows"; + +class TerminalError extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ? {this.state.error.message} : this.props.children; + } +} +function TerminalRow({ + reference, + view, +}: { + reference: TerminalRef; + view: PluginSidebarViewProps; +}) { + const terminal = useTerminal(reference); + const paseo = usePaseo(); + const focus = useCallback(() => paseo.terminals.ref(reference).focus(), [paseo, reference]); + const close = useMutation({ mutationFn: () => paseo.terminals.ref(reference).kill() }); + const kill = useCallback(() => close.mutate(), [close]); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, padding: 12 }), + [view.theme], + ); + if (!terminal) return null; + return ( + + + + {terminal.title} + {terminal.isScript ? " (script)" : ""} + + + + Close terminal + + {close.error ? {close.error.message} : null} + + ); +} +function HostTerminals({ hostId, view }: { hostId: string; view: PluginSidebarViewProps }) { + const terminals = useTerminals({ hosts: [hostId], orderBy: "createdAt" }); + return ( + + {terminals.length ? ( + terminals.map((reference) => ( + + )) + ) : ( + + )} + + ); +} +function HostButton({ + hostId, + label, + select, + view, +}: { + hostId: string; + label: string; + select: (id: string) => void; + view: PluginSidebarViewProps; +}) { + const choose = useCallback(() => select(hostId), [select, hostId]); + return ( + + + + ); +} +export function Terminals(view: PluginSidebarViewProps) { + const hosts = useHosts(); + const [chosen, select] = useState(null); + const selected = chosen; + return ( + + {hosts.map((host) => ( + + ))} + {selected ? ( + + + + ) : ( + + )} + + ); +} diff --git a/plugin-examples/sidebar-views/client/workspaces.tsx b/plugin-examples/sidebar-views/client/workspaces.tsx new file mode 100644 index 0000000000..5c41eb4242 --- /dev/null +++ b/plugin-examples/sidebar-views/client/workspaces.tsx @@ -0,0 +1,37 @@ +import { ScrollView, View } from "react-native"; +import { + useProjects, + useWorkspaces, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { Heading, ProjectHeading, WorkspaceRow } from "./rows"; +function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) { + const workspaces = useWorkspaces({ + where: (item) => item.project.key === reference.key && !item.pinnedAt, + orderBy: "name", + }); + return ( + + + {workspaces.map((workspace) => ( + + ))} + + ); +} +export function Workspaces(view: PluginSidebarViewProps) { + const projects = useProjects({ orderBy: "displayName" }); + const pinned = useWorkspaces({ where: (item) => !!item.pinnedAt, orderBy: "name" }); + return ( + + {pinned.length ? : null} + {pinned.map((reference) => ( + + ))} + {projects.map((reference) => ( + + ))} + + ); +} diff --git a/plugin-examples/sidebar-views/index.client.tsx b/plugin-examples/sidebar-views/index.client.tsx new file mode 100644 index 0000000000..3cf194c8a2 --- /dev/null +++ b/plugin-examples/sidebar-views/index.client.tsx @@ -0,0 +1,18 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { Workspaces } from "./client/workspaces"; +import { Agents } from "./client/agents"; +import { Nested } from "./client/nested"; +import { Terminals } from "./client/terminals"; +import { Labels } from "./client/labels"; +export default function contribute(client: PluginClientContext) { + const cleanups = [ + client.addSidebarView({ id: "terminals", title: "Example terminals", Component: Terminals }), + client.addSidebarView({ id: "workspaces", title: "Example workspaces", Component: Workspaces }), + client.addSidebarView({ id: "agents", title: "Example agents", Component: Agents }), + client.addSidebarView({ id: "nested", title: "Example nested agents", Component: Nested }), + client.addSidebarView({ id: "labels", title: "Example label folders", Component: Labels }), + ]; + return () => { + for (const cleanup of cleanups) void cleanup(); + }; +} diff --git a/plugin-examples/sidebar-views/paseo-plugin.json b/plugin-examples/sidebar-views/paseo-plugin.json new file mode 100644 index 0000000000..1e66495a19 --- /dev/null +++ b/plugin-examples/sidebar-views/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "sidebar-views", + "requirements": { "paseo": ">=0.16.0" } +} diff --git a/plugin-examples/sidebar-views/shared/preferences.ts b/plugin-examples/sidebar-views/shared/preferences.ts new file mode 100644 index 0000000000..69b65eb397 --- /dev/null +++ b/plugin-examples/sidebar-views/shared/preferences.ts @@ -0,0 +1,8 @@ +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +export const folders = defineSettings({ + id: "folders", + scope: "device", + version: 1, + schema: z.object({ collapsed: z.array(z.string()).default([]) }), +}); diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index d4b247a16c..0293e3fb0d 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -73,6 +73,7 @@ Use this table as the complete registration checklist. | `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` | | `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) | | New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` | +| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) | | New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` | | `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | | Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` | diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 5e88cf9bf5..1489372e8b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -634,6 +634,13 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples. +## Sidebar views + +`addSidebarView({ id, title, description?, Component })` registers an optional replacement for the +sidebar content region. It returns cleanup. The user selects one view in Settings > Sidebar; +registration does not activate it. See [Build a sidebar view](sidebar-views.md) for the props, +ref-based queries and actions, device state and theme tokens. + ## Screens and sidebar items A screen is a full page of plugin UI. A sidebar item is a component Paseo renders in the sidebar @@ -1387,13 +1394,13 @@ export default function contribute(server: PluginServerContext) { } ``` -| Definition field | Contract | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | -| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. | -| `version` | Required positive integer describing the schema, independent of the write revision. | -| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | -| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | +| Definition field | Contract | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | +| `scope` | Required `"host"` or `"device"`. Host documents are shared by authorized clients. Device documents stay on this device, keyed by installation and settings ID; use them for layout preferences. | +| `version` | Required positive integer describing the schema, independent of the write revision. | +| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | +| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | Call `useSettings(preferences)` in any contributed component. It returns a discriminated state: diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index bb979f52e2..de3e486908 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -510,6 +510,10 @@ export default function contribute(client: PluginClientContext) { 使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。 +## 侧边栏视图 {#sidebar-views} + +`addSidebarView({ id, title, description?, Component })` 注册可替换侧边栏内容区域的视图,并返回清理函数。用户在设置的侧边栏页面中选择视图;注册不会激活它。视图属性、基于引用的查询与操作、设备状态和主题令牌见[构建侧边栏视图](sidebar-views.md)。 + ## 界面和侧边栏项 {#screens-and-sidebar-items} 界面是一整页的插件 UI。侧边栏项是 Paseo 在侧边栏顶部或底部渲染的组件;按下它时做什么由它决定。先注册界面,再注册一个打开它的项: @@ -1157,13 +1161,13 @@ export default function contribute(server: PluginServerContext) { } ``` -| 定义字段 | 契约 | -| ------------------------------ | --------------------------------------------------------------------------------------------- | -| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 | -| `scope` | 必须为 `"host"`。该主机所有获得授权的客户端共享这份文档。没有按用户、设备本地或跨主机的同步。 | -| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 | -| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 | -| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 | +| 定义字段 | 契约 | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 | +| `scope` | 必填 `"host"` 或 `"device"`。主机文档由授权客户端共享。设备文档以插件安装和设置 ID 为键保存在当前设备上,用于布局偏好。 | +| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 | +| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 | +| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 | 在任何贡献的组件中调用 `useSettings(preferences)`。它返回一个可区分的状态: diff --git a/public-docs/plugins/sidebar-views.md b/public-docs/plugins/sidebar-views.md new file mode 100644 index 0000000000..542c0f6166 --- /dev/null +++ b/public-docs/plugins/sidebar-views.md @@ -0,0 +1,177 @@ +# Build a sidebar view + +A sidebar view replaces the content between Paseo's header and footer. You receive theme tokens, +active entity refs and navigation methods, then render your own React Native UI. Install the +[sidebar-views example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views) +to try projects, task-based agents, nested agents and label folders. + +## Register a view + +```tsx +import type { + PluginClientContext, + PluginSidebarViewProps, + WorkspaceRef, +} from "@getpaseo/plugin/client"; +import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + navigation, + theme, +}: { + reference: WorkspaceRef; + navigation: PluginSidebarViewProps["navigation"]; + theme: PluginSidebarViewProps["theme"]; +}) { + const workspace = useWorkspace(reference); + if (!workspace) return null; + return ( + navigation.openWorkspace(reference)}> + {workspace.title ?? workspace.name} + + ); +} +function Workspaces({ navigation, theme }: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces }); +} +``` + +Registration adds an option under **Settings > Sidebar > Sidebar view**. The user selects it; +installing the plugin leaves the current view selected. Header navigation, plugin header items, +the callout and footer stay visible. A missing or disabled view falls back to Default view. +A view that throws shows a placeholder with **Use default view**. + +The view renders once across connected hosts. `active.workspace` and `active.agent` are refs or +`null`. Use `navigation.openWorkspace`, `openAgent`, `openTerminal` or `openProject` with a ref; +`openProject` starts the new workspace flow. Navigation closes the compact sidebar. Call +`dismiss()` after navigation you implement yourself. + +## Query refs, read each row + +`useWorkspaces`, `useAgents`, `useProjects` and `useTerminals` return immutable arrays of refs. +A ref has `kind`, `hostId`, `id` and a cross-host `key`. The same entity keeps the same frozen +ref for the app session. Pass it to `useWorkspace`, `useAgent`, `useProject` or `useTerminal` +inside the row. A deleted entity returns `null`. + +```tsx +const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects. +const agents = useAgents({ + hosts: [hostId], + where: (agent) => agent.workspace.key === workspaceRef.key, + orderBy: ["lastActivityAt", "desc"], +}); +const folders = useWorkspaces({ + groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders. +}); +``` + +Every query accepts `hosts` (server IDs), `where`, `orderBy` and `groupBy`. Omit `hosts` to query +every connected host. `orderBy` accepts a snapshot field, `[field, "asc" | "desc"]`, or a +comparator. Null fields sort last. Keep comparators cheap; they run when data changes. +`groupBy` returns a string, `{ key, title?, color? }`, or an array of these. Grouped results have +`key`, `title`, `color` and `items`. Groups follow first appearance after sorting; duplicate keys +use the first title. An empty array puts an item in no group. + +An unchanged ref sequence keeps its array identity. Groups keep their identity when their +metadata and item sequence stay the same. A workspace status change updates that row without +rendering the list again. A grouping change updates the affected groups. Inline query objects +and functions work without managing subscriptions yourself. + +Entity hooks accept a selector and optional equality function: + +```tsx +const workspace = useWorkspace(ref); // Interned snapshot, compared by identity. +const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality. +const selected = useWorkspace(ref, selector, equals); +``` + +Without a selector, custom equality receives snapshots. With a selector, it receives selections. +Existing workspace panels can continue passing a host-local string ID to `useWorkspace` and +`useAgent`. Prefer refs for a view spanning hosts. `useHosts()` retains its existing +`serverId`, `label` and connection `status` fields. + +Workspace snapshots include `project`, `pinnedAt`, `labels`, `background`, `branch`, +`lastPromptAt`, `parentAgent` and `createdAt` (null on older hosts). Use `indicator` and the +localized `indicatorLabel` to display the status Paseo shows. `status` remains the raw daemon +status. Agent snapshots include their workspace and parent refs. Project snapshots provide +`displayName`, `rootPath`, `kind` and `iconRevision`. + +## List terminals + +The terminals example selects a host, lists its terminals, focuses a terminal tab and closes a +terminal through its handle. `useTerminals({ hosts: [hostId] })` requires a host advertising +`terminalCollection`. A terminal reader throws an update-host error for an unsupported queried +host; other entity hooks continue working. Keep host selection outside an error boundary so the +user can select another host. Omit `hosts` to require the capability on every connected host. + +Terminal snapshots contain `workspace`, `title`, `isScript` and `createdAt`. A terminal kept alive +by a worker from before timestamp tracking has `createdAt: null`. Script ownership follows +workspace script runtime entries. The host observation follows terminal creation, closure, title +changes and reconnects, and releases when its last plugin subscriber leaves. + +## Act on a ref + +`usePaseo()` routes ref-based actions to the ref's host: + +```tsx +const paseo = usePaseo(); +await paseo.workspaces.ref(workspaceRef).setPinned(true); +await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true }); +await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false }); +paseo.projects.ref(projectRef).open(); +await paseo.projects.ref(projectRef).createWorkspace(); +paseo.terminals.ref(terminalRef).focus(); +await paseo.terminals.ref(terminalRef).kill(); +``` + +Workspace and agent handles retain their existing methods. Assigning a new label creates it; +assigning an existing label keeps its color. The label-folder example adds the destination +before removing the source, so a failed assignment preserves the original folder. It owns the +move gesture and error display. + +## Keep layout choices on the device + +Define a [settings document](reference.md#settings-screens) with `scope: "device"` for collapsed +folders or manual order. `useSettings` reads and writes it through the same revision-based API +as host settings. Device documents belong to the installation and settings ID, persist across +app launches, and are shared across that installation's client surfaces. They are not synced +to other devices. Use `scope: "host"` for data clients should share through the daemon. + +## Match the app's density + +Render with React Native primitives. The SDK exports no sidebar-view rows, sections, lists, +badges or drag helpers. The existing header/footer item kit is a separate surface. + +For a dense row use a minimum height of 28, horizontal padding of 12, a 12-point primary label +with 16-point line height and an 11-point secondary label. Use these supplied tokens: + +| Row element | Theme token | +| -------------------------------- | ------------------------ | +| Primary label | `colors.foreground` | +| Secondary label, section heading | `colors.foregroundMuted` | +| Selected row fill | `colors.surface2` | +| Separator | `colors.border` | +| Action or focus accent | `colors.accent` | +| Success indicator | `colors.statusSuccess` | +| Needs input indicator | `colors.statusWarning` | +| Failure indicator | `colors.statusDanger` | + +Give pressable rows an accessible label and show controls on touch layouts. The examples use +only plugin-owned components and the tokens passed to the view. diff --git a/public-docs/plugins/sidebar-views.zh-CN.md b/public-docs/plugins/sidebar-views.zh-CN.md new file mode 100644 index 0000000000..0c6ab89d33 --- /dev/null +++ b/public-docs/plugins/sidebar-views.zh-CN.md @@ -0,0 +1,135 @@ +# 构建侧边栏视图 {#build-a-sidebar-view} + +侧边栏视图替换 Paseo 页眉与页脚之间的内容。插件接收主题令牌、当前实体引用和导航方法,使用 React Native 绘制自己的界面。安装[侧边栏视图示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views),体验项目列表、按任务分组的 Agent、嵌套 Agent 和标签文件夹。 + +## 注册视图 {#register-a-view} + +```tsx +import type { + PluginClientContext, + PluginSidebarViewProps, + WorkspaceRef, +} from "@getpaseo/plugin/client"; +import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + navigation, + theme, +}: { + reference: WorkspaceRef; + navigation: PluginSidebarViewProps["navigation"]; + theme: PluginSidebarViewProps["theme"]; +}) { + const workspace = useWorkspace(reference); + if (!workspace) return null; + return ( + navigation.openWorkspace(reference)}> + {workspace.title ?? workspace.name} + + ); +} +function Workspaces({ navigation, theme }: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces }); +} +``` + +注册后,用户可以在**设置 → 侧边栏 → 侧边栏视图**中选择该视图。安装插件不会切换当前视图。页眉导航、插件页眉项、提示区域和页脚保持可见。所选视图缺失或被禁用时,显示默认视图。视图抛出异常时,显示带有**使用默认视图**按钮的占位界面。 + +视图同时展示所有已连接主机的数据。`active.workspace` 和 `active.agent` 是引用或 `null`。将引用传给 `navigation.openWorkspace`、`openAgent`、`openTerminal` 或 `openProject`;`openProject` 打开新建工作区流程。导航会关闭紧凑布局的侧边栏。如果插件自行执行导航,完成后调用 `dismiss()`。 + +## 查询引用,逐行读取 {#query-refs-read-each-row} + +`useWorkspaces`、`useAgents`、`useProjects` 和 `useTerminals` 返回不可变的引用数组。引用包含 `kind`、`hostId`、`id` 和跨主机唯一的 `key`。在一次应用会话中,同一实体使用同一个冻结引用。在行组件内将它传给 `useWorkspace`、`useAgent`、`useProject` 或 `useTerminal`。实体删除后返回 `null`。 + +```tsx +const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects. +const agents = useAgents({ + hosts: [hostId], + where: (agent) => agent.workspace.key === workspaceRef.key, + orderBy: ["lastActivityAt", "desc"], +}); +const folders = useWorkspaces({ + groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders. +}); +``` + +每个查询都支持 `hosts`(服务器 ID)、`where`、`orderBy` 和 `groupBy`。省略 `hosts` 时查询所有已连接主机。`orderBy` 接受快照字段、`[field, "asc" | "desc"]` 或比较函数。空值排在最后。比较函数会在数据变化时执行,应保持开销较小。 + +`groupBy` 返回字符串、`{ key, title?, color? }` 或这些值组成的数组。分组结果包含 `key`、`title`、`color` 和 `items`。分组按排序后首次出现的顺序排列;重复的键使用第一个标题。返回空数组表示该项不属于任何分组。 + +引用序列不变时,数组对象保持不变。分组的元数据和项序列不变时,分组对象保持不变。工作区状态变化只更新对应行,不会重新渲染整个列表。分组变化会更新受影响的分组。查询对象和函数可以内联编写,无需自行管理订阅。 + +实体钩子支持选择器和可选的相等比较函数: + +```tsx +const workspace = useWorkspace(ref); // Interned snapshot, compared by identity. +const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality. +const selected = useWorkspace(ref, selector, equals); +``` + +没有选择器时,自定义相等比较函数接收快照;有选择器时接收选择结果。现有工作区面板仍可向 `useWorkspace` 和 `useAgent` 传入主机本地的字符串 ID。跨主机视图应使用引用。`useHosts()` 保留现有的 `serverId`、`label` 和连接 `status` 字段。 + +工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。 + +## 列出终端 {#list-terminals} + +终端示例支持选择主机、列出终端、聚焦终端标签页,以及通过句柄关闭终端。`useTerminals({ hosts: [hostId] })` 要求主机声明 `terminalCollection` 能力。查询不支持该能力的主机时,终端读取会抛出要求更新主机的错误;其他实体钩子继续工作。将主机选择器放在错误边界之外,方便用户切换主机。省略 `hosts` 时,所有已连接主机都必须支持该能力。 + +终端快照包含 `workspace`、`title`、`isScript` 和 `createdAt`。在时间戳跟踪引入前启动的 worker 所保留的终端,其 `createdAt` 为 null。脚本归属来自工作区脚本运行时条目。主机观察跟踪终端创建、关闭、标题变化和重新连接,并在最后一个终端读取订阅者离开时释放。 + +## 通过引用执行操作 {#act-on-a-ref} + +`usePaseo()` 将基于引用的操作发送到引用所属的主机: + +```tsx +const paseo = usePaseo(); +await paseo.workspaces.ref(workspaceRef).setPinned(true); +await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true }); +await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false }); +paseo.projects.ref(projectRef).open(); +await paseo.projects.ref(projectRef).createWorkspace(); +paseo.terminals.ref(terminalRef).focus(); +await paseo.terminals.ref(terminalRef).kill(); +``` + +工作区和 Agent 句柄保留现有方法。分配新标签会创建该标签;分配已有标签会保留其颜色。标签文件夹示例先添加目标标签,再移除源标签,因此分配失败时仍保留原文件夹。插件负责移动手势和错误显示。 + +## 在设备上保存布局选择 {#keep-layout-choices-on-the-device} + +定义 `scope: "device"` 的[设置文档](reference.md#settings-screens),保存折叠文件夹或手动排序。`useSettings` 通过与主机设置相同的修订版本 API 读取和写入。设备文档属于插件安装和设置 ID,在应用重启后保留,并由该安装的客户端界面共享。它们不会同步到其他设备。需要由客户端通过 daemon 共享的数据使用 `scope: "host"`。 + +## 匹配应用的信息密度 {#match-the-apps-density} + +使用 React Native 原语绘制界面。SDK 不导出侧边栏视图的行、分区、列表、徽标或拖拽辅助组件。现有页眉和页脚组件包属于独立界面。 + +紧凑行的最小高度为 28,水平内边距为 12;主标签字号为 12、行高为 16,次标签字号为 11。使用以下主题令牌: + +| 行元素 | 主题令牌 | +| ---------------- | ------------------------ | +| 主标签 | `colors.foreground` | +| 次标签、分区标题 | `colors.foregroundMuted` | +| 选中行背景 | `colors.surface2` | +| 分隔线 | `colors.border` | +| 操作或焦点强调色 | `colors.accent` | +| 成功指示 | `colors.statusSuccess` | +| 需要输入指示 | `colors.statusWarning` | +| 失败指示 | `colors.statusDanger` | + +为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。