From b41e813cb5b7ed60eaeaa8e65a9933a31d036e21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:15:31 -0700 Subject: [PATCH] fix(native-chat): surface draft launch context in desktop and mobile chat composers (#9802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): surface draft launch context in chat composers Creating a workspace from a GitHub issue delivers the issue link only into the agent TUI's input buffer (argv prefill or startup paste), so the chat view showed no trace of it on desktop or mobile. Desktop: draft launches now seed an in-memory launch draft keyed by tab id (direct work-item launches, background GitHub work-item creates, quick-create composer, and new-tab draft deliveries). The chat composer adopts the seed once as its editable draft, declines permanently if the composer already has text, and drops an untouched copy when any user turn lands (the one-line TUI input means the prefill was submitted or deliberately cleared) or on its own send, whose existing input pre-clear retires the TUI copy. Mobile: the host publishes the draft as an optional launchDraft field on the mobile terminal tab snapshot (additive, no protocol bump) and the mobile composer adopts it with the same once-only/decline/resolve semantics. Mobile chat sends now also pre-clear the TUI input line (Ctrl+U, desktop parity) so a pending prefill cannot concatenate with the sent message. Completion seeding resolves the launch tab from the synced store tabs when the backend spawned the terminal and activation reports no primaryTabId. Split the Windows shell-quoting tests into their own file to stay within the max-lines budget. * revert(mobile): drop incidental pnpm-lock churn from the launch-draft branch The libc binding fields and the @typescript-eslint peer re-resolution came from a local install, not from this change; mobile/package.json is untouched. * fix(native-chat): resolve launch drafts without trusting cross-host clocks The rule required a user turn stamped at or after the seed. Grok omits row timestamps, so a Grok launch draft never resolved; and the seed time is a renderer clock while the stamp comes from the executing host's JSONL, so a remote workspace whose clock trailed never resolved either. Both left the composer adopting an already-submitted prefill, which re-sends it as a duplicate turn. Resolve on any user turn that is not PROVABLY older than the seed (a launch draft's session starts with zero user turns), with the existing cross-host skew slack, plus a timestamp-free backstop for wider skew: a new tail user turn since the draft was first observed. "Load earlier" prepends, so it cannot move the tail and cannot over-resolve. Split out of native-chat-pending.ts to stay under the max-lines ratchet. * fix(worktrees): seed the launch draft on the agent's own tab, never on tabs[0] Two defects in the completion seed: - The tab was resolved by array position. buildStartupOpt returns undefined on the backend-spawn path, so applyDefaultTerminalTabs stamps launchAgent on no tab and the launchAgent guard was dead there. A repo with default terminal tabs ("dev server", "logs", ...) got the draft on a tab that runs no agent, and then published it to mobile as THAT tab's launchDraft. Correlate on the backend startup tab, then on a launchAgent-stamped tab, then on primaryTabId (which is the agent tab whenever the renderer owns startup); never tabs[0]. - Runtime-owned worktrees mirror their session tabs async, so tabsByWorktree was empty at seed time and the seed was silently dropped for that whole host class. Defer to the first mirrored tab via the existing delayed-delivery queue, which now holds every pending delivery for a worktree instead of one (setup/issue commands and the seed both wait on the same first tab). * fix(store): evict nativeChatLaunchDraftByTabId on every teardown path The new map was absent from all four paths its sibling nativeChatLaunchPromptByTabId participates in: tab close, the orphan terminal sweep, the bulk worktree purge, and the removeWorktree teardown. A stranded entry is worse than a plain leak here because sync-runtime-graph keeps publishing it to mobile as that tab's launchDraft. * fix(native-chat): only seed single-line unsubmitted launch drafts The unsubmitted-delivery branch seeded on every draft delivery, which also caught the agent-session-fork path whose prompt is multi-line scraped context. The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a multi-line prefill cannot be fully cleared and its earlier lines would glue onto the next message. The GitHub work-item draft this feature targets is a bare issue URL, so narrowing costs it nothing. Also assert the composer retires the seed after a send — deleting that call previously failed no test. * fix(mobile): stop the chat pre-clear from wiping a just-pasted image The text write set clearInputFirst unconditionally. On the image path that Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image, so the agent receives the text alone while acceptSend still renders the thumbnail on the sent bubble — silent image loss. Desktop's image path clears exactly once, before the paste, and never again; mobile now matches: pre-clear only when nothing was deliberately pasted first. The image paste already leads with its own Ctrl+U, so a launch-draft prefill parked on the input line still cannot glue onto the message. Pinned at both levels: the controller test drives the real send hook and asserts clearInputFirst per branch, and the send module asserts the wire text carries no leading \x15. The image-attachments test injects its own baseSend, so it structurally could not observe this. * fix(mobile): hold the launch-draft prefill until the transcript settles session.tabs delivers launchDraft before the transcript read resolves, so the seed effect could run against an empty in-flight message list and miss the user-turn decline. Launching from an issue, submitting the prefill in the TUI, and never opening desktop chat (nothing else clears the host seed) then prefilled the mobile composer with the already-sent issue link — a send tapped before it retracted duplicated it to the agent. Thread the session's loading state through and skip the seed while the read is in flight. idle/waiting-session still seed: no session means no user turns. * fix(runtime): publish a launch draft to mobile only for the tab's own agent The publish had no agent check while the desktop consumer declines on mismatch. The seed is keyed by tab id, which survives a pane's agent switch, so mobile could adopt a draft desktop refuses — seed for claude, never open desktop chat, switch the pane to Codex, and mobile prefills the Codex chat with the Claude-era issue link. Align publish with the consumer. * fix(native-chat): take the launch-draft baseline only after the transcript loads The timestamp-free backstop snapshotted the transcript's user turns on first observation of the draft, which can happen while the read is still in flight and `messages` is []. A pane bound to a session that already had user turns then backfilled above that zero baseline with a different tail id, so clause 2 resolved and silently dropped the seed — the launch context never appeared, and the feature no-oped for exactly the panes it was meant to serve. Clause 1 was already correct there (that history is provably older than the seed). Gate baseline capture and resolution on the transcript read settling, the same shape mobile's drafts hook uses. Clause 1 is unchanged; while loading the merged list is empty anyway, and a pane with live appends is never reported 'loading'. Also restore clause 1's short-circuit: it scans with .some() again and only allocates the user-turn list when falling through to the backstop. NativeChatView sat at exactly the 400-line cap, so the composer's two launch-draft props are now spread from the hook result they already mirror. * fix(native-chat): reject multi-line launch drafts inside the seed helper The single-line guard lived in deliverLaunchPromptToAgentTab, so the two other seeding entry points (worktree create, direct work-item launch) bypassed it — and every Linear launch is multi-line by construction ("Linked Linear issue: STA-…" + url). The chat send pre-clears the TUI with Ctrl+U, which kills to start of LINE, so those earlier lines stay parked to glue onto the next message. * fix(worktrees): keep the deferred agent seed off ambiguous mirrored tabs The runtime-owned deferred path fell back to tabs[0], which the module's own docstring forbids: with repo default tabs ("dev server", "logs") the seed lands on a tab running no agent, where mobile withholds it and desktop's agent check ignores it — the feature is silently dead for that create and the entry leaks until tab close. The queue entry is consumed before delivery, so there is no retry to fall back on; accept the first mirrored tab only when it is the worktree's only one and so unambiguously the agent's. * fix(mobile): treat a launch-draft-only session-tab frame as a change mobileSessionTabEqual's terminal branch never compared launchDraft, and the route keeps `prev` when tabs compare equal — so a publish whose only delta is the draft appearing or retracting was discarded and never reached the composer. Live QA passed only because agentStatus happened to change in the same frame. MobileSessionTab's terminal variant did not declare the field either (the controller read it through the structurally wider MobileNativeChatTab), which is why TypeScript never flagged it. * fix(mobile): judge a launch prefill only from its own settled transcript Two ways the drafts hook was reading a transcript that was not the active chat's: - transcriptLoading came from `status`, a plain useState written by a passive effect declared before the drafts hook. On the commit where the tab identity changes it still holds the previous tab's value, so the guard was off on exactly the render that seeds: first entry saw status 'idle' with an empty list and seeded an already-submitted link, and a tab switch declined the new tab's prefill from the old tab's turns. The session hook now tracks the identity its messages describe and reports transcriptLoading until they agree; the retire effect gates on it too. - Leaving chat view nulled launchDraft while draftKey stayed the same, which the hook could not tell from a host retraction — it declined the prefill permanently, so peeking at the terminal dropped the context. The controller now passes the raw field plus an explicit chatActive flag, and both effects hold their state when the tab is not on chat. The controller wiring was previously unasserted: replacing both props with constants left all 795 mobile session tests green. * fix(native-chat): keep the launch-draft baseline across a transcript reload baselineKey went null whenever the transcript was loading, and the null branch DISCARDED an already-valid baseline taken from a settled read. It was then re-taken from the fuller list, swallowing the very user turn that resolves the draft — so a stale prefill gets re-adopted as a duplicate turn. Key the baseline on draft identity alone and gate only the capture. session.status is also not a truthful read-in-flight signal: a live 'working' hook outranks 'loading', so the guard could be off over an in-flight empty list. Expose the read phase itself and gate on that. * test: cover the launch-draft reducers and the sync-key skip gate Every consumer test injects the three launch-draft reducers as bare vi.fn()s, so reducing markNativeChatLaunchDraftAdopted to a no-op left 2609 tests green — while in the app the composer would resurrect the prefill after every manual clear. canSkipRuntimeMobileSessionSyncKeyBuild had no launch-draft case either: when it skips, the sync key is never even built, so the existing getRuntimeMobileSessionSyncKey case cannot catch its removal. * fix(native-chat): hold the launch-draft baseline in state, not a render-mutated ref react-compiler rejects reading or writing a ref during render. Adjust the held baseline with the sanctioned render-time setState instead, keeping the local copy so the render that first sees a settled transcript resolves against it. * fix(mobile): carry the transcript identity in the session read state react-doctor flags the separate loadedIdentity state as an extra render for a derivable value. Hold status alongside the identity it describes in one state written by the subscription effect, so transcriptLoading derives from it. * test(native-chat): assert the readPhase contract without the hook-status race The test asserted status === 'working', which depends on liveStatusOverride winning over ambient transcript state — green locally, red under CI load. The contract is that readPhase stays 'loading' once live content unmasks status, so assert exactly that; it still fails if readPhase derives from status. * fix(mobile): derive pre-read chat status instead of writing it from the effect react-doctor's no-derived-state-effect flags idle/waiting-session/loading being set in the subscription effect: all three are pure functions of the props. Derive them during render and keep state only for the genuinely async outcome, tagged with the identity it describes. The tag now gates `messages` too, so a just-switched tab never sees the previous tab's transcript at all rather than seeing it behind a loading flag. * fix(mobile): drop a settled chat read once its subscription is torn down The settled outcome was only ever replaced by a newly arriving frame, so any effect re-run that landed back on an already-settled identity resurfaced it over a list the same effect had just cleared: 'ready' with no messages and transcriptLoading false. Toggling out of chat view and back hit this every time (the agent goes null, then returns), flashing the "start a chat" empty state over a real conversation and opening the launch-draft seed's decline check on an empty transcript. A reconnect did the same via the client dep. Identity and client are the effect's only inputs, so tagging the read with both and dropping it during render when either moves covers every re-run. --- .../session/mobile-session-route-types.ts | 2 + .../session/mobile-native-chat-eligibility.ts | 2 + .../session/mobile-native-chat-send.test.ts | 46 +++ mobile/src/session/mobile-native-chat-send.ts | 8 +- .../session/mobile-terminal-records.test.ts | 23 ++ mobile/src/session/mobile-terminal-records.ts | 5 + .../use-mobile-native-chat-controller.test.ts | 159 ++++++++- .../use-mobile-native-chat-controller.ts | 8 +- ...le-native-chat-drafts-launch-draft.test.ts | 310 ++++++++++++++++++ .../use-mobile-native-chat-drafts.test.ts | 13 +- .../session/use-mobile-native-chat-drafts.ts | 77 ++++- .../use-mobile-native-chat-message-send.ts | 7 + .../use-mobile-native-chat-session.test.ts | 140 ++++++++ .../session/use-mobile-native-chat-session.ts | 59 +++- src/main/runtime/orca-runtime.ts | 1 + .../native-chat/NativeChatComposer.test.tsx | 24 +- .../native-chat/NativeChatComposer.tsx | 18 +- .../components/native-chat/NativeChatView.tsx | 11 + .../native-chat/native-chat-composer-types.ts | 5 + ...ative-chat-launch-draft-resolution.test.ts | 87 +++++ .../native-chat-launch-draft-resolution.ts | 58 ++++ .../native-chat/native-chat-live-status.ts | 2 +- ...native-chat-launch-draft-adoption.test.tsx | 257 +++++++++++++++ .../use-native-chat-launch-draft-adoption.ts | 106 ++++++ .../use-native-chat-live-session.test.ts | 20 ++ .../use-native-chat-live-session.ts | 8 +- src/renderer/src/hooks/useComposerState.ts | 1 + .../lib/agent-launch-prompt-delivery.test.ts | 119 ++++++- .../src/lib/agent-launch-prompt-delivery.ts | 30 ++ .../lib/github-work-item-background-create.ts | 14 +- .../github-work-item-background-request.ts | 6 +- .../lib/hook-command-delayed-delivery.test.ts | 18 + .../src/lib/hook-command-delayed-delivery.ts | 20 +- ...h-agent-in-new-tab-windows-quoting.test.ts | 300 +++++++++++++++++ .../src/lib/launch-agent-in-new-tab.test.ts | 153 +-------- .../src/lib/launch-work-item-direct.test.ts | 60 ++++ .../src/lib/launch-work-item-direct.ts | 12 + .../src/lib/native-chat-launch-prompt.ts | 14 + .../src/lib/pending-worktree-creation.ts | 3 + .../lib/worktree-creation-agent-seeds.test.ts | 215 ++++++++++++ .../src/lib/worktree-creation-agent-seeds.ts | 96 ++++++ .../src/lib/worktree-creation-flow.test.ts | 98 +++++- .../src/lib/worktree-creation-flow.ts | 19 +- .../src/runtime/sync-runtime-graph.test.ts | 109 ++++++ .../src/runtime/sync-runtime-graph.ts | 13 + .../native-chat-launch-draft-teardown.test.ts | 139 ++++++++ .../store/slices/terminal-orphan-helpers.ts | 6 + src/renderer/src/store/slices/terminals.ts | 46 ++- src/renderer/src/store/slices/worktrees.ts | 4 + src/shared/runtime-types.ts | 3 + 50 files changed, 2744 insertions(+), 210 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts create mode 100644 src/renderer/src/lib/worktree-creation-agent-seeds.test.ts create mode 100644 src/renderer/src/lib/worktree-creation-agent-seeds.ts create mode 100644 src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts index da4d6096c..219dd58dc 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -26,6 +26,8 @@ export type MobileSessionTab = /** Agent Orca launched in this terminal, if any. This makes chat eligible * before the first live agent-status update reaches the mobile client. */ launchAgent?: TuiAgent + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string terminalTheme?: MobileTerminalTheme isActive: boolean } diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 84e0e00d0..988e56c67 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -25,6 +25,8 @@ export type MobileNativeChatTab = { type: string launchAgent?: string | null agentStatus?: AgentStatusEntry | null + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string } /** Resolve a session tab to the transcript identity native chat needs, or diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 2f4e5fb9b..1f3d57985 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -169,6 +169,52 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe('rejected') }) + it('prepends the input-line clear byte when clearInputFirst is set', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + clearInputFirst: true + }) + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: '\x15hello', + enter: true + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) + }) + + it('sends the text verbatim when clearInputFirst is not set', async () => { + // An image send pastes the image (behind its own leading Ctrl+U) before this + // text write; a clear byte here would kill the pasted image off the input line. + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'what is this', + clearInputFirst: false + }) + const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string } + expect(sent.text).toBe('what is this') + expect(sent.text.startsWith('\x15')).toBe(false) + }) + it('sends a single non-submitting Escape for prompt cancellation', async () => { const client = clientWithResponse({ id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 6bce66e9b..ec8a0f978 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -8,11 +8,17 @@ type MobileTerminalClient = { type: 'mobile' } +// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the +// same byte before its body), so a launch-context prefill parked there cannot +// concatenate with a mobile chat message. The host writes text bytes verbatim. +const CLEAR_UNSUBMITTED_INPUT = '\x15' + type MobileNativeChatSendArgs = { client: RpcClient terminal: string text: string enter?: boolean + clearInputFirst?: boolean mobileClient?: MobileTerminalClient /** Shared budget for a whole user action (heal → paste → text, or one selector's * keystroke sequence). Omit to give this write its own full budget. */ @@ -51,7 +57,7 @@ export async function sendMobileNativeChatMessageWithOutcome( 'terminal.send', { terminal: args.terminal, - text: args.text, + text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, ...(args.mobileClient ? { client: args.mobileClient } : {}) }, diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index 0ebd2c556..fba077426 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -87,6 +87,29 @@ describe('mobile terminal records', () => { ).toEqual([]) }) + it('treats a launch draft appearing or retracting as a session-tab change', () => { + // The route keeps `prev` when these compare equal, so a frame whose only + // delta is the draft would never reach the chat composer. + const base: MobileTerminalSessionTab = { + type: 'terminal', + id: 'term-1::leaf-1', + parentTabId: 'term-1', + leafId: 'leaf-1', + title: 'Claude', + status: 'ready', + terminal: 'pty-1', + isActive: true + } + const seeded: MobileTerminalSessionTab = { + ...base, + launchDraft: 'https://github.com/o/r/issues/12' + } + + expect(mobileSessionTabsEqual([base], [seeded])).toBe(false) + expect(mobileSessionTabsEqual([seeded], [base])).toBe(false) + expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true) + }) + it('treats terminal agent-status changes as session-tab changes', () => { const base: MobileTerminalSessionTab = { type: 'terminal', diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index ffe93ac29..07255ffbf 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -17,6 +17,8 @@ export type MobileTerminalSessionTab = { status?: 'pending-handle' | 'ready' terminal: string | null agentStatus?: AgentStatusEntry | null + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string terminalTheme?: MobileTerminalTheme isActive: boolean } @@ -84,6 +86,9 @@ function mobileSessionTabEqual( a.leafId === b.leafId && a.status === b.status && a.terminal === b.terminal && + // A frame whose only delta is the launch draft appearing or retracting + // still has to reach the chat composer. + a.launchDraft === b.launchDraft && JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) && JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null) ) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 7118d2332..db285aecb 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -10,25 +10,37 @@ const clearDraftForSend = vi.fn() const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() +// Mutable stand-ins so the launch-draft wiring below can drive chat resolution +// and transcript state; defaults keep the send-seam tests unchanged. +const viewMode = { isTabChatView: (_tabId: string) => true } +const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } +const draftsArgs: Record[] = [] + // The controller composes many session hooks; each is mocked to a minimal shape // so this test isolates the send seam (outcome -> drafts accounting). vi.mock('./use-mobile-session-view-mode', () => ({ - useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() }) + useMobileSessionViewMode: () => ({ + isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId), + toggleTabChatView: vi.fn() + }) })) vi.mock('./use-mobile-native-chat-session', () => ({ - useMobileNativeChatSession: () => ({ messages: [] }) + useMobileNativeChatSession: () => sessionState })) vi.mock('./use-mobile-native-chat-drafts', () => ({ - useMobileNativeChatDrafts: () => ({ - composerText: '', - setComposerText: vi.fn(), - pending: [], - captureSendOrigin, - clearDraftForSend, - restoreRejectedDraft, - acceptSend, - holdUnconfirmedSend - }) + useMobileNativeChatDrafts: (args: Record) => { + draftsArgs.push(args) + return { + composerText: '', + setComposerText: vi.fn(), + pending: [], + captureSendOrigin, + clearDraftForSend, + restoreRejectedDraft, + acceptSend, + holdUnconfirmedSend + } + } })) vi.mock('./use-mobile-native-chat-prompts', () => ({ useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null }) @@ -213,6 +225,27 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + it('pre-clears the input line for a text-only send but never for an image send', async () => { + // The image path pastes the image behind its OWN leading Ctrl+U and then calls + // this send; a second clear here wipes the image off the input line and the + // agent receives text alone while the echo bubble still shows the thumbnail. + sendWithOutcome.mockResolvedValue('accepted') + + await act(async () => { + await controller!.handleNativeChatSend('answer') + }) + expect(sendWithOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ text: 'answer', clearInputFirst: true }) + ) + + await act(async () => { + await controller!.handleNativeChatSend('look', ['file:///a.jpg']) + }) + expect(sendWithOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ text: 'look', clearInputFirst: false }) + ) + }) + it('holds an unknown-outcome send without posting the optimistic echo', async () => { sendWithOutcome.mockResolvedValue('unknown') let accepted = false @@ -282,3 +315,105 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(onSendError).toHaveBeenCalledWith('Message not sent') }) }) + +describe('useMobileNativeChatController launch-draft wiring', () => { + let renderer: ReactTestRenderer | null = null + const clientStub = { sendRequest: vi.fn() } + + const chatTab = { + type: 'terminal', + id: 'tab-1', + title: 'Claude', + terminal: 'term-1', + launchAgent: 'claude', + launchDraft: 'https://github.com/o/r/issues/12', + isActive: true + } + + function Harness({ tab }: { tab: unknown }): null { + useMobileNativeChatController({ + client: clientStub as unknown as RpcClient, + connState: 'connected', + hostId: 'h', + worktreeId: 'w', + activeSessionTab: tab as never, + activeSessionTabId: 'tab-1', + activeHandleRef: { current: 'term-1' }, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + onSendError: vi.fn(), + onSendResolved: vi.fn() + }) + return null + } + + function render(tab: unknown): void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { + if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { + return + } + original(...a) + }) + try { + act(() => { + renderer = create(createElement(Harness, { tab })) + }) + } finally { + spy.mockRestore() + } + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + draftsArgs.length = 0 + viewMode.isTabChatView = () => true + sessionState.messages = [] + sessionState.status = 'ready' + sessionState.transcriptLoading = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('forwards the tab launch draft and chat-active flag for a chat-resolved tab', () => { + render(chatTab) + + expect(draftsArgs.at(-1)).toMatchObject({ + tabId: 'tab-1', + launchDraft: 'https://github.com/o/r/issues/12', + chatActive: true, + transcriptLoading: false + }) + }) + + it('forwards the raw draft with chatActive false when the tab shows the terminal', () => { + // Nulling the draft off-chat is indistinguishable from a host retraction and + // permanently declines the prefill; the flag is what keeps them apart. + viewMode.isTabChatView = () => false + render(chatTab) + + expect(draftsArgs.at(-1)).toMatchObject({ + launchDraft: 'https://github.com/o/r/issues/12', + chatActive: false + }) + }) + + it('forwards the session hook’s transcriptLoading, not its status', () => { + // 'working' masks 'loading' in status, so only the read-phase signal is honest. + sessionState.status = 'working' + sessionState.transcriptLoading = true + render(chatTab) + + expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true }) + }) + + it('forwards a null draft for a tab that publishes none', () => { + render({ ...chatTab, launchDraft: undefined }) + + expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 8ca4b0b60..255e28981 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -143,7 +143,13 @@ export function useMobileNativeChatController(args: { worktreeId, tabId: activeSessionTabId, sessionId: activeChatSessionId, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + launchDraft: activeSessionTab?.launchDraft ?? null, + // Why: pass the raw draft plus this flag rather than nulling it off-chat — + // a null is indistinguishable from a host retraction, and peeking at the + // terminal view would permanently decline the prefill. + chatActive: showNativeChat, + transcriptLoading: nativeChatSession.transcriptLoading }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts new file mode 100644 index 000000000..3b830c038 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -0,0 +1,310 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +// Adoption and retirement of the host-published launch draft (the TUI-input +// prefill mirrored into the chat composer). Split from the send/pending suite +// so both stay under the per-file line cap. +describe('useMobileNativeChatDrafts launch draft', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [], + launchDraft = null, + chatActive = true, + transcriptLoading = false + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + launchDraft?: string | null + chatActive?: boolean + transcriptLoading?: boolean + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages, + launchDraft, + chatActive, + transcriptLoading + }) + return null + } + + async function mount(tabId: string): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('prefills the composer from a host launch draft exactly once', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' }) + ) + ) + expect(state?.composerText).toBe('https://github.com/o/r/issues/12') + + // A user clear must not see the prefill resurrected on the next render. + act(() => state?.setComposerText('')) + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('does not overwrite typed composer text with a launch draft', async () => { + await mount('a') + act(() => state?.setComposerText('typed first')) + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('typed first') + }) + + it('declines a launch draft when the transcript already has a user turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', 'already sent')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('clears an untouched prefill once a user turn lands, keeping user edits', async () => { + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', 'sent from the TUI')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('') + + // Edited prefill survives resolution on another tab's copy. + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + act(() => state?.setComposerText('issue link plus my notes')) + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'b', + messages: [userTextMessage('m2', 'sent from the TUI')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('issue link plus my notes') + }) + + it('holds the launch draft until the transcript read settles', async () => { + // `session.tabs` carries launchDraft before the transcript loads. Seeding on + // the empty in-flight list would prefill an already-submitted issue link, and + // a send tapped before it retracts duplicates it to the agent. + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true }) + ) + ) + expect(state?.composerText).toBe('') + + // The settled transcript already holds the submitted turn — decline for good. + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + messages: [userTextMessage('m1', 'already sent from the TUI')] + }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('seeds once the transcript settles empty', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true }) + ) + ) + expect(state?.composerText).toBe('') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('holds the seed while the tab is not resolved to chat view', async () => { + // Off chat the session hook is not subscribed, so `messages` is empty for a + // reason that says nothing about the transcript — judging the seed from it + // would prefill an issue link the agent already submitted in the TUI. + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'issue link', chatActive: false }) + ) + ) + expect(state?.composerText).toBe('') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + messages: [userTextMessage('m1', 'already sent from the TUI')] + }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('keeps an adopted prefill when the tab momentarily drops out of the snapshot', async () => { + // A session-tabs frame can transiently omit the active tab: the draft then + // reads as retracted and chat as inactive, but nothing was resolved. + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null, chatActive: false })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('does not decline another tab’s prefill from the transcript it is still showing', async () => { + // The session hook resets its list in an effect, so the commit that first + // sees tab b still carries tab a's turns. Only transcriptLoading says so. + const carriedOver = [userTextMessage('m1', 'sent on a')] + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver })) + ) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'b', + messages: carriedOver, + launchDraft: 'issue link', + transcriptLoading: true + }) + ) + ) + expect(state?.composerText).toBe('') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('does not retire an adopted prefill from the transcript it is still showing', async () => { + const carriedOver = [userTextMessage('m1', 'sent on a')] + await mount('b') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver })) + ) + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'b', + messages: carriedOver, + launchDraft: 'issue link', + transcriptLoading: true + }) + ) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('clears an untouched prefill when the host stops publishing the launch draft', async () => { + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null })) + ) + expect(state?.composerText).toBe('') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 233ae34eb..bf3f94bd2 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -43,18 +43,27 @@ describe('useMobileNativeChatDrafts', () => { function Harness({ tabId, sessionId = `session-${tabId}`, - messages = [] + messages = [], + launchDraft = null, + chatActive = true, + transcriptLoading = false }: { tabId: string sessionId?: string | null messages?: NativeChatMessage[] + launchDraft?: string | null + chatActive?: boolean + transcriptLoading?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId, sessionId, - messages + messages, + launchDraft, + chatActive, + transcriptLoading }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a72a08f68..79b82ad5c 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -41,6 +41,15 @@ export function useMobileNativeChatDrafts(args: { tabId: string | null sessionId: string | null messages: readonly NativeChatMessage[] + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string | null + /** Whether the tab is currently resolved to the chat view. Off-chat the + * launch-draft effects hold their state instead of acting on it. */ + chatActive?: boolean + /** `messages` is not yet this session's real history (read in flight, or the + * transcript still belongs to the previously active tab), so it cannot be + * trusted to decline or retire the seed. */ + transcriptLoading?: boolean }): { composerText: string setComposerText: Dispatch> @@ -57,7 +66,16 @@ export function useMobileNativeChatDrafts(args: { onUnconfirmed: () => void ) => void } { - const { hostId, worktreeId, tabId, sessionId, messages } = args + const { + hostId, + worktreeId, + tabId, + sessionId, + messages, + launchDraft, + chatActive = true, + transcriptLoading + } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null const [drafts, setDrafts] = useState>({}) @@ -73,6 +91,63 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) + // Seeded launch-context text per tab; '' marks a permanent decline so a + // cleared composer never resurrects the prefill. + const seededLaunchDraftByKeyRef = useRef(new Map()) + + // Why: launch context delivered as a TUI-input prefill is invisible in chat; + // adopt it once as the composer draft so mobile shows the same context. + useEffect(() => { + if ( + !draftKey || + !chatActive || + !launchDraft?.trim() || + seededLaunchDraftByKeyRef.current.has(draftKey) + ) { + return + } + // Why: `session.tabs` carries launchDraft before the transcript read settles, + // and an empty (or previous tab's) list would let the decline below misjudge + // an already-submitted prefill — long enough for a send to duplicate it. + if (transcriptLoading) { + return + } + // A user turn already in the transcript means the one-line TUI prefill was + // submitted or deliberately cleared; decline instead of resurrecting it. + if (messages.some((message) => normalizedUserText(message) !== null)) { + seededLaunchDraftByKeyRef.current.set(draftKey, '') + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) + setDrafts((previous) => + (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + + // Drop an untouched adopted copy once the prefill is resolved elsewhere — a + // user turn landed (sent or cleared TUI-side) or the host stopped publishing + // it (desktop sent or reconciled it). User edits are always kept. + useEffect(() => { + // Same gates as the seed: off-chat there is no retraction to read (the tab + // publishes no draft to us), and an untrusted transcript would wipe an + // untouched copy on the strength of another tab's user turns. + if (!draftKey || !chatActive || transcriptLoading) { + return + } + const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) + if (!seeded) { + return + } + const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + if (!hasUserTurn && launchDraft?.trim()) { + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, '') + setDrafts((previous) => + (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + const setComposerText: Dispatch> = useCallback( (value) => { if (!draftKey) { diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 559524f3c..366dd4265 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -99,6 +99,13 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + // Why: pre-clear only when nothing was deliberately pasted first. The heal + // above fires only for terminals a mobile image paste marked, so a desktop + // launch-draft prefill parked on the input line would otherwise glue onto + // this message. An image send already led its own paste with Ctrl+U, and a + // second one here would wipe the image it just pasted (desktop's image path + // likewise clears once, before the paste, and never again). + clearInputFirst: !images?.length, deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 584f34624..3c1263069 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -218,3 +218,143 @@ describe('useMobileNativeChatSession', () => { expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) }) }) + +describe('useMobileNativeChatSession transcriptLoading', () => { + let renderer: ReactTestRenderer | null = null + const renders: { + sessionId: string | null + transcriptLoading: boolean + status: string + ids: string[] + }[] = [] + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + renders.length = 0 + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ + client, + sessionId, + agent = 'claude' + }: { + client: RpcClient | null + sessionId: string | null + agent?: string | null + }): null { + const session = useMobileNativeChatSession({ + client, + agent, + sessionId, + transcriptPath: null + }) + renders.push({ + sessionId, + transcriptLoading: session.transcriptLoading, + status: session.status, + ids: session.messages.map((entry) => entry.id) + }) + return null + } + + async function mountAt(client: RpcClient | null, sessionId: string | null): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client, sessionId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('reports loading on the very first render, before the subscription effect runs', async () => { + // `status` starts at 'idle', so on its own it would tell the launch-draft + // seed that an empty transcript is this session's real history. + const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {}) + await mountAt({ subscribe } as unknown as RpcClient, 'session-a') + + expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] }) + }) + + it('re-reads instead of resurfacing a settled read when the same identity returns', async () => { + // Leaving chat view nulls the agent, then returning restores the identity a + // settled read already matched — but its list was cleared, so trusting it + // would report 'ready' over an empty transcript. + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + const client = { subscribe } as unknown as RpcClient + await mountAt(client, 'session-a') + expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false }) + + // Toggle out to the terminal view, then back. + await act(async () => + renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null })) + ) + renders.length = 0 + await act(async () => + renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + }) + + it('re-reads instead of resurfacing a settled read after a reconnect', async () => { + // A reconnect swaps the client without moving the identity; the effect + // re-subscribes and clears the list, so the old outcome must not stand. + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + const client = { subscribe } as unknown as RpcClient + await mountAt(client, 'session-a') + expect(renders.at(-1)).toMatchObject({ status: 'ready' }) + + const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + }) + + it('never hands out the previous session’s messages under the new session id', async () => { + const subscribe: RpcClient['subscribe'] = vi.fn((_method, params, onData) => { + if ((params as { sessionId: string }).sessionId === 'session-a') { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + } + return () => {} + }) + const client = { subscribe } as unknown as RpcClient + await mountAt(client, 'session-a') + await act(async () => + renderer?.update(createElement(Harness, { client, sessionId: 'session-b' })) + ) + + // The effect that resets the list lands a commit later, so `messages` still + // holds session-a's transcript here — it must never surface under b, and b + // must read as loading until its own read settles. + const leaked = renders.find( + (entry) => entry.sessionId === 'session-b' && entry.ids.includes('a-1') + ) + expect(leaked).toBeUndefined() + expect(renders.find((entry) => entry.sessionId === 'session-b')).toMatchObject({ + transcriptLoading: true, + ids: [] + }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 2d381fdd6..9c739ce49 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -13,6 +13,12 @@ export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'r export type MobileNativeChatSession = { messages: NativeChatMessage[] status: MobileNativeChatStatus + /** True while `messages` cannot be trusted as this session's real history: + * the read is in flight, OR the subscription effect has not yet caught up to + * a just-changed agent/session, so `messages`/`status` still describe the + * previous tab. Consumers that decide something from an empty transcript + * (the launch-draft seed) must wait for this to clear. */ + transcriptLoading: boolean error?: string /** True when an older page may exist (the last read filled the window). */ hasMore: boolean @@ -22,6 +28,9 @@ export type MobileNativeChatSession = { loadEarlier: () => void } +// Stable empty reference so a not-yet-current read doesn't churn consumers. +const EMPTY_MESSAGES: NativeChatMessage[] = [] + // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 const PAGE = 60 @@ -42,7 +51,33 @@ export function useMobileNativeChatSession(args: { }): MobileNativeChatSession { const { client, agent, sessionId, transcriptPath } = args const [messages, setMessages] = useState([]) - const [status, setStatus] = useState('idle') + const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}` + // Pre-read status is a pure function of the props, so derive it rather than + // letting the effect write it a commit later. + const initialStatus: MobileNativeChatStatus = + !client || !agent ? 'idle' : !sessionId ? 'waiting-session' : 'loading' + // Only the settled outcome is genuinely async, and it is tagged with the + // identity it describes so a just-switched tab is never judged by the + // previous tab's transcript — the effect that clears `messages` is passive + // and lands a commit late. + const [read, setRead] = useState<{ + client: RpcClient + identity: string + status: MobileNativeChatStatus + } | null>(null) + // Drop it the moment its subscription stops being the live one — identity and + // client are the effect's only inputs, so together they catch every re-run. + // Without this a toggle out of chat view and back (agent null, then the same + // identity again) would resurface a settled 'ready' over an emptied list. + let current = read + if (current !== null && (current.identity !== identity || current.client !== client)) { + current = null + setRead(null) + } + // A settled read only counts while the props still call for one: losing the + // client/agent/session means idle or waiting-session outranks it outright. + const settled = initialStatus === 'loading' ? current : null + const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) @@ -78,16 +113,12 @@ export function useMobileNativeChatSession(args: { setHasMore(false) beforeOffsetRef.current = null if (!client || !agent) { - setStatus('idle') return } if (!sessionId) { - setStatus('waiting-session') return } - setStatus('loading') - const unsubscribe = client.subscribe( 'nativeChat.subscribe', { @@ -121,7 +152,7 @@ export function useMobileNativeChatSession(args: { return } if (applied.kind === 'error') { - setStatus('error') + setRead({ client, identity, status: 'error' }) setError(applied.error) return } @@ -140,7 +171,7 @@ export function useMobileNativeChatSession(args: { setLoadingEarlier(false) beforeOffsetRef.current = null } - setStatus('ready') + setRead({ client, identity, status: 'ready' }) } ) @@ -148,7 +179,7 @@ export function useMobileNativeChatSession(args: { cancelled = true unsubscribe() } - }, [client, agent, sessionId, transcriptPath, setList]) + }, [client, agent, sessionId, transcriptPath, identity, setList]) const loadEarlier = useCallback(() => { if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { @@ -215,5 +246,15 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) - return { messages, status, error, hasMore, loadingEarlier, loadEarlier } + return { + // Withheld until the settled read belongs to this identity: the effect that + // clears the previous tab's list is passive, so `messages` lags a commit. + messages: settled ? messages : EMPTY_MESSAGES, + status, + transcriptLoading: status === 'loading', + error, + hasMore, + loadingEarlier, + loadEarlier + } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index dac1b9f0f..9278dcf7d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -26786,6 +26786,7 @@ export class OrcaRuntimeService { ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), + ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index cb340ee0c..fe6cbdd6f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -32,14 +32,18 @@ const mocks = vi.hoisted(() => ({ sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), - draftScopeKeys: [] as string[] + draftScopeKeys: [] as string[], + clearNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchDraftAdopted: vi.fn() })) vi.mock('../../store', () => { const state = { dictationState: 'idle', settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, - updateSettings: vi.fn() + updateSettings: vi.fn(), + clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft, + markNativeChatLaunchDraftAdopted: mocks.markNativeChatLaunchDraftAdopted } const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) useAppStore.getState = () => state @@ -201,6 +205,22 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('retires the launch-draft seed once a send clears the TUI input line', () => { + render( + + ) + expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1') + }) + it('keeps the draft scope anchored to the pane while the PTY reconnects', () => { const view = render( (EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) const [notice, setNotice] = useState(null) @@ -284,6 +296,9 @@ export const NativeChatComposer = forwardRef s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null) const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt) const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null + // Launch context prefilled into the TUI input as an unsent draft; the + // composer adopts it so the GUI view shows the same context as the TUI. + // Shape matches NativeChatComposer's two launch-draft props, so it spreads. + const launchDraftSignal = useNativeChatLaunchDraftSignal({ + terminalTabId, + agent, + messages: session.messages, + transcriptLoading: session.readPhase === 'loading' + }) // The live-session merge reconciles hooks with replayable transcript turn // boundaries; all working consumers must use that one lifecycle decision. const liveWorking = session.status === 'working' @@ -442,6 +452,7 @@ function NativeChatResolvedView({ onSlashCommand={onSlashCommand} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} + {...launchDraftSignal} /> )} {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 5b8a9ab5e..58e97ccd2 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -1,4 +1,5 @@ import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' export type NativeChatComposerProps = { /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ @@ -24,6 +25,10 @@ export type NativeChatComposerProps = { onSwitchToTerminal?: () => void /** Reads the hosted TUI's current rendered screen when chat is entered. */ readTerminalScreen?: () => string | null + /** Launch context prefilled into the TUI input as an unsent draft; adopted as the composer draft. */ + launchDraft?: NativeChatLaunchDraft | null + /** True once the transcript shows the TUI-side draft was submitted or cleared. */ + launchDraftResolved?: boolean } export type NativeChatComposerHandle = { diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts new file mode 100644 index 000000000..afec656c7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + launchDraftResolvedByTranscript, + nativeChatLaunchDraftTurnBaseline +} from './native-chat-launch-draft-resolution' + +function userMessage(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } +} + +function assistantMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 2, + source: 'transcript' + } +} + +describe('launchDraftResolvedByTranscript', () => { + const SEEDED_AT = 100_000 + + it('resolves on any user turn at or after the seed time, even with different text', () => { + // The TUI input holds one line: a later user turn means the prefill was + // submitted with that turn (possibly edited/concatenated) or cleared first. + const submitted = { ...userMessage('u1', 'unrelated text'), timestamp: SEEDED_AT + 5_000 } + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [submitted])).toBe(true) + }) + + it('ignores assistant turns and user turns from before the seed', () => { + const early = { ...userMessage('u1', 'old turn'), timestamp: SEEDED_AT - 50_000 } + const reply = { ...assistantMessage('a1', 'hello'), timestamp: SEEDED_AT + 5_000 } + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [early, reply])).toBe(false) + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [])).toBe(false) + }) + + it('resolves on undated user turns (Grok omits row timestamps)', () => { + const undated = { ...userMessage('u1', 'submitted in the TUI'), timestamp: null } + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [undated])).toBe(true) + }) + + it('resolves when the executing host clock trails the renderer within the slack', () => { + // SSH/remote workspaces stamp the JSONL on the other host's clock. + const behind = { ...userMessage('u1', 'submitted'), timestamp: SEEDED_AT - 1_500 } + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [behind])).toBe(true) + }) + + it('resolves past wider clock skew once a new tail user turn lands', () => { + const stale = { ...userMessage('u1', 'earlier turn'), timestamp: SEEDED_AT - 600_000 } + const baseline = { userTurnCount: 1, lastUserTurnId: 'u1' } + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [stale], baseline)).toBe(false) + + const next = { ...userMessage('u2', 'submitted'), timestamp: SEEDED_AT - 600_000 + 10 } + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, [stale, next], baseline)).toBe( + true + ) + }) + + it('does not resolve when "load earlier" only prepends history', () => { + const tail = { ...userMessage('u9', 'earlier turn'), timestamp: SEEDED_AT - 600_000 } + const baseline = { userTurnCount: 1, lastUserTurnId: 'u9' } + const paged = [ + { ...userMessage('u7', 'older'), timestamp: SEEDED_AT - 900_000 }, + { ...userMessage('u8', 'older'), timestamp: SEEDED_AT - 800_000 }, + tail + ] + expect(launchDraftResolvedByTranscript({ createdAt: SEEDED_AT }, paged, baseline)).toBe(false) + }) +}) + +describe('nativeChatLaunchDraftTurnBaseline', () => { + it('snapshots the user-turn count and tail id, ignoring assistant turns', () => { + expect( + nativeChatLaunchDraftTurnBaseline([ + userMessage('u1', 'one'), + assistantMessage('a1', 'reply'), + userMessage('u2', 'two') + ]) + ).toEqual({ userTurnCount: 2, lastUserTurnId: 'u2' }) + expect(nativeChatLaunchDraftTurnBaseline([])).toEqual({ + userTurnCount: 0, + lastUserTurnId: null + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts new file mode 100644 index 000000000..a16556ff7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.ts @@ -0,0 +1,58 @@ +// When a launch-time draft (context prefilled into the agent's TUI input, never +// submitted) stops being live. Pure so the rule stays unit-testable without React. + +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status' + +/** The transcript's user turns as seen when a launch draft was first observed. + * A new *tail* turn past this is the timestamp-free proof that the TUI prefill + * was resolved; "load earlier" prepends, so it cannot move the tail. */ +export type NativeChatLaunchDraftTurnBaseline = { + userTurnCount: number + lastUserTurnId: string | null +} + +export function nativeChatLaunchDraftTurnBaseline( + messages: NativeChatMessage[] +): NativeChatLaunchDraftTurnBaseline { + const userTurns = messages.filter((message) => message.role === 'user') + return { + userTurnCount: userTurns.length, + lastUserTurnId: userTurns.at(-1)?.id ?? null + } +} + +// Why: the TUI input holds one line — any user turn after the draft was seeded +// means that prefill was either submitted with the turn or deliberately cleared +// first, so the composer copy must not linger as stale context. Text matching +// would miss TUI-edited submissions and concatenated remote sends. +// +// A launch draft is seeded at agent launch, so its session starts with zero user +// turns. That makes "not provably older than the seed" the right test rather than +// "stamped after the seed": Grok omits row timestamps entirely, and a remote +// host's JSONL clock can trail the renderer's. Only a turn whose own timestamp +// puts it before the seed by more than the cross-host slack is treated as +// pre-existing history (a resumed session, or a `load earlier` page). +export function launchDraftResolvedByTranscript( + entry: Pick, + messages: NativeChatMessage[], + baseline?: NativeChatLaunchDraftTurnBaseline | null +): boolean { + const provablyOlder = (message: NativeChatMessage): boolean => + message.timestamp !== null && + message.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS < entry.createdAt + if (messages.some((message) => message.role === 'user' && !provablyOlder(message))) { + return true + } + // Backstop for host clock skew wider than the slack: the transcript grew a new + // tail user turn since the draft was first observed. + if (!baseline) { + return false + } + const userTurns = messages.filter((message) => message.role === 'user') + return ( + userTurns.length > baseline.userTurnCount && + (userTurns.at(-1)?.id ?? null) !== baseline.lastUserTurnId + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index 77890d592..7a49ddb1e 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -77,7 +77,7 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat } /** Slack for comparing transcript timestamps to hook receipt times across hosts. */ -const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 +export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 function liveStatusOverride( hookState: AgentStatusState | null, diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx new file mode 100644 index 000000000..94c6252db --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx @@ -0,0 +1,257 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + useNativeChatLaunchDraftAdoption, + useNativeChatLaunchDraftSignal +} from './use-native-chat-launch-draft-adoption' + +const mocks = vi.hoisted(() => ({ + markNativeChatLaunchDraftAdopted: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + storeState: { nativeChatLaunchDraftByTabId: {} as Record } +})) + +vi.mock('../../store', () => { + const useAppStore = ((selector: (state: unknown) => unknown) => + selector(mocks.storeState)) as unknown as { + (selector: (state: unknown) => unknown): unknown + getState: () => unknown + } + useAppStore.getState = () => ({ + ...mocks.storeState, + markNativeChatLaunchDraftAdopted: mocks.markNativeChatLaunchDraftAdopted, + clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft + }) + return { useAppStore } +}) + +function launchDraft(overrides: Partial = {}): NativeChatLaunchDraft { + return { + tabId: 'tab-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: 1000, + ...overrides + } +} + +function setup(args: { + launchDraft: NativeChatLaunchDraft | null + launchDraftResolved?: boolean + draft?: string + agent?: string +}): { setDraft: ReturnType; setCaret: ReturnType } { + const setDraft = vi.fn() + const setCaret = vi.fn() + renderHook(() => + useNativeChatLaunchDraftAdoption({ + terminalTabId: 'tab-1', + agent: args.agent ?? 'claude', + launchDraft: args.launchDraft, + launchDraftResolved: args.launchDraftResolved ?? false, + draft: args.draft ?? '', + setDraft, + setCaret + }) + ) + return { setDraft, setCaret } +} + +const SEEDED_AT = 1_000_000 + +function userTurn(id: string, timestamp: number | null): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text: id }], timestamp, source: 'transcript' } +} + +type SignalProps = { messages: NativeChatMessage[]; transcriptLoading?: boolean } + +function renderSignal(messages: NativeChatMessage[], transcriptLoading = false) { + const initialProps: SignalProps = { messages, transcriptLoading } + return renderHook( + (props: SignalProps) => + useNativeChatLaunchDraftSignal({ + terminalTabId: 'tab-1', + agent: 'claude', + messages: props.messages, + transcriptLoading: props.transcriptLoading === true + }), + { initialProps } + ) +} + +describe('useNativeChatLaunchDraftSignal', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storeState.nativeChatLaunchDraftByTabId = { + 'tab-1': launchDraft({ createdAt: SEEDED_AT }) + } + }) + + it('resolves on an undated user turn (Grok omits row timestamps)', () => { + const { result } = renderSignal([userTurn('u1', null)]) + + expect(result.current.launchDraftResolved).toBe(true) + }) + + it('resolves a new turn even when the executing host clock runs far behind', () => { + // SSH/remote workspaces stamp the JSONL on the other host's clock, so no + // timestamp on either side proves the turn is new — the baseline does. + const stale = userTurn('u1', SEEDED_AT - 600_000) + const { result, rerender } = renderSignal([stale]) + expect(result.current.launchDraftResolved).toBe(false) + + rerender({ messages: [stale, userTurn('u2', SEEDED_AT - 599_000)] }) + expect(result.current.launchDraftResolved).toBe(true) + }) + + it('does not resolve when "load earlier" only prepends older history', () => { + const tail = userTurn('u9', SEEDED_AT - 600_000) + const { result, rerender } = renderSignal([tail]) + expect(result.current.launchDraftResolved).toBe(false) + + rerender({ messages: [userTurn('u7', SEEDED_AT - 900_000), tail] }) + expect(result.current.launchDraftResolved).toBe(false) + }) + + it('does not resolve when a loading transcript backfills older history', () => { + // The baseline must not be snapshotted on the empty in-flight list: the + // backfill itself would then push the count above it and silently drop a + // seed the user never saw. + const { result, rerender } = renderSignal([], true) + expect(result.current.launchDraftResolved).toBe(false) + + rerender({ + messages: [ + userTurn('u1', SEEDED_AT - 900_000), + userTurn('u2', SEEDED_AT - 800_000), + userTurn('u3', SEEDED_AT - 700_000) + ], + transcriptLoading: false + }) + + expect(result.current.launchDraftResolved).toBe(false) + expect(result.current.launchDraft).not.toBeNull() + }) + + it('still resolves a genuinely new turn after that backfilled history', () => { + const history = [userTurn('u1', SEEDED_AT - 900_000), userTurn('u2', SEEDED_AT - 800_000)] + const { result, rerender } = renderSignal([], true) + + rerender({ messages: history, transcriptLoading: false }) + expect(result.current.launchDraftResolved).toBe(false) + + rerender({ + messages: [...history, userTurn('u3', SEEDED_AT - 799_000)], + transcriptLoading: false + }) + expect(result.current.launchDraftResolved).toBe(true) + }) + + it('keeps a settled baseline across a later transcript reload', () => { + // Discarding it and re-taking from the fuller list would swallow the very + // user turn that resolves the draft, so a stale prefill gets re-adopted. + const { result, rerender } = renderSignal([]) + expect(result.current.launchDraftResolved).toBe(false) + + // Provably older than the seed, so only the baseline can resolve it. + const turn = userTurn('u1', SEEDED_AT - 600_000) + rerender({ messages: [turn], transcriptLoading: true }) + expect(result.current.launchDraftResolved).toBe(false) + + rerender({ messages: [turn], transcriptLoading: false }) + expect(result.current.launchDraftResolved).toBe(true) + }) + + it('does not resolve from an in-flight transcript that still shows recent turns', () => { + // A read in flight can still be rendering the previous generation's tail; + // those turns say nothing about this draft. + const { result } = renderSignal([userTurn('u1', SEEDED_AT)], true) + + expect(result.current.launchDraftResolved).toBe(false) + expect(result.current.launchDraft).not.toBeNull() + }) + + it('ignores a draft seeded for another agent', () => { + mocks.storeState.nativeChatLaunchDraftByTabId = { + 'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT }) + } + const { result } = renderSignal([userTurn('u1', null)]) + + expect(result.current.launchDraft).toBeNull() + expect(result.current.launchDraftResolved).toBe(false) + }) +}) + +describe('useNativeChatLaunchDraftAdoption', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('adopts an unadopted seed into an empty composer', () => { + const entry = launchDraft() + const { setDraft, setCaret } = setup({ launchDraft: entry }) + + expect(mocks.markNativeChatLaunchDraftAdopted).toHaveBeenCalledWith('tab-1') + expect(setDraft).toHaveBeenCalledWith(entry.text) + expect(setCaret).toHaveBeenCalledWith(entry.text.length) + expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('declines the seed permanently when the composer already holds text', () => { + const { setDraft } = setup({ launchDraft: launchDraft(), draft: 'user typed first' }) + + expect(mocks.markNativeChatLaunchDraftAdopted).toHaveBeenCalledWith('tab-1') + expect(setDraft).not.toHaveBeenCalled() + }) + + it('does nothing for a different agent or a missing seed', () => { + setup({ launchDraft: launchDraft({ agent: 'codex' }) }) + setup({ launchDraft: null }) + + expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled() + expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('does not re-adopt an already adopted seed after the user clears the composer', () => { + const { setDraft } = setup({ launchDraft: launchDraft({ adopted: true }), draft: '' }) + + expect(setDraft).not.toHaveBeenCalled() + expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled() + }) + + it('clears an untouched adopted copy once the transcript resolves the draft', () => { + const entry = launchDraft({ adopted: true }) + const { setDraft, setCaret } = setup({ + launchDraft: entry, + launchDraftResolved: true, + draft: entry.text + }) + + expect(setDraft).toHaveBeenCalledWith('') + expect(setCaret).toHaveBeenCalledWith(0) + expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1') + }) + + it('keeps user edits when the transcript resolves the draft', () => { + const { setDraft } = setup({ + launchDraft: launchDraft({ adopted: true }), + launchDraftResolved: true, + draft: 'edited context' + }) + + expect(setDraft).not.toHaveBeenCalled() + expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1') + }) + + it('drops an unadopted seed once the transcript resolves it', () => { + const { setDraft } = setup({ launchDraft: launchDraft(), launchDraftResolved: true }) + + expect(setDraft).not.toHaveBeenCalled() + expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled() + expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts new file mode 100644 index 000000000..aa4eb0a6d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from 'react' +import { useAppStore } from '../../store' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + launchDraftResolvedByTranscript, + nativeChatLaunchDraftTurnBaseline, + type NativeChatLaunchDraftTurnBaseline +} from './native-chat-launch-draft-resolution' + +/** Select the pane's launch draft and whether the transcript has resolved it. */ +export function useNativeChatLaunchDraftSignal(args: { + terminalTabId: string + agent: string + messages: NativeChatMessage[] + /** The transcript read is still in flight, so `messages` is not yet the + * session's real history. Same gate mobile's drafts hook uses. */ + transcriptLoading?: boolean +}): { launchDraft: NativeChatLaunchDraft | null; launchDraftResolved: boolean } { + const launchDraft = useAppStore((s) => s.nativeChatLaunchDraftByTabId[args.terminalTabId] ?? null) + const paneLaunchDraft = launchDraft?.agent === args.agent ? launchDraft : null + const messages = args.messages + const transcriptLoading = args.transcriptLoading === true + // Timestamp-free backstop: snapshot the transcript's user turns the first time + // this draft is seen, so a host clock skewed past the slack still resolves. + // Deferred until the read settles — a baseline taken on the empty in-flight + // list would be exceeded by the backfill itself, dropping a draft the user + // never saw. + const [heldBaseline, setHeldBaseline] = useState<{ + key: string + baseline: NativeChatLaunchDraftTurnBaseline + } | null>(null) + // Keyed on draft identity alone: a reload mid-draft must not discard a + // baseline already taken from a settled transcript — re-taking it from the + // fuller list would swallow the very user turn that resolves the draft. + const baselineKey = paneLaunchDraft + ? `${paneLaunchDraft.tabId} ${paneLaunchDraft.createdAt}` + : null + // Adjusted during render, not in an effect: the same render that first sees a + // settled transcript must already resolve against the captured baseline. + let held = heldBaseline + if (baselineKey === null) { + if (heldBaseline !== null) { + setHeldBaseline(null) + } + held = null + } else if (heldBaseline?.key !== baselineKey && !transcriptLoading) { + held = { key: baselineKey, baseline: nativeChatLaunchDraftTurnBaseline(messages) } + setHeldBaseline(held) + } + const baseline = held?.baseline ?? null + const launchDraftResolved = useMemo( + () => + paneLaunchDraft && !transcriptLoading + ? launchDraftResolvedByTranscript(paneLaunchDraft, messages, baseline) + : false, + [paneLaunchDraft, messages, baseline, transcriptLoading] + ) + return { launchDraft: paneLaunchDraft, launchDraftResolved } +} + +/** + * Adopt launch-time draft context (e.g. a linked issue URL prefilled into the + * TUI input buffer) into the chat composer, and drop it again once the + * transcript shows the TUI-side copy was resolved (submitted or cleared). + * + * State machine per seeded draft: + * - unadopted + composer empty → copy text into the composer, mark adopted + * - unadopted + composer in use → mark adopted without copying (never stomp) + * - resolved by transcript → clear the seed; also clear the composer copy + * only when it is still the untouched seed text + */ +export function useNativeChatLaunchDraftAdoption(args: { + terminalTabId: string + agent: string + launchDraft: NativeChatLaunchDraft | null | undefined + launchDraftResolved: boolean + draft: string + setDraft: (next: string) => void + setCaret: (next: number) => void +}): void { + const { terminalTabId, agent, launchDraft, launchDraftResolved, draft, setDraft, setCaret } = args + useEffect(() => { + if (!launchDraft || launchDraft.agent !== agent) { + return + } + if (launchDraftResolved) { + if (launchDraft.adopted && draft === launchDraft.text) { + setDraft('') + setCaret(0) + } + useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId) + return + } + if (launchDraft.adopted) { + return + } + // Mark adopted before copying so a composer that already holds user text + // declines the seed permanently instead of resurrecting it on a later clear. + useAppStore.getState().markNativeChatLaunchDraftAdopted(terminalTabId) + if (draft === '') { + setDraft(launchDraft.text) + setCaret(launchDraft.text.length) + } + }, [agent, draft, launchDraft, launchDraftResolved, setCaret, setDraft, terminalTabId]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 24af7a9e4..c642d891b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -865,6 +865,26 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { expect(latest?.messages.map((m) => m.id)).toContain('a-early') }) + it('still reports the loading readPhase once live content unmasks the status', async () => { + // `status` is not a truthful read-in-flight signal: content landing + // mid-retry outranks the spinner, so status leaves 'loading' while the read + // is still in flight. The launch-draft baseline must never be snapshotted + // on that partial list, so consumers need the raw phase instead. + vi.useFakeTimers() + const transport = getMockTransport('env-1', { autoSnapshot: false }) + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + // The user's own turn arrives on the watcher before the read settles. + await act(async () => { + transport.emit({ type: 'appended', messages: [user('u-live', 'hi')] }) + }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.readPhase).toBe('loading') + }) + it('renders live-appended content even when the initial read settled into a permanent error', async () => { const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index d742e426f..651381594 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -47,6 +47,10 @@ export type NativeChatLiveSession = NativeChatSession & { loadingEarlier: boolean /** Grow the read window to page in older history (scrolled-to-top trigger). */ loadEarlier: () => void + /** Raw initial-read phase. `status` is not a substitute: a live 'working' hook + * outranks (and so hides) 'loading', which would let a consumer deciding from + * an empty list treat an in-flight transcript as real history. */ + readPhase: ReadState['phase'] } // Stable empty-base reference so a non-ready read doesn't churn the base axis. @@ -82,7 +86,7 @@ function notFoundRetryDelayMs(attempt: number): number { return NOTFOUND_RETRY_DELAYS_MS[attempt] ?? NOTFOUND_RETRY_FIXED_DELAY_MS } -type ReadState = +export type ReadState = | { phase: 'loading' } | { phase: 'ready'; messages: NativeChatMessage[] } | { phase: 'error'; error: string } @@ -357,7 +361,7 @@ export function useNativeChatLiveSession( loading: read.phase === 'loading' && appended.length === 0, ...(read.phase === 'error' && appended.length === 0 ? { error: read.error } : {}) }) - return { ...session, hasMore, loadingEarlier, loadEarlier } + return { ...session, hasMore, loadingEarlier, loadEarlier, readPhase: read.phase } }, [ surfacedMessages, read, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 934065ab3..c57047981 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3999,6 +3999,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note: trimmedNote, startupPlan, quickPrompt, + ...(quickDraftPrompt ? { launchDraftPrompt: quickDraftPrompt } : {}), quickTelemetry, ...(createMultiple ? { suppressTerminalFocusOnCompletion: true } : {}) } diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 5ce6ea897..8672c6819 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ pasteDraftWhenAgentReady: vi.fn(), seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchPromptFailed: vi.fn() })) @@ -14,12 +15,50 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed }) } })) -import { deliverLaunchPromptToAgentTab } from './agent-launch-prompt-delivery' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab +} from './agent-launch-prompt-delivery' + +describe('seedNativeChatLaunchDraftForAgentTab', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects multi-line text at the helper, not just at the delivery caller', () => { + // Worktree-create and work-item launches seed through this helper directly + // (a Linear draft is always `Linked Linear issue: …\n\n`), so the + // Ctrl+U kill-to-start-of-LINE constraint has to live here. + seedNativeChatLaunchDraftForAgentTab({ + tabId: 'linear-tab', + agent: 'codex', + text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + }) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('seeds single-line text', () => { + seedNativeChatLaunchDraftForAgentTab({ + tabId: 'issue-tab', + agent: 'codex', + text: 'https://github.com/o/r/issues/12' + }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'issue-tab', + agent: 'codex', + text: 'https://github.com/o/r/issues/12', + createdAt: expect.any(Number) + }) + }) +}) describe('deliverLaunchPromptToAgentTab', () => { beforeEach(() => { @@ -55,7 +94,7 @@ describe('deliverLaunchPromptToAgentTab', () => { }) }) - it('does not seed for drafts, unsupported agents, or empty content', async () => { + it('does not seed a launch prompt for drafts, unsupported agents, or empty content', async () => { await deliverLaunchPromptToAgentTab({ tabId: 'draft-tab', agent: 'codex', @@ -81,6 +120,82 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) + it('seeds a native-chat launch draft for supported unsubmitted content', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Review first', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'draft-tab', + agent: 'codex', + text: 'Review first', + createdAt: expect.any(Number) + }) + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + + it('does not seed a launch draft for multi-line content', async () => { + // The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a + // multi-line prefill (e.g. scraped session-fork context) would leave earlier + // lines behind to glue onto the next message. + await deliverLaunchPromptToAgentTab({ + tabId: 'fork-tab', + agent: 'codex', + content: 'Forked from session\n\nhttps://example.test/context', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('does not seed a launch draft for submitted, unsupported, or empty content', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'submit-tab', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'unsupported-tab', + agent: 'gemini', + content: 'Review first', + submit: false, + forcePaste: false + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'empty-tab', + agent: 'claude', + content: ' ', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('keeps the seeded launch draft when paste delivery fails', async () => { + // A paste timeout means the TUI never got the draft — the composer copy is + // then the only copy, so it must not be flagged or cleared. + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Review first', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalled() + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + it('marks a seeded launch prompt failed when paste delivery returns false', async () => { mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 1569c5f1a..2f89856bb 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -4,6 +4,32 @@ import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/types' +/** Seed the chat-composer copy of launch context that reaches only the TUI + * input (argv prefill or startup paste). No-op for empty text or agents + * without a native-chat renderer. */ +export function seedNativeChatLaunchDraftForAgentTab(args: { + tabId: string + agent: TuiAgent + text: string +}): void { + // Single-line only: the chat send pre-clears the TUI with Ctrl+U + // (kill-to-start-of-LINE), so a multi-line prefill (e.g. a Linear issue block) + // would leave earlier lines to glue onto the next message. + if ( + args.text.trim().length === 0 || + args.text.includes('\n') || + !isNativeChatSupportedAgent(args.agent) + ) { + return + } + useAppStore.getState().seedNativeChatLaunchDraft({ + tabId: args.tabId, + agent: args.agent, + text: args.text, + createdAt: Date.now() + }) +} + export function deliverLaunchPromptToAgentTab(args: { tabId: string agent: TuiAgent @@ -24,6 +50,10 @@ export function deliverLaunchPromptToAgentTab(args: { text: content, createdAt: Date.now() }) + } else if (submit !== true) { + // Why: an unsubmitted draft lives only in the TUI input buffer; seed the + // chat-composer copy so the context isn't invisible in the GUI view. + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) } // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts index d050cbf1b..8f08c1b30 100644 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ b/src/renderer/src/lib/github-work-item-background-create.ts @@ -241,12 +241,13 @@ export async function createGitHubWorkItemWorkspaceInBackground( if (!deps.hasPendingCreate(creationId)) { return { kind: 'background-started' } } - const { startupPlan, quickPrompt, quickTelemetry } = buildGitHubWorkItemStartupPlan({ - agent, - item: args.item, - repo, - store - }) + const { startupPlan, quickPrompt, launchDraftPrompt, quickTelemetry } = + buildGitHubWorkItemStartupPlan({ + agent, + item: args.item, + repo, + store + }) if (agent && !startupPlan) { deps.toastError(agentLaunchCommandErrorMessage()) abandonStagedCreate(creationId, restoreView, deps) @@ -310,6 +311,7 @@ export async function createGitHubWorkItemWorkspaceInBackground( ...(issueCommand ? { issueCommand } : {}), startupPlan, quickPrompt, + ...(launchDraftPrompt ? { launchDraftPrompt } : {}), quickTelemetry } diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts index 86cf3c829..b8a761f46 100644 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ b/src/renderer/src/lib/github-work-item-background-request.ts @@ -174,11 +174,14 @@ export function buildGitHubWorkItemStartupPlan(args: { }): { startupPlan: AgentStartupPlan | null quickPrompt: string + /** Draft context (issue link) that reaches only the TUI input; callers thread + * it onto the creation request so completion can seed the chat composer. */ + launchDraftPrompt: string | null quickTelemetry: AgentStartedTelemetry | null } { const { agent, item, repo, store } = args if (!agent) { - return { startupPlan: null, quickPrompt: '', quickTelemetry: null } + return { startupPlan: null, quickPrompt: '', launchDraftPrompt: null, quickTelemetry: null } } const { prompt: quickPrompt, draftPrompt } = resolveGitHubWorkItemPrompt(item) // Why: runtime-owned repos launch on their owner host, not on the client @@ -233,6 +236,7 @@ export function buildGitHubWorkItemStartupPlan(args: { return { startupPlan, quickPrompt, + launchDraftPrompt: draftPrompt || null, quickTelemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: 'new_workspace_composer', diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts index 63fafaaae..4e2e561ed 100644 --- a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts +++ b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts @@ -59,6 +59,24 @@ describe('queueHookCommandsForFirstWorktreeTab', () => { expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1') }) + it('keeps every delivery queued for the same worktree', () => { + // A runtime-owned create can have setup/issue commands AND an agent-tab seed + // waiting on the same first mirrored tab; neither may evict the other. + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: {} }) + const deliverFirst = vi.fn() + const deliverSecond = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver: deliverFirst }) + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver: deliverSecond }) + + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } }) + + expect(deliverFirst).toHaveBeenCalledTimes(1) + expect(deliverSecond).toHaveBeenCalledTimes(1) + expect(deliverSecond).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1') + }) + it('drops the pending delivery when the worktree is no longer known', () => { setStorePartial({ tabsByWorktree: {}, diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts index 5f2db5d0c..1dfdd7c1c 100644 --- a/src/renderer/src/lib/hook-command-delayed-delivery.ts +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -12,13 +12,21 @@ type PendingWorktreeHookCommandDelivery = { // the delivery until the first mirrored terminal tab lands instead of // dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription // shape: subscribed only while something is pending. -const pendingHookCommandDeliveries = new Map() +// Queued per worktree rather than one-per-worktree: a runtime-owned create can +// have both setup/issue commands and an agent-tab seed waiting on the same +// first mirrored tab, and neither may evict the other. +const pendingHookCommandDeliveries = new Map() let unsubscribePendingHookCommandDeliveries: (() => void) | null = null export function queueHookCommandsForFirstWorktreeTab( delivery: PendingWorktreeHookCommandDelivery ): void { - pendingHookCommandDeliveries.set(delivery.worktreeId, delivery) + const queued = pendingHookCommandDeliveries.get(delivery.worktreeId) + if (queued) { + queued.push(delivery) + } else { + pendingHookCommandDeliveries.set(delivery.worktreeId, [delivery]) + } ensurePendingHookCommandSubscription() flushPendingHookCommandDeliveries() } @@ -72,7 +80,7 @@ function stopPendingHookCommandSubscriptionIfIdle(): void { function flushPendingHookCommandDeliveries(): void { const state = useAppStore.getState() - for (const [worktreeId, delivery] of pendingHookCommandDeliveries) { + for (const [worktreeId, deliveries] of pendingHookCommandDeliveries) { const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id if (!firstTerminalTabId) { // Why: a worktree can be removed before its tabs ever mirror; drop the @@ -83,9 +91,11 @@ function flushPendingHookCommandDeliveries(): void { continue } // Delete before delivering so store writes inside deliver cannot re-enter - // this entry through the subscription. + // these entries through the subscription. pendingHookCommandDeliveries.delete(worktreeId) - delivery.deliver(state, firstTerminalTabId) + for (const delivery of deliveries) { + delivery.deliver(state, firstTerminalTabId) + } } stopPendingHookCommandSubscriptionIfIdle() } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts new file mode 100644 index 000000000..fbca834fd --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -0,0 +1,300 @@ +// Windows/WSL shell-quoting coverage for launchAgentInNewTab, split from +// launch-agent-in-new-tab.test.ts to keep both files within the lines budget. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockQueueTabStartupCommand = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + terminalWindowsShell?: string + }, + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), + transientClearedAgentStatusConnectionIds: {} as Record, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }] + }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record< + string, + { activeLeafId: string | null; ptyIdsByLeafId?: Record } + >, + ptyIdsByTabId: {} as Record, + createTab: mockCreateTab, + closeTab: vi.fn(), + queueTabStartupCommand: mockQueueTabStartupCommand, + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('sonner', () => ({ + toast: { message: vi.fn(), error: vi.fn() } +})) + +vi.mock('@/components/tab-bar/reconcile-order', () => ({ + reconcileTabOrder: vi.fn( + (_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [ + ...termIds, + ...editorIds, + ...browserIds + ] + ) +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) + +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) + +describe('launchAgentInNewTab Windows shell quoting', () => { + beforeEach(() => { + vi.clearAllMocks() + store.activeRepoId = 'repo-1' + store.activeWorktreeId = 'wt-1' + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null + } + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + store.transientClearedAgentStatusConnectionIds = {} + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + } + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + store.openFiles = [] + store.browserTabsByWorktree = {} + store.tabBarOrderByWorktree = {} + store.terminalLayoutsByTabId = {} + store.ptyIdsByTabId = {} + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockPasteDraftWhenAgentReady.mockResolvedValue(true) + }) + + it('uses the explicit startup shell platform when building draft launch commands', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" + }) + ) + }) + + it('quotes local Windows default agent args for cmd.exe empty launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'claude "--dangerously-skip-permissions"' + }) + ) + }) + + it('keeps PowerShell quoting for local Windows default agent args', async () => { + store.settings.terminalWindowsShell = 'powershell.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions'" + }) + ) + }) + + it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'fix the spinner', + agentArgs: '--model gpt-5', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'codex "--model" "gpt-5" "fix the spinner"', + agentArgsOverride: '--model gpt-5' + }) + ) + }) + + it('quotes local Windows draft launches for Git Bash', async () => { + store.settings.terminalWindowsShell = 'git-bash' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + }) + ) + }) + + it('does not use the local Windows shell setting for remote Windows launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions'" + }) + ) + }) + + it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\Users\\jinwo\\repo\\feature', + displayName: 'feature' + } + ] + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + }) + ) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 35d069935..6b9de3849 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -8,6 +8,7 @@ const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockSeedNativeChatLaunchPrompt = vi.fn() +const mockSeedNativeChatLaunchDraft = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() @@ -80,6 +81,7 @@ const store = { setTabBarOrder: mockSetTabBarOrder, setAgentStatus: mockSetAgentStatus, seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed } @@ -441,157 +443,6 @@ describe('launchAgentInNewTab', () => { expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) - it('uses the explicit startup shell platform when building draft launch commands', async () => { - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: "review Bob's change", - promptDelivery: 'draft', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" - }) - ) - }) - - it('quotes local Windows default agent args for cmd.exe empty launches', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: 'claude "--dangerously-skip-permissions"' - }) - ) - }) - - it('keeps PowerShell quoting for local Windows default agent args', async () => { - store.settings.terminalWindowsShell = 'powershell.exe' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" - }) - ) - }) - - it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'fix the spinner', - agentArgs: '--model gpt-5', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: 'codex "--model" "gpt-5" "fix the spinner"', - agentArgsOverride: '--model gpt-5' - }) - ) - }) - - it('quotes local Windows draft launches for Git Bash', async () => { - store.settings.terminalWindowsShell = 'git-bash' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: "review Bob's change", - promptDelivery: 'draft', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" - }) - ) - }) - - it('does not use the local Windows shell setting for remote Windows launches', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }] - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" - }) - ) - }) - - it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - store.projects = [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] - store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] - store.worktreesByRepo = { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - projectId: 'repo-1', - path: 'C:\\Users\\jinwo\\repo\\feature', - displayName: 'feature' - } - ] - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: "review Bob's change", - promptDelivery: 'draft' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" - }) - ) - }) - it('falls back to post-ready draft paste when a Windows inline draft would be too large', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const prompt = 'x'.repeat(25_000) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 55586d811..a31475d10 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ updateWorktreeMeta: vi.fn(), setSidebarOpen: vi.fn(), seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchPromptFailed: vi.fn(), activateAndRevealWorktree: vi.fn(), pasteDraftWhenAgentReady: vi.fn(), @@ -24,6 +25,7 @@ const mocks = vi.hoisted(() => ({ updateWorktreeMeta: ReturnType setSidebarOpen: ReturnType seedNativeChatLaunchPrompt: ReturnType + seedNativeChatLaunchDraft: ReturnType markNativeChatLaunchPromptFailed: ReturnType } })) @@ -191,6 +193,7 @@ describe('launchWorkItemDirect', () => { updateWorktreeMeta: mocks.updateWorktreeMeta, setSidebarOpen: mocks.setSidebarOpen, seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed } as typeof mocks.store // @ts-expect-error -- test shim @@ -428,6 +431,62 @@ describe('launchWorkItemDirect', () => { expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled() }) + it('seeds the chat-composer launch draft for a GitHub issue draft launch', async () => { + mocks.ensureDetectedAgents.mockResolvedValue(['claude']) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + item: { + type: 'issue', + number: 12, + title: 'Fix crash on launch', + url: 'https://github.com/acme/repo/issues/12' + } + }) + ).resolves.toBe(true) + + // The issue link prefills only the TUI input (argv `--prefill`); the seeded + // draft is what makes the same context visible in the chat view. + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'claude', + text: 'https://github.com/acme/repo/issues/12', + createdAt: expect.any(Number) + }) + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + + it('withholds the chat-composer launch draft for a multi-line Linear draft launch', async () => { + // A Linear draft is always `Linked Linear issue: ENG-42\n\n`. The chat + // send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so seeding it + // would leave the first line parked to glue onto the next message. + mocks.ensureDetectedAgents.mockResolvedValue(['claude']) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + item: { + type: 'issue', + number: null, + title: 'Ship Linear parity', + url: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', + linearIdentifier: 'ENG-42' + } + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + it('preserves explicit Linear paste content submit-after-ready behavior', async () => { mocks.ensureDetectedAgents.mockResolvedValue(['claude']) const { launchWorkItemDirect } = await import('./launch-work-item-direct') @@ -472,6 +531,7 @@ describe('launchWorkItemDirect', () => { text: 'Use this explicit user prompt.', createdAt: expect.any(Number) }) + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) it('uses remote cursor-agent detection, trust preflight, and paste launch for SSH repos', async () => { diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 61f49274e..b6f86602e 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -13,6 +13,7 @@ import { workspaceActivationErrorMessage } from '@/lib/launch-work-item-direct-messages' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { getConnectionId } from '@/lib/connection-context' import type { GitPushTarget, SetupDecision, TuiAgent } from '../../../shared/types' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' @@ -307,6 +308,17 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } + // Why: draft delivery lands only in the TUI input buffer (argv prefill or + // startup-owned paste); seed the chat-composer copy so the work-item context + // isn't invisible in the GUI view. + if (promptDelivery === 'draft' && primaryTabId && effectiveAgent) { + seedNativeChatLaunchDraftForAgentTab({ + tabId: primaryTabId, + agent: effectiveAgent, + text: draftContent + }) + } + // Why: at this point the workspace is live and the agent (if any) has // been queued on `primaryTabId`. The post-launch paste step below only // applies to agents that lacked a native prefill flag; for agents that diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts index 270a27f7a..bffbd1537 100644 --- a/src/renderer/src/lib/native-chat-launch-prompt.ts +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -7,3 +7,17 @@ export type NativeChatLaunchPrompt = { createdAt: number failed?: boolean } + +/** + * Launch-time context delivered as an UNSENT draft (e.g. a linked issue URL + * prefilled into the agent TUI's input buffer). The chat composer adopts it as + * its own draft so the context isn't invisible in the GUI view. + */ +export type NativeChatLaunchDraft = { + tabId: string + agent: TuiAgent + text: string + createdAt: number + /** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */ + adopted?: boolean +} diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index b7f042a7b..f8b37ae43 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -87,6 +87,9 @@ export type WorktreeCreationRequest = { * did not already spawn it. Null for blank-shell creates. */ startupPlan: AgentStartupPlan | null quickPrompt: string + /** Launch context delivered only as an unsent TUI-input draft (argv prefill or + * startup paste); completion seeds the chat-composer copy from it. */ + launchDraftPrompt?: string quickTelemetry: AgentStartedTelemetry | null /** When the composer stays open for sequential creates, completion must not * steal focus from the next workspace name field. */ diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts new file mode 100644 index 000000000..21f1b76fc --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' +import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent-seeds' + +const mocks = vi.hoisted(() => ({ + seedNativeChatAppliedSessionOptions: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn() +})) + +vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ + seedNativeChatAppliedSessionOptions: mocks.seedNativeChatAppliedSessionOptions +})) +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab +})) + +type AppState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById + +const DRAFT = 'https://github.com/o/r/issues/12' + +const request = { + agent: 'claude' as const, + startupPlan: { agent: 'claude', launchCommand: 'claude' } as never, + launchDraftPrompt: DRAFT +} + +function setTabs(tabs: { id: string; launchAgent?: string }[]): void { + useAppStore.setState({ + tabsByWorktree: { 'wt-1': tabs }, + getKnownWorktreeById: ((id: string) => + id === 'wt-1' ? { id } : undefined) as unknown as AppState['getKnownWorktreeById'] + } as unknown as Partial) +} + +function seededTabIds(): string[] { + return mocks.seedNativeChatLaunchDraftForAgentTab.mock.calls.map((call) => call[0].tabId) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + resetHookCommandDelayedDeliveryForTests() + useAppStore.setState({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById + } as Partial) +}) + +describe('seedAgentTabStateAfterWorktreeCreate', () => { + it('seeds the backend-spawned startup tab, not the first default terminal tab', () => { + // Repo default tabs (dev server / logs / shell) run no agent; on the + // backend-spawn path none of them carries launchAgent either. + setTabs([{ id: 'dev-server' }, { id: 'logs' }, { id: 'agent-tab' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'dev-server', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(seededTabIds()).toEqual(['agent-tab']) + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith( + 'agent-tab', + 'claude', + undefined + ) + }) + + it('seeds the launchAgent-stamped tab when the renderer owns startup', () => { + setTabs([{ id: 'dev-server' }, { id: 'agent-tab', launchAgent: 'claude' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'dev-server', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(seededTabIds()).toEqual(['agent-tab']) + }) + + it('still seeds primaryTabId on the ordinary local path', () => { + setTabs([{ id: 'tab-1' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(seededTabIds()).toEqual(['tab-1']) + }) + + it('never falls back to an unrelated first tab', () => { + setTabs([{ id: 'dev-server' }, { id: 'logs' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + }) + + it('defers the seed to the first mirrored tab on runtime-owned worktrees', () => { + // Runtime-owned (web session) worktrees mirror their session tabs async, so + // the worktree has no tab at all at seed time. + setTabs([]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + + setTabs([{ id: 'mirror-tab-1' }]) + + expect(seededTabIds()).toEqual(['mirror-tab-1']) + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith( + 'mirror-tab-1', + 'claude', + undefined + ) + }) + + it('prefers the agent-stamped tab over the first mirrored tab when both land', () => { + setTabs([]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + + setTabs([{ id: 'mirror-shell' }, { id: 'mirror-agent', launchAgent: 'claude' }]) + + expect(seededTabIds()).toEqual(['mirror-agent']) + }) + + it('drops the deferred seed when the mirrored tabs are ambiguous', () => { + // Repo default tabs mirror together and none is stamped: `tabs[0]` here is + // "dev server". Seeding it would be withheld from mobile by the agent check + // and ignored on desktop — the same invariant the synchronous path holds. + setTabs([]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + + setTabs([{ id: 'mirror-dev-server' }, { id: 'mirror-logs' }]) + + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled() + }) + + it('seeds session options but no draft without launch draft context', () => { + setTabs([{ id: 'tab-1' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { agent: 'claude', startupPlan: request.startupPlan }, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledOnce() + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + }) + + it('does nothing without an agent or a startup plan', () => { + setTabs([{ id: 'tab-1' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { agent: null, startupPlan: request.startupPlan, launchDraftPrompt: DRAFT }, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + seedAgentTabStateAfterWorktreeCreate({ + request: { agent: 'claude', startupPlan: null, launchDraftPrompt: DRAFT }, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts new file mode 100644 index 000000000..a51739343 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -0,0 +1,96 @@ +import { useAppStore } from '@/store' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import type { TuiAgent } from '../../../shared/types' + +type AppStoreSnapshot = ReturnType + +type SeedRequest = Pick + +/** + * Resolve the tab the launch agent actually runs in. + * + * Deliberately never falls back to `tabs[0]`: with repo default terminal tabs + * (e.g. "dev server", "logs", "shell") the worktree's first tab runs no agent, + * and activation's `primaryTabId` is only the worktree's primary tab. + */ +function resolveLaunchAgentTabId( + state: AppStoreSnapshot, + args: { + agent: TuiAgent + worktreeId: string + primaryTabId: string | null + startupTerminalTabId: string | null | undefined + backendSpawned: boolean + } +): string | null { + // Main spawned the agent itself, so its startup tab is authoritative. + if (args.backendSpawned && args.startupTerminalTabId) { + return args.startupTerminalTabId + } + const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? [] + const stamped = worktreeTabs.find((tab) => tab.launchAgent === args.agent)?.id + // Why: when the renderer owns startup, `ensureAgentStartupInTerminal` queues it + // on primaryTabId, so that tab is the agent's tab by construction. + return stamped ?? args.primaryTabId ?? args.startupTerminalTabId ?? null +} + +function applyAgentTabSeeds(request: SeedRequest, agent: TuiAgent, tabId: string): void { + seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions) + // Why: draft launch context reaches only the TUI input; seed the + // chat-composer copy so it isn't invisible in the chat view. + if (request.launchDraftPrompt) { + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: request.launchDraftPrompt }) + } +} + +/** + * Seed per-tab agent state (applied session options + the chat-composer copy of + * draft launch context) once a created worktree's launch tab is known. + */ +export function seedAgentTabStateAfterWorktreeCreate(args: { + request: SeedRequest + worktreeId: string + primaryTabId: string | null + startupTerminalTabId: string | null | undefined + backendSpawned: boolean +}): void { + const { request, worktreeId } = args + const agent = request.agent + if (!request.startupPlan || !agent) { + return + } + const state = useAppStore.getState() + const tabId = resolveLaunchAgentTabId(state, { ...args, agent }) + if (tabId) { + applyAgentTabSeeds(request, agent, tabId) + return + } + if ((state.tabsByWorktree[worktreeId] ?? []).length > 0) { + // Tabs exist but none is the agent's; seeding one anyway would publish the + // draft on a tab that runs no agent. + return + } + // Why: runtime-owned (web session) worktrees mirror their session tabs async, + // so there is no tab to seed yet — hold the seed for the first mirrored tab + // instead of dropping it and leaving the draft invisible on that host class. + queueHookCommandsForFirstWorktreeTab({ + worktreeId, + deliver: (state, firstTerminalTabId) => { + const mirroredTabId = resolveLaunchAgentTabId(state, { ...args, agent }) + if (mirroredTabId) { + applyAgentTabSeeds(request, agent, mirroredTabId) + return + } + // Why: same invariant as the synchronous branch — never seed a tab that + // runs no agent. The queue entry is consumed before delivery (no retry), + // so accept the first mirrored tab only when it is the worktree's only + // one and therefore unambiguously the agent's. + if ((state.tabsByWorktree[worktreeId] ?? []).length === 1) { + applyAgentTabSeeds(request, agent, firstTerminalTabId) + } + } + }) +} diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 90af3fe69..9e53b1a24 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -39,7 +39,9 @@ const store = { setSidebarOpen: vi.fn(), createWorktree: vi.fn(() => new Promise(() => {})), setupProjectExistingFolder: vi.fn(), - refreshRuntimeEnvironmentStatus: vi.fn() + refreshRuntimeEnvironmentStatus: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + tabsByWorktree: {} as Record } vi.mock('@/store', () => ({ @@ -97,6 +99,7 @@ beforeEach(() => { store.repos = [] store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(makeRequest()) } store.createWorktree.mockImplementation(() => new Promise(() => {})) + store.tabsByWorktree = {} vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') }) @@ -627,6 +630,99 @@ describe('staged background worktree creation', () => { expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() }) + it('seeds the chat-composer launch draft on completion for draft launches', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'claude', + startupPlan: { + agent: 'claude', + launchCommand: 'claude --prefill x', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + }, + launchDraftPrompt: 'https://github.com/o/r/issues/12' + }) + ) + + expect(started).toBe(true) + await vi.waitFor(() => + expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: expect.any(Number) + }) + ) + }) + + it('seeds the backend-spawned agent tab, not the worktree default terminal tab', async () => { + // Repo default tabs ("dev server", "logs", …) make activation's primaryTabId + // a tab that runs no agent; main's startup terminal is the agent's own tab. + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.tabsByWorktree = { 'wt-1': [{ id: 'dev-server' }, { id: 'agent-tab' }] } + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }, + startupTerminal: { tabId: 'agent-tab', spawned: true } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'dev-server' }) + + continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'claude', + startupPlan: { + agent: 'claude', + launchCommand: 'claude --prefill x', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + }, + launchDraftPrompt: 'https://github.com/o/r/issues/12' + }) + ) + + await vi.waitFor(() => expect(store.seedNativeChatLaunchDraft).toHaveBeenCalled()) + expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'agent-tab' }) + ) + }) + + it('does not seed a launch draft without draft launch context', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'claude', + startupPlan: { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + ) + + await vi.waitFor(() => expect(activateAndRevealWorktree).toHaveBeenCalled()) + expect(store.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + it('toasts a staged create error after the user leaves the creation surface', async () => { store.activeView = 'tasks' store.createWorktree.mockRejectedValueOnce(new Error('create failed')) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index dcfc25a33..7b7099f29 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -25,7 +25,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' -import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean @@ -255,16 +255,13 @@ async function executeWorktreeCreation( // Why: clearing synchronously right after activation lets React commit the // panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash. useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false }) - if (preparedRequest.startupPlan && preparedRequest.agent) { - const optionScopeKey = primaryTabId ?? result.startupTerminal?.tabId - if (optionScopeKey) { - seedNativeChatAppliedSessionOptions( - optionScopeKey, - preparedRequest.agent, - preparedRequest.startupPlan.sessionOptions - ) - } - } + seedAgentTabStateAfterWorktreeCreate({ + request: preparedRequest, + worktreeId: worktree.id, + primaryTabId, + startupTerminalTabId: result.startupTerminal?.tabId, + backendSpawned + }) if (preparedRequest.startupPlan && !backendSpawned) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index d1082d89c..8d3a149e5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -386,6 +386,48 @@ describe('getRuntimeMobileSessionSyncKey', () => { expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false) }) + it('changes when a native-chat launch draft is seeded or cleared', () => { + const sharedOverrides = makeSharedOverrides() + const launchDraft = { + tabId: 'term-1', + agent: 'claude' as const, + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + + const before = getRuntimeMobileSessionSyncKey( + makeState({ ...sharedOverrides, nativeChatLaunchDraftByTabId: {} }) + ) + const after = getRuntimeMobileSessionSyncKey( + makeState({ + ...sharedOverrides, + nativeChatLaunchDraftByTabId: { 'term-1': launchDraft } + }) + ) + + expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false) + }) + + it('does not skip the App subscriber gate when a launch draft is seeded', () => { + // The key is never even built when this gate skips, so the draft-aware key + // case above cannot catch a regression here. + const sharedOverrides = makeSharedOverrides() + const before = makeState({ ...sharedOverrides, nativeChatLaunchDraftByTabId: {} }) + const after = makeState({ + ...sharedOverrides, + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude' as const, + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + } + }) + + expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false) + }) + it('changes when explicit agent status epoch changes', () => { const sharedOverrides = makeSharedOverrides() const before = getRuntimeMobileSessionSyncKey( @@ -669,6 +711,73 @@ describe('buildMobileSessionTabSnapshots', () => { ) }) + it('publishes the native-chat launch draft on terminal surface tabs', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'claude' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as unknown as AppState['terminalLayoutsByTabId'], + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + } + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'term-1', + launchDraft: 'https://github.com/o/r/issues/12' + }) + ]) + }) + + it('withholds a launch draft seeded for a different agent than the tab runs', () => { + // The seed is keyed by tab id, which survives an agent switch. Desktop's + // consumer declines on mismatch; publishing anyway would prefill the new + // agent's mobile chat with the previous agent's link. + const leafId = '11111111-1111-4111-8111-111111111111' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'codex' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as unknown as AppState['terminalLayoutsByTabId'], + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + } + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs[0]).not.toHaveProperty('launchDraft') + }) + it('preserves source-control diff metadata for mobile file tabs', () => { const diffId = 'wt-1::diff::unstaged::src/app.ts' const state = makeState({ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 7d90d25d1..33cb27ed7 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -245,6 +245,7 @@ export type RuntimeMobileSessionSyncKey = { // Why: compared by reference; reallocation signals a real layout/title change, avoiding stringifying thousands of tabs. See docs/agent-working-pane-typing-lag.md. terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] + nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] groupsByWorktree: AppState['groupsByWorktree'] activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] layoutByWorktree: AppState['layoutByWorktree'] @@ -300,6 +301,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild( state.activeTabId === previousState.activeTabId && state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && + state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && state.agentStatusEpoch === previousState.agentStatusEpoch && state.agentStatusByPaneKey === previousState.agentStatusByPaneKey ) @@ -336,6 +338,7 @@ export function getRuntimeMobileSessionSyncKey( return { terminalLayoutsByTabId: state.terminalLayoutsByTabId, runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, + nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, groupsByWorktree: state.groupsByWorktree, activeGroupIdByWorktree: state.activeGroupIdByWorktree, layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, @@ -580,6 +583,7 @@ export function runtimeMobileSessionSyncKeysEqual( return ( a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && + a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && a.groupsByWorktree === b.groupsByWorktree && a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && a.layoutByWorktree === b.layoutByWorktree && @@ -1363,6 +1367,12 @@ function buildMobileTerminalSurfaceTabs( : undefined const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} const terminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) + // Agent-matched like the desktop consumer: a pane whose agent changed keeps its + // tab id, so an unmatched seed would prefill the new agent's chat with stale text. + const seededLaunchDraft = state.nativeChatLaunchDraftByTabId?.[terminal.id] + const launchDraftEntry = + seededLaunchDraft && seededLaunchDraft.agent === terminal.launchAgent ? seededLaunchDraft : null + const launchDraftText = launchDraftEntry?.text.trim() ? launchDraftEntry.text : null const container = registered?.getContainer() const firstChild = container?.firstElementChild const liveLayoutRoot = serializePaneTree( @@ -1423,6 +1433,9 @@ function buildMobileTerminalSurfaceTabs( ...(terminalTheme ? { terminalTheme } : {}), ...(agentStatus ? { agentStatus } : {}), ...(terminal.launchAgent ? { launchAgent: terminal.launchAgent } : {}), + // Launch context that exists only as an unsent TUI-input draft; mobile + // prefills its chat composer from it (desktop keeps its own seed store). + ...(launchDraftText ? { launchDraft: launchDraftText } : {}), parentLayout, isActive: isDesktopTabActive && leafId === activeLeafId } diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts new file mode 100644 index 000000000..79b1fd416 --- /dev/null +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -0,0 +1,139 @@ +/** + * Teardown coverage for `nativeChatLaunchDraftByTabId`. + * + * A stranded entry is worse than a plain leak: sync-runtime-graph keeps + * publishing it to mobile as that tab's `launchDraft`. It must evict wherever + * its sibling `nativeChatLaunchPromptByTabId` does — tab close, orphan sweep, + * the bulk worktree purge, and the single removeWorktree teardown. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' + +function draft(tabId: string, text: string) { + return { tabId, agent: 'claude' as const, text, createdAt: 1 } +} + +function seedDrafts(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + }, + nativeChatLaunchDraftByTabId: { + [TAB1]: draft(TAB1, 'https://github.com/o/r/issues/1'), + [TAB2]: draft(TAB2, 'https://github.com/o/r/issues/2') + } + }) +} + +describe('nativeChatLaunchDraftByTabId teardown', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApi.worktrees.remove.mockResolvedValue(undefined) + }) + + it('closeTab drops the closed tab’s draft only', () => { + const store = createTestStore() + seedDrafts(store) + + store.getState().closeTab(TAB1) + + const s = store.getState() + expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) + + it('bulk purgeWorktreeTerminalState drops drafts for the removed worktree only', () => { + const store = createTestStore() + seedDrafts(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + + const s = store.getState() + expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) + + it('single removeWorktree drops drafts for the removed worktree only', async () => { + const store = createTestStore() + seedDrafts(store) + + const result = await store.getState().removeWorktree(WT1) + + expect(result).toEqual({ ok: true }) + const s = store.getState() + expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) + + it('persists the adopted flag, idempotently, and clears the whole entry', () => { + // Every consumer test injects these reducers as bare vi.fn()s. `adopted` is + // what stops a manually cleared composer from resurrecting the prefill, so + // assert the real reducers here. + const store = createTestStore() + store.getState().seedNativeChatLaunchDraft(draft(TAB1, 'https://github.com/o/r/issues/1')) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.adopted).toBeUndefined() + + store.getState().markNativeChatLaunchDraftAdopted(TAB1) + const adopted = store.getState().nativeChatLaunchDraftByTabId[TAB1] + expect(adopted).toMatchObject({ tabId: TAB1, adopted: true }) + + store.getState().markNativeChatLaunchDraftAdopted(TAB1) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]).toBe(adopted) + + store.getState().clearNativeChatLaunchDraft(TAB1) + expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false) + }) + + it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => { + const store = createTestStore() + seedDrafts(store) + + const patch = buildOrphanTerminalCleanupPatch(store.getState(), WT1, new Set([TAB1])) + + expect(patch.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(patch.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) +}) diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index 0768d70bc..45445f530 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -48,6 +48,7 @@ type OrphanTerminalCleanupState = Pick< | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' | 'nativeChatLaunchPromptByTabId' + | 'nativeChatLaunchDraftByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -99,6 +100,7 @@ export function buildOrphanTerminalCleanupPatch( | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' | 'nativeChatLaunchPromptByTabId' + | 'nativeChatLaunchDraftByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -118,6 +120,7 @@ export function buildOrphanTerminalCleanupPatch( pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: state.automaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: state.nativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, tabBarOrderByWorktree: state.tabBarOrderByWorktree, cacheTimerByKey: state.cacheTimerByKey, activeTabIdByWorktree: state.activeTabIdByWorktree, @@ -141,6 +144,7 @@ export function buildOrphanTerminalCleanupPatch( ...state.automaticAgentResumeClaimsByTabId } const nextNativeChatLaunchPromptByTabId = { ...state.nativeChatLaunchPromptByTabId } + const nextNativeChatLaunchDraftByTabId = { ...state.nativeChatLaunchDraftByTabId } const nextTabBarOrderByWorktree = { ...state.tabBarOrderByWorktree, [worktreeId]: (state.tabBarOrderByWorktree[worktreeId] ?? []).filter( @@ -165,6 +169,7 @@ export function buildOrphanTerminalCleanupPatch( delete nextPendingIssueCommandSplitByTabId[orphanTabId] delete nextAutomaticAgentResumeClaimsByTabId[orphanTabId] delete nextNativeChatLaunchPromptByTabId[orphanTabId] + delete nextNativeChatLaunchDraftByTabId[orphanTabId] for (const key of Object.keys(nextCacheTimerByKey)) { if (key.startsWith(`${orphanTabId}:`)) { delete nextCacheTimerByKey[key] @@ -195,6 +200,7 @@ export function buildOrphanTerminalCleanupPatch( pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, tabBarOrderByWorktree: nextTabBarOrderByWorktree, cacheTimerByKey: nextCacheTimerByKey, activeTabIdByWorktree: nextActiveTabIdByWorktree, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 33f41609c..9a9e898c6 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -119,7 +119,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -559,6 +559,11 @@ export type TerminalSlice = { seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void markNativeChatLaunchPromptFailed: (tabId: string) => void clearNativeChatLaunchPrompt: (tabId: string) => void + /** Launch context prefilled into the TUI input as an unsent draft; the chat composer adopts it. In-memory only. */ + nativeChatLaunchDraftByTabId: Record + seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void + markNativeChatLaunchDraftAdopted: (tabId: string) => void + clearNativeChatLaunchDraft: (tabId: string) => void pendingStartupByTabId: Record< string, { @@ -1001,6 +1006,7 @@ export const createTerminalSlice: StateCreator pendingIssueCommandSplitByTabId: {}, automaticAgentResumeClaimsByTabId: {}, nativeChatLaunchPromptByTabId: {}, + nativeChatLaunchDraftByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, restoredRuntimeHostIdByWorkspaceSessionKey: {}, @@ -1086,6 +1092,41 @@ export const createTerminalSlice: StateCreator }) }, + seedNativeChatLaunchDraft: (draft) => { + set((s) => ({ + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [draft.tabId]: draft + } + })) + }, + + markNativeChatLaunchDraftAdopted: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if (!current || current.adopted) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, adopted: true } + } + } + }) + }, + + clearNativeChatLaunchDraft: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchDraftByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchDraftByTabId } + delete next[tabId] + return { nativeChatLaunchDraftByTabId: next } + }) + }, + recordTerminalInput: (paneKey, timestamp = Date.now()) => { if (!paneKey || !Number.isFinite(timestamp)) { return @@ -1584,6 +1625,8 @@ export const createTerminalSlice: StateCreator delete nextAutomaticAgentResumeClaimsByTabId[tabId] const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } delete nextNativeChatLaunchPromptByTabId[tabId] + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } + delete nextNativeChatLaunchDraftByTabId[tabId] const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } delete nextPendingInitialCwdByTabId[tabId] const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } @@ -1668,6 +1711,7 @@ export const createTerminalSlice: StateCreator pendingStartupByTabId: nextPendingStartupByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 234db3dcc..7462073d9 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2479,6 +2479,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial ...s.automaticAgentResumeClaimsByTabId } const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId } const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId } @@ -3872,6 +3874,7 @@ export const createWorktreeSlice: StateCreator delete nextRuntimePaneTitlesByTabId[tabId] delete nextAutomaticAgentResumeClaimsByTabId[tabId] delete nextNativeChatLaunchPromptByTabId[tabId] + delete nextNativeChatLaunchDraftByTabId[tabId] delete nextExpandedPaneByTabId[tabId] delete nextCanExpandPaneByTabId[tabId] } @@ -4003,6 +4006,7 @@ export const createWorktreeSlice: StateCreator runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, terminalLayoutsByTabId: nextLayouts, expandedPaneByTabId: nextExpandedPaneByTabId, canExpandPaneByTabId: nextCanExpandPaneByTabId, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 2ef276328..62d8293bc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -171,6 +171,9 @@ export type RuntimeMobileSessionTerminalTab = { /** Per-tab view preference (terminal xterm vs native chat). Host-persisted so * paired clients converge; clients still win during the optimistic echo window. */ viewMode?: 'terminal' | 'chat' + /** Launch context delivered only into the TUI input as an unsent draft; the + * mobile chat composer adopts it so the context isn't invisible in chat. */ + launchDraft?: string isActive: boolean }