From 62cb76a906e1ef27c148c0c4be3a724cb5a7296d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:26:35 -0700 Subject: [PATCH] fix(native-chat): retire mobile-consumed launch drafts --- .../session/mobile-session-route-types.ts | 1 + .../session/mobile-native-chat-eligibility.ts | 1 + .../session/mobile-native-chat-send.test.ts | 2 + mobile/src/session/mobile-native-chat-send.ts | 3 + .../session/mobile-terminal-records.test.ts | 4 +- mobile/src/session/mobile-terminal-records.ts | 2 + .../use-mobile-native-chat-controller.test.ts | 3 + .../use-mobile-native-chat-controller.ts | 4 +- ...le-native-chat-drafts-launch-draft.test.ts | 18 ++ .../session/use-mobile-native-chat-drafts.ts | 8 +- ...se-mobile-native-chat-launch-draft-seed.ts | 55 ++++-- ...se-mobile-native-chat-message-send.test.ts | 39 +++-- .../use-mobile-native-chat-message-send.ts | 19 +- src/main/runtime/orca-runtime.test.ts | 125 ++++++++++++++ src/main/runtime/orca-runtime.ts | 163 +++++++++++++++++- ...ime-request-connection.integration.test.ts | 13 ++ src/main/runtime/rpc/methods/client-events.ts | 4 + src/main/runtime/rpc/methods/terminal.ts | 15 ++ ...minal-send-launch-draft-resolution.test.ts | 67 +++++++ .../window/attach-main-window-services.ts | 2 + src/preload/api-types.ts | 3 + src/preload/index.ts | 10 ++ ...native-chat-launch-draft-adoption.test.tsx | 11 ++ .../use-native-chat-launch-draft-adoption.ts | 5 +- src/renderer/src/hooks/useIpcEvents.ts | 17 ++ .../src/lib/native-chat-launch-prompt.ts | 2 + ...at-launch-draft-runtime-resolution.test.ts | 18 ++ ...ve-chat-launch-draft-runtime-resolution.ts | 19 ++ .../src/runtime/runtime-client-events.test.ts | 17 +- .../src/runtime/runtime-client-events.ts | 1 + .../src/runtime/sync-runtime-graph.test.ts | 31 +++- .../src/runtime/sync-runtime-graph.ts | 29 +++- .../native-chat-launch-draft-teardown.test.ts | 15 ++ src/renderer/src/store/slices/terminals.ts | 24 +++ src/renderer/src/web/web-preload-api.ts | 1 + src/shared/runtime-client-events.ts | 2 + src/shared/runtime-types.ts | 9 + 37 files changed, 720 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts create mode 100644 src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts create mode 100644 src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.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 219dd58dc7b..c5a94e9d38f 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -28,6 +28,7 @@ export type MobileSessionTab = launchAgent?: TuiAgent /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number 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 988e56c67a4..2f997c2cdc5 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -27,6 +27,7 @@ export type MobileNativeChatTab = { agentStatus?: AgentStatusEntry | null /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number } /** 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 efcbe9d4e0b..ccdacfd7b3e 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -31,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => { client, terminal: 'term', text: 'hello', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 }, mobileClient: { id: 'device', type: 'mobile' } }) ).resolves.toBe(true) @@ -40,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => { terminal: 'term', text: 'hello', enter: true, + resolvedLaunchDraft: { text: 'seed', createdAt: 7 }, client: { id: 'device', type: 'mobile' } }, { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 8dfc430557a..ce432c3efb9 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -24,6 +24,8 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean + /** Exact host launch draft this submitting write resolves when accepted. */ + resolvedLaunchDraft?: { text: string; createdAt: number } 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. */ @@ -64,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, + ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, // The budget covers this whole write, reconnect wait included — a chat send diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index fba0774261c..cdcbfaafade 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -102,12 +102,14 @@ describe('mobile terminal records', () => { } const seeded: MobileTerminalSessionTab = { ...base, - launchDraft: 'https://github.com/o/r/issues/12' + launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 1 } expect(mobileSessionTabsEqual([base], [seeded])).toBe(false) expect(mobileSessionTabsEqual([seeded], [base])).toBe(false) expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true) + expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false) }) it('treats terminal agent-status changes as session-tab changes', () => { diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 07255ffbff7..4a9e4438c84 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -19,6 +19,7 @@ export type MobileTerminalSessionTab = { agentStatus?: AgentStatusEntry | null /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number terminalTheme?: MobileTerminalTheme isActive: boolean } @@ -89,6 +90,7 @@ function mobileSessionTabEqual( // A frame whose only delta is the launch draft appearing or retracting // still has to reach the chat composer. a.launchDraft === b.launchDraft && + a.launchDraftCreatedAt === b.launchDraftCreatedAt && 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 9ebf45db81e..2b4fb0e9d0f 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -36,6 +36,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ pending: [], captureSendOrigin, readSeededLaunchDraft: () => null, + readSeededLaunchDraftSeed: () => null, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -328,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { terminal: 'term-1', launchAgent: 'claude', launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 7, isActive: true } @@ -386,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { expect(draftsArgs.at(-1)).toMatchObject({ tabId: 'tab-1', launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 7, chatActive: true, transcriptLoading: false }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index fe0f0a53395..fd0a564b19c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -138,6 +138,7 @@ export function useMobileNativeChatController(args: { pending: chatPending, captureSendOrigin, readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -149,6 +150,7 @@ export function useMobileNativeChatController(args: { sessionId: activeChatSessionId, messages: nativeChatSession.messages, launchDraft: activeSessionTab?.launchDraft ?? null, + launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? 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. @@ -247,7 +249,7 @@ export function useMobileNativeChatController(args: { handleRef: activeHandleRef, deviceTokenRef, captureSendOrigin, - readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, 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 index 3b830c0384b..91c84814c41 100644 --- 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 @@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId = `session-${tabId}`, messages = [], launchDraft = null, + launchDraftCreatedAt = null, chatActive = true, transcriptLoading = false }: { @@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId?: string | null messages?: NativeChatMessage[] launchDraft?: string | null + launchDraftCreatedAt?: number | null chatActive?: boolean transcriptLoading?: boolean }): null { @@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId, messages, launchDraft, + launchDraftCreatedAt, chatActive, transcriptLoading }) @@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => { expect(state?.composerText).toBe('') }) + it('captures the generation paired with the adopted text', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + launchDraftCreatedAt: 7 + }) + ) + ) + + expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 }) + }) + it('does not overwrite typed composer text with a launch draft', async () => { await mount('a') act(() => state?.setComposerText('typed first')) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 2009b591987..a43bc8b286f 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -9,6 +9,7 @@ import { } from './mobile-native-chat-draft-reconcile' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' export type MobileNativeChatPendingMessage = { id: string @@ -44,6 +45,7 @@ export function useMobileNativeChatDrafts(args: { messages: readonly NativeChatMessage[] /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string | null + launchDraftCreatedAt?: number | 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 @@ -60,6 +62,7 @@ export function useMobileNativeChatDrafts(args: { * line, or null once it has been declined or retired. Send paths size their * pre-clear from it, since one Ctrl+U clears only one logical line. */ readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null /** Clear the composer at send time, before the RPC settles. */ clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void /** Put the text back after a definite rejection, unless newer edits exist. */ @@ -78,6 +81,7 @@ export function useMobileNativeChatDrafts(args: { sessionId, messages, launchDraft, + launchDraftCreatedAt, chatActive = true, transcriptLoading } = args @@ -96,10 +100,11 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) - const { readSeededLaunchDraft } = useMobileNativeChatLaunchDraftSeed({ + const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, messages, launchDraft, + launchDraftCreatedAt, chatActive, transcriptLoading, setDrafts @@ -316,6 +321,7 @@ export function useMobileNativeChatDrafts(args: { pending, captureSendOrigin, readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts index f8bef4ebf06..b75dbf12767 100644 --- a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } fr import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { normalizedUserText } from './mobile-native-chat-draft-reconcile' +export type MobileNativeChatLaunchDraftSeed = { + text: string + createdAt: number | null +} + /** * Adopting the host's launch-context prefill as the mobile composer draft, and * retiring it again once it is resolved elsewhere. Split out of the drafts hook @@ -12,6 +17,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { messages: readonly NativeChatMessage[] /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string | null + launchDraftCreatedAt?: number | null chatActive: boolean transcriptLoading?: boolean setDrafts: Dispatch>> @@ -20,12 +26,23 @@ export function useMobileNativeChatLaunchDraftSeed(args: { * once declined or retired. Send paths size their pre-clear from it, since * one Ctrl+U clears only one logical line. */ readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null } { - const { draftKey, messages, launchDraft, chatActive, transcriptLoading, setDrafts } = args + const { + draftKey, + messages, + launchDraft, + launchDraftCreatedAt, + chatActive, + transcriptLoading, + setDrafts + } = args - // Seeded launch-context text per tab; '' marks a permanent decline so a + // Seeded launch-context text per tab; null marks a permanent decline so a // cleared composer never resurrects the prefill. - const seededLaunchDraftByKeyRef = useRef(new Map()) + 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. @@ -47,14 +64,25 @@ export function useMobileNativeChatLaunchDraftSeed(args: { // A user turn already in the transcript means the TUI prefill was submitted // or deliberately cleared; decline instead of resurrecting it. if (messages.some((message) => normalizedUserText(message) !== null)) { - seededLaunchDraftByKeyRef.current.set(draftKey, '') + seededLaunchDraftByKeyRef.current.set(draftKey, null) return } - seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) + seededLaunchDraftByKeyRef.current.set(draftKey, { + text: launchDraft, + createdAt: launchDraftCreatedAt ?? null + }) setDrafts((previous) => (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous ) - }, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading]) + }, [ + chatActive, + draftKey, + launchDraft, + launchDraftCreatedAt, + messages, + setDrafts, + 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 @@ -74,18 +102,21 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!hasUserTurn && launchDraft?.trim()) { return } - seededLaunchDraftByKeyRef.current.set(draftKey, '') + seededLaunchDraftByKeyRef.current.set(draftKey, null) setDrafts((previous) => - (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous + (previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous ) }, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading]) - // '' in the map is a permanent decline, not a parked draft — both mean there is - // nothing of ours on the TUI line, so both read as null. + // A missing or declined entry means there is nothing of ours on the TUI line. const readSeededLaunchDraft = useCallback( - () => (draftKey ? seededLaunchDraftByKeyRef.current.get(draftKey) || null : null), + () => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null), + [draftKey] + ) + const readSeededLaunchDraftSeed = useCallback( + () => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null), [draftKey] ) - return { readSeededLaunchDraft } + return { readSeededLaunchDraft, readSeededLaunchDraftSeed } } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 0fb699ac317..da36cdb925b 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -29,7 +29,9 @@ describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null - const mount = (readSeededLaunchDraft: () => string | null): void => { + const mount = ( + readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null + ): void => { function Probe(): null { api = useMobileNativeChatMessageSend({ client: { sendRequest: vi.fn() } as never, @@ -37,7 +39,7 @@ describe('useMobileNativeChatMessageSend', () => { handleRef: { current: 'term' }, deviceTokenRef: { current: 'device' }, captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, - readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend: () => {}, restoreRejectedDraft: () => {}, acceptSend: () => {}, @@ -51,8 +53,14 @@ describe('useMobileNativeChatMessageSend', () => { }) } - const sentArgs = (): { clearInputFirst?: boolean } => - sendWithOutcome.mock.calls[0]![0] as { clearInputFirst?: boolean } + const sentArgs = (): { + clearInputFirst?: boolean + resolvedLaunchDraft?: { text: string; createdAt: number } + } => + sendWithOutcome.mock.calls[0]![0] as { + clearInputFirst?: boolean + resolvedLaunchDraft?: { text: string; createdAt: number } + } const clearArgs = (): { clearInput?: string } => (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } @@ -72,7 +80,7 @@ describe('useMobileNativeChatMessageSend', () => { }) it('sizes the pre-clear to every line of a parked launch draft', async () => { - mount(() => DRAFT) + mount(() => ({ text: DRAFT, createdAt: 1 })) await act(async () => { await api!.send('hello') }) @@ -81,7 +89,7 @@ describe('useMobileNativeChatMessageSend', () => { it('issues the burst as its OWN write, before the body', async () => { // Bundled into the body write it arrived as literal Ctrl+U text. - mount(() => DRAFT) + mount(() => ({ text: DRAFT, createdAt: 1 })) await act(async () => { await api!.send('hello') }) @@ -95,7 +103,7 @@ describe('useMobileNativeChatMessageSend', () => { it('aborts without sending the body when the clear is rejected', async () => { // Sending on top of an uncleared line is exactly the concatenation bug. clearInputWrite.mockResolvedValue(false) - mount(() => DRAFT) + mount(() => ({ text: DRAFT, createdAt: 1 })) let result: boolean | undefined await act(async () => { result = await api!.send('hello') @@ -107,11 +115,12 @@ describe('useMobileNativeChatMessageSend', () => { it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => { // A Ctrl+U written immediately before body text in the SAME write arrives as // a literal control character, so it would head the received message. - mount(() => DRAFT) + mount(() => ({ text: DRAFT, createdAt: 1 })) await act(async () => { await api!.send('hello') }) expect(sentArgs().clearInputFirst).toBe(false) + expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) }) it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => { @@ -120,6 +129,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(sentArgs().clearInputFirst).toBe(true) + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) it('writes no clear at all when nothing is parked on the line', async () => { @@ -131,7 +141,7 @@ describe('useMobileNativeChatMessageSend', () => { }) it('reads the draft at send time, so a retired seed stops widening the clear', async () => { - let parked: string | null = DRAFT + let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 } mount(() => parked) await act(async () => { await api!.send('first') @@ -147,11 +157,20 @@ describe('useMobileNativeChatMessageSend', () => { it('does not clear an image send after the image was pasted', async () => { // A second clear here would wipe the image that was just pasted. - mount(() => DRAFT) + mount(() => ({ text: DRAFT, createdAt: 1 })) await act(async () => { await api!.send('caption', ['file:///a.png']) }) expect(clearInputWrite).not.toHaveBeenCalled() expect(sentArgs().clearInputFirst).toBe(false) + expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) + }) + + it('does not resolve a composer seed from a question-card answer', async () => { + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.answerQuestion('1') + }) + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) }) 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 b8f50ac9c05..4143af338c7 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -8,6 +8,7 @@ import { } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' +import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' export type MobileNativeChatMessageSend = { @@ -36,7 +37,7 @@ export function useMobileNativeChatMessageSend(args: { captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text Orca parked on the agent's TUI input line, or null. Read * at send time so the pre-clear can be sized to every line it occupies. */ - readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void @@ -53,7 +54,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, deviceTokenRef, captureSendOrigin, - readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -112,12 +113,12 @@ export function useMobileNativeChatMessageSend(args: { // arrived as literal Ctrl+U text and the draft concatenated (see // clearMobileNativeChatInput). A rejected clear aborts the send rather // than pasting on top of an uncleared line. - const seededLaunchDraft = readSeededLaunchDraft() + const seededLaunchDraft = readSeededLaunchDraftSeed() if (seededLaunchDraft && !images?.length) { const cleared = await clearMobileNativeChatInput({ client, terminal: handle, - clearInput: buildAgentTuiClearInputForText(seededLaunchDraft), + clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } @@ -147,6 +148,14 @@ export function useMobileNativeChatMessageSend(args: { // write reaches the agent as a literal control character rather than a // keypress (observed live as a stray \x15 heading the received message). clearInputFirst: !images?.length && !seededLaunchDraft, + ...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number' + ? { + resolvedLaunchDraft: { + text: seededLaunchDraft.text, + createdAt: seededLaunchDraft.createdAt + } + } + : {}), deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } @@ -182,7 +191,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, holdUnconfirmedSend, onSendError, - readSeededLaunchDraft, + readSeededLaunchDraftSeed, restoreRejectedDraft ] ) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 72663c7f908..31baf10aa00 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -73,6 +73,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator' import { HEADLESS_RUNTIME_WINDOW_ID, type RuntimeMobileSessionTabsResult, + type RuntimeSyncWindowGraph, type RuntimeTerminalCreate } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' @@ -2514,6 +2515,130 @@ describe('OrcaRuntimeService', () => { }) }) + it('routes a launch-draft resolution to the handle-owning local and remote renderers', async () => { + const runtime = new OrcaRuntimeService(store) + const nativeChatLaunchDraftResolved = vi.fn() + const events: RuntimeClientEvent[] = [] + runtime.setNotifier({ nativeChatLaunchDraftResolved } as never) + runtime.onClientEvent((event) => events.push(event)) + runtime.attachWindow(1) + const graph: RuntimeSyncWindowGraph = { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ], + mobileSessionTabs: [ + { + worktree: 'repo-1::/tmp/worktree-a', + publicationEpoch: 'launch-draft-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude', + launchDraft: 'seed', + launchDraftCreatedAt: 7, + isActive: true + } + ] + } + ] + } + runtime.syncWindowGraph(1, graph) + const listed = await runtime.listMobileSessionTabs('branch:feature/foo') + const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal') + if (!mobileTab?.terminal) { + throw new Error('expected mobile terminal handle') + } + expect(mobileTab).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 7 }) + + runtime.notifyNativeChatLaunchDraftResolved(mobileTab.terminal, { + text: 'seed', + createdAt: 7 + }) + + expect(nativeChatLaunchDraftResolved).toHaveBeenCalledWith('tab-1', { + text: 'seed', + createdAt: 7 + }) + expect(events).toContainEqual({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) + expect(runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot()).toContainEqual({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) + const retired = (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + expect(retired).not.toHaveProperty('launchDraft') + expect(retired).not.toHaveProperty('launchDraftCreatedAt') + + runtime.markRendererReloading(1) + const replay = runtime.syncWindowGraph(1, { + ...graph, + mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({ + ...snapshot, + publicationEpoch: 'launch-draft-reload', + snapshotVersion: 2 + })) + }) + expect(replay.nativeChatLaunchDraftResolutions).toEqual([ + { tabId: 'tab-1', text: 'seed', createdAt: 7 } + ]) + expect( + (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + ).not.toHaveProperty('launchDraft') + + const reconciled = runtime.syncWindowGraph(1, { + ...graph, + mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({ + ...snapshot, + publicationEpoch: 'launch-draft-reload', + snapshotVersion: 3, + tabs: snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + return { ...tab, launchDraftCreatedAt: 8 } + }) + })) + }) + expect(reconciled.nativeChatLaunchDraftResolutions).toBeUndefined() + expect( + (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + ).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 8 }) + }) + it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => { const runtime = new OrcaRuntimeService(store) const tabId = 'tab-1' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 161314dbb16..36b9c30b3b3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -324,6 +324,7 @@ import { type RuntimeMobileSessionTabsRemovedResult, type RuntimeMobileSessionTabsResult, type RuntimeMobileSessionTabsSnapshot, + type RuntimeNativeChatLaunchDraftResolution, type RuntimeSessionTabCloseReason, type RuntimeBrowserDriverState, type RuntimeTerminalDriverState, @@ -1777,6 +1778,10 @@ type RuntimeNotifier = { // and so a future write coordinator can use the same signal as scheduling // input. See docs/mobile-presence-lock.md. terminalDriverChanged(ptyId: string, driver: DriverState): void + nativeChatLaunchDraftResolved?( + tabId: string, + resolution: { text: string; createdAt: number } + ): void browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void } @@ -2582,6 +2587,12 @@ type LayoutQueueEntry = { }[] } +type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & { + worktreeId: string +} + +const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200 + async function hasLocalWorktreeBaseRef( repoPath: string, baseRef: string, @@ -2702,6 +2713,10 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + private nativeChatLaunchDraftResolutionByTabId = new Map< + string, + NativeChatLaunchDraftResolutionTombstone + >() private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null @@ -4705,12 +4720,146 @@ export class OrcaRuntimeService { return events } + getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } + >[] { + return [...this.nativeChatLaunchDraftResolutionByTabId.values()] + .sort((a, b) => a.tabId.localeCompare(b.tabId)) + .map(({ tabId, text, createdAt }) => ({ + type: 'nativeChatLaunchDraftResolved', + tabId, + text, + createdAt + })) + } + private emitClientEvent(event: RuntimeClientEvent): void { // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event') } + notifyNativeChatLaunchDraftResolved( + handle: string, + resolution: { text: string; createdAt: number } + ): void { + const owner = this.resolveNativeChatLaunchDraftOwner(handle) + if (!owner) { + return + } + const tombstone = { ...owner, ...resolution } + this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId) + this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone) + while ( + this.nativeChatLaunchDraftResolutionByTabId.size > + MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES + ) { + const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value + if (typeof oldestTabId !== 'string') { + break + } + this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId) + } + this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone) + this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution) + this.emitClientEvent({ + type: 'nativeChatLaunchDraftResolved', + tabId: owner.tabId, + ...resolution + }) + } + + private resolveNativeChatLaunchDraftOwner( + handle: string + ): { tabId: string; worktreeId: string } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (!record.tabId.startsWith('pty:')) { + return { tabId: record.tabId, worktreeId: record.worktreeId } + } + const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null + const tabId = + pty?.tabId && !pty.tabId.startsWith('pty:') + ? pty.tabId + : parsePaneKey(pty?.paneKey ?? '')?.tabId + if (!pty || !tabId || tabId.startsWith('pty:')) { + return null + } + return { tabId, worktreeId: pty.worktreeId } + } + + private retireResolvedNativeChatLaunchDraftFromMobileSnapshot( + resolution: NativeChatLaunchDraftResolutionTombstone + ): void { + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) { + continue + } + const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + if (next === snapshot) { + return + } + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...next, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + return + } + } + + private applyNativeChatLaunchDraftResolutionFence( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId) + if ( + !resolution || + !runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) || + tab.launchDraft !== resolution.text || + tab.launchDraftCreatedAt !== resolution.createdAt + ) { + return tab + } + changed = true + const next = { ...tab } + delete next.launchDraft + delete next.launchDraftCreatedAt + return next + }) + return changed ? { ...snapshot, tabs } : snapshot + } + + private reconcileNativeChatLaunchDraftResolutionTombstones( + snapshot: RuntimeMobileSessionTabsSnapshot + ): void { + for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) { + if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) { + continue + } + const surfaces = snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && tab.parentTabId === tabId + ) + if ( + surfaces.length === 0 || + !surfaces.some( + (tab) => + tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt + ) + ) { + this.nativeChatLaunchDraftResolutionByTabId.delete(tabId) + } + } + } + private notifyWorktreesChanged(repoId: string): void { this.notifier?.worktreesChanged(repoId) this.emitClientEvent({ type: 'worktreesChanged', repoId }) @@ -5174,9 +5323,14 @@ export class OrcaRuntimeService { } const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() + const nativeChatLaunchDraftResolutions = + this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map( + ({ tabId, text, createdAt }) => ({ tabId, text, createdAt }) + ) return { ...this.getStatus(), - ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}) + ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}) } } @@ -27790,7 +27944,9 @@ export class OrcaRuntimeService { ) { continue } - const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot) + this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) + const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than @@ -28405,6 +28561,9 @@ export class OrcaRuntimeService { ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), + ...(tab.launchDraftCreatedAt !== undefined + ? { launchDraftCreatedAt: tab.launchDraftCreatedAt } + : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 98c295c2548..a4c10384735 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -332,6 +332,14 @@ describe('remote runtime request connection integration', () => { const worktreeId = 'repo-1::C:\\repo\\feature' const ptyId = `${worktreeId}@@pty-1` let sleepSnapshot: RuntimeClientEvent[] = [] + const launchDraftResolutionSnapshot: RuntimeClientEvent[] = [ + { + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + } + ] const emit = (event: RuntimeClientEvent): void => { for (const listener of clientEventListeners) { listener(event) @@ -362,6 +370,7 @@ describe('remote runtime request connection integration', () => { return () => clientEventListeners.delete(listener) }, getTerminalSleepClientEventSnapshot: () => sleepSnapshot, + getNativeChatLaunchDraftResolutionClientEventSnapshot: () => launchDraftResolutionSnapshot, sleepTerminalsForWorktree: async () => { emit({ type: 'worktreeTerminalSleepState', @@ -440,6 +449,9 @@ describe('remote runtime request connection integration', () => { await waitFor(() => clientEvents.every((events) => events.some((e) => e.type === 'ready')) ) + for (const events of clientEvents) { + expect(events).toContainEqual(launchDraftResolutionSnapshot[0]) + } await expect( requester.request( 'terminal.sleep', @@ -487,6 +499,7 @@ describe('remote runtime request connection integration', () => { .filter((event) => event.type === 'worktreeTerminalSleepState') .map((event) => event.phase) ).toEqual(['committed']) + expect(reconnectedEvents).toContainEqual(launchDraftResolutionSnapshot[0]) sleepSnapshot = [] emit({ diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 9a73948255a..956594dd5e3 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -38,6 +38,10 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) { emit(event) } + for (const event of runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot?.() ?? + []) { + emit(event) + } const sshStates = listRegisteredSshTargets().flatMap((target) => { const state = getPublicSshState(getRegisteredSshState(target.id) ?? null) return state ? [{ targetId: target.id, state }] : [] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 2f874ea9f8c..4ca479ea5cc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -863,6 +863,12 @@ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), interrupt: z.unknown().optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), requireAgentStatus: z.enum(['sendable']).optional(), // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. inputKind: z.enum(['query-reply']).optional(), @@ -1183,6 +1189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalSend, handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -1361,6 +1368,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (result.accepted !== true) { mobileFloorClaim.current?.rollback() } + if ( + result.accepted === true && + params.enter === true && + params.client?.type === 'mobile' && + params.resolvedLaunchDraft + ) { + runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) + } // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. return { send: result } } diff --git a/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts new file mode 100644 index 00000000000..9e5cf52384a --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const RESOLUTION = { text: 'seed', createdAt: 1 } + +function makeRequest(params: unknown): RpcRequest { + return { id: 'request', authToken: 'token', method: 'terminal.send', params } +} + +function makeRuntime(accepted: boolean): OrcaRuntimeService { + return { + getRuntimeId: () => 'runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + beginMobileInputFloor: vi.fn().mockReturnValue({ commit: vi.fn(), rollback: vi.fn() }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted, + bytesWritten: accepted ? 1 : 0 + }), + notifyNativeChatLaunchDraftResolved: vi.fn() + } as unknown as OrcaRuntimeService +} + +async function send( + runtime: OrcaRuntimeService, + options: { enter: boolean; clientType: 'mobile' | 'desktop' } +): Promise { + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + await dispatcher.dispatch( + makeRequest({ + terminal: 'terminal-1', + text: 'hello', + enter: options.enter, + resolvedLaunchDraft: RESOLUTION, + client: { id: 'client-1', type: options.clientType } + }) + ) +} + +describe('terminal.send launch-draft resolution', () => { + it('notifies after an accepted mobile submit', async () => { + const runtime = makeRuntime(true) + + await send(runtime, { enter: true, clientType: 'mobile' }) + + expect(runtime.notifyNativeChatLaunchDraftResolved).toHaveBeenCalledWith( + 'terminal-1', + RESOLUTION + ) + }) + + it.each([ + ['rejected submit', false, true, 'mobile'], + ['clear-only write', true, false, 'mobile'], + ['desktop submit', true, true, 'desktop'] + ] as const)('does not notify after a %s', async (_case, accepted, enter, clientType) => { + const runtime = makeRuntime(accepted) + + await send(runtime, { enter, clientType }) + + expect(runtime.notifyNativeChatLaunchDraftResolved).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 05790e88a9d..d14acf0afea 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -472,6 +472,8 @@ function registerRuntimeWindowLifecycle( send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => send('runtime:terminalDriverChanged', { ptyId, driver }), + nativeChatLaunchDraftResolved: (tabId, resolution) => + send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }), browserDriverChanged: (browserPageId, driver) => send('runtime:browserDriverChanged', { browserPageId, driver }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e0f86d43761..93e6a015769 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3304,6 +3304,9 @@ export type PreloadApi = { onTerminalDriverChanged: ( callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void ) => () => void + onNativeChatLaunchDraftResolved?: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ) => () => void onBrowserDriverChanged: ( callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 63db42aeddd..b36e128c2ef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4250,6 +4250,16 @@ const api = { ipcRenderer.on('runtime:terminalDriverChanged', listener) return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) }, + onNativeChatLaunchDraftResolved: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; text: string; createdAt: number } + ) => callback(data) + ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) + return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) + }, onBrowserDriverChanged: ( callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void ): (() => void) => { 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 index 94c6252db59..2c0bfaf6570 100644 --- 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 @@ -175,6 +175,17 @@ describe('useNativeChatLaunchDraftSignal', () => { expect(result.current.launchDraft).not.toBeNull() }) + it('resolves immediately from an accepted mobile submission', () => { + mocks.storeState.nativeChatLaunchDraftByTabId = { + 'tab-1': launchDraft({ adopted: true, resolved: true, createdAt: SEEDED_AT }) + } + + const { result } = renderSignal([], true) + + expect(result.current.launchDraftResolved).toBe(true) + expect(result.current.launchDraft?.resolved).toBe(true) + }) + it('ignores a draft seeded for another agent', () => { mocks.storeState.nativeChatLaunchDraftByTabId = { 'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT }) 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 index aa4eb0a6d19..f82d5ab1278 100644 --- 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 @@ -51,9 +51,10 @@ export function useNativeChatLaunchDraftSignal(args: { const baseline = held?.baseline ?? null const launchDraftResolved = useMemo( () => - paneLaunchDraft && !transcriptLoading + paneLaunchDraft?.resolved === true || + (paneLaunchDraft && !transcriptLoading ? launchDraftResolvedByTranscript(paneLaunchDraft, messages, baseline) - : false, + : false), [paneLaunchDraft, messages, baseline, transcriptLoading] ) return { launchDraft: paneLaunchDraft, launchDraftResolved } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 03d3a64b743..be2f6301904 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -88,6 +88,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' @@ -936,6 +937,10 @@ export function useIpcEvents(): void { }) return } + if (event.type === 'nativeChatLaunchDraftResolved') { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) + return + } if (event.type === 'reposChanged') { runtimeProjectRefreshScheduler.request(environmentId) return @@ -3472,6 +3477,18 @@ export function useIpcEvents(): void { }) ) + const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( + (event) => { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), { + type: 'nativeChatLaunchDraftResolved', + ...event + }) + } + ) + if (unsubscribeLaunchDraftResolution) { + unsubs.push(unsubscribeLaunchDraftResolution) + } + unsubs.push( window.api.runtime.onBrowserDriverChanged((event) => { if (isRuntimeEnvironmentActive()) { diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts index bffbd1537a3..b279730c812 100644 --- a/src/renderer/src/lib/native-chat-launch-prompt.ts +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -20,4 +20,6 @@ export type NativeChatLaunchDraft = { createdAt: number /** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */ adopted?: boolean + /** Accepted mobile submission consumed the TUI-side copy. */ + resolved?: boolean } diff --git a/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts new file mode 100644 index 00000000000..205392eebe8 --- /dev/null +++ b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' + +describe('applyNativeChatLaunchDraftResolved', () => { + it('routes the exact generation to the store action', () => { + const resolveNativeChatLaunchDraft = vi.fn() + + applyNativeChatLaunchDraftResolved( + { resolveNativeChatLaunchDraft }, + { type: 'nativeChatLaunchDraftResolved', tabId: 'tab-1', text: 'seed', createdAt: 7 } + ) + + expect(resolveNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1', { + text: 'seed', + createdAt: 7 + }) + }) +}) diff --git a/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts new file mode 100644 index 00000000000..8bee4656f94 --- /dev/null +++ b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts @@ -0,0 +1,19 @@ +import type { AppState } from '@/store' +import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' + +type LaunchDraftResolvedEvent = Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } +> + +type LaunchDraftResolutionState = Pick + +export function applyNativeChatLaunchDraftResolved( + state: LaunchDraftResolutionState, + event: LaunchDraftResolvedEvent +): void { + state.resolveNativeChatLaunchDraft(event.tabId, { + text: event.text, + createdAt: event.createdAt + }) +} diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index 4f61c56b68f..983ec41ca87 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -54,17 +54,32 @@ describe('subscribeRuntimeClientEvents', () => { batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] } } }) + capturedOnResponse({ + ok: true, + result: { + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + } + }) capturedOnResponse({ ok: false, error: { code: 'method_not_found', message: 'missing' } }) - expect(onEvent).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenCalledTimes(3) expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' }) expect(onEvent).toHaveBeenCalledWith({ type: 'terminalSideEffects', batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] } }) + expect(onEvent).toHaveBeenCalledWith({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) expect(onError).toHaveBeenCalledWith({ code: 'method_not_found', message: 'missing' }) subscription.unsubscribe() diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 2a41dbb1c57..2631166379c 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -86,6 +86,7 @@ function isRuntimeClientEvent( return ( message.type === 'reposChanged' || message.type === 'worktreesChanged' || + message.type === 'nativeChatLaunchDraftResolved' || message.type === 'terminalSideEffects' || message.type === 'sshStateChanged' || message.type === 'linearLinkedIssueUpdated' || diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 8d3a149e583..0ca21521f12 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -741,11 +741,40 @@ describe('buildMobileSessionTabSnapshots', () => { expect.objectContaining({ type: 'terminal', parentTabId: 'term-1', - launchDraft: 'https://github.com/o/r/issues/12' + launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 1 }) ]) }) + it('retracts a launch draft as soon as mobile resolves it', () => { + 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: 'issue link', + createdAt: 1, + resolved: true + } + } + }) + + expect(buildMobileSessionTabSnapshots(state)[0]?.tabs[0]).not.toHaveProperty('launchDraft') + }) + 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 diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 33cb27ed74a..3b256b2e884 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -40,6 +40,7 @@ import { } from '../components/tab-bar/group-tab-order' import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' type RegisteredTerminalTab = { tabId: string @@ -734,9 +735,16 @@ async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) - getStoreState()?.setRuntimeAgentOrchestrationByPaneKey?.( - result?.agentOrchestrationByPaneKey ?? {} - ) + const currentState = getStoreState() + currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) + for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { + if (currentState) { + applyNativeChatLaunchDraftResolved(currentState, { + type: 'nativeChatLaunchDraftResolved', + ...resolution + }) + } + } } catch (error) { console.error('[runtime] Failed to sync renderer graph:', error) } @@ -1371,8 +1379,12 @@ function buildMobileTerminalSurfaceTabs( // 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 + seededLaunchDraft && + !seededLaunchDraft.resolved && + seededLaunchDraft.agent === terminal.launchAgent + ? seededLaunchDraft + : null + const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null const container = registered?.getContainer() const firstChild = container?.firstElementChild const liveLayoutRoot = serializePaneTree( @@ -1435,7 +1447,12 @@ function buildMobileTerminalSurfaceTabs( ...(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 } : {}), + ...(publishedLaunchDraft + ? { + launchDraft: publishedLaunchDraft.text, + launchDraftCreatedAt: publishedLaunchDraft.createdAt + } + : {}), 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 index 79b1fd4162b..08408c343fc 100644 --- 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 @@ -127,6 +127,21 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false) }) + it('resolves only the exact draft generation', () => { + const store = createTestStore() + const entry = draft(TAB1, 'same text') + store.getState().seedNativeChatLaunchDraft(entry) + + store.getState().resolveNativeChatLaunchDraft(TAB1, { text: entry.text, createdAt: 0 }) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBeUndefined() + + store.getState().resolveNativeChatLaunchDraft(TAB1, { + text: entry.text, + createdAt: entry.createdAt + }) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBe(true) + }) + it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => { const store = createTestStore() seedDrafts(store) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 09911ad8ba9..27fd094bad7 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -569,6 +569,10 @@ export type TerminalSlice = { nativeChatLaunchDraftByTabId: Record seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void markNativeChatLaunchDraftAdopted: (tabId: string) => void + resolveNativeChatLaunchDraft: ( + tabId: string, + resolution: Pick + ) => void clearNativeChatLaunchDraft: (tabId: string) => void pendingStartupByTabId: Record< string, @@ -1127,6 +1131,26 @@ export const createTerminalSlice: StateCreator }) }, + resolveNativeChatLaunchDraft: (tabId, resolution) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if ( + !current || + current.resolved || + current.createdAt !== resolution.createdAt || + current.text !== resolution.text + ) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, resolved: true } + } + } + }) + }, + clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d8e3ef432e2..7cefb6e4dee 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1339,6 +1339,7 @@ function createRuntimeApi(): NonNullable['runtime']> { reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), onTerminalFitOverrideChanged: () => noopUnsubscribe, onTerminalDriverChanged: () => noopUnsubscribe, + onNativeChatLaunchDraftResolved: () => noopUnsubscribe, onBrowserDriverChanged: () => noopUnsubscribe } } diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 3e0b1a255e7..242dd44af24 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -6,10 +6,12 @@ import type { } from './types' import type { SshConnectionState } from './ssh-types' import type { TerminalSideEffectBatch } from './terminal-side-effect-facts' +import type { RuntimeNativeChatLaunchDraftResolution } from './runtime-types' export type RuntimeClientEvent = | { type: 'reposChanged' } | { type: 'worktreesChanged'; repoId: string } + | ({ type: 'nativeChatLaunchDraftResolved' } & RuntimeNativeChatLaunchDraftResolution) | { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch } // Why: SSH connections live on the runtime host; paired clients have no IPC // channel for ssh:state-changed, so without this event their reconnect diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 62d8293bcc8..4a59dba9c90 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -146,10 +146,17 @@ export type RuntimeSyncWindowGraph = { mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[] } +export type RuntimeNativeChatLaunchDraftResolution = { + tabId: string + text: string + createdAt: number +} + export type RuntimeSyncWindowGraphResult = RuntimeStatus & { /** Main owns terminal handles/dispatches, so renderer graph sync returns the * parent metadata needed by title-derived agent rows without name guessing. */ agentOrchestrationByPaneKey?: Record + nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[] } export type RuntimeMobileSessionTerminalTab = { @@ -174,6 +181,8 @@ export type RuntimeMobileSessionTerminalTab = { /** 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 + /** Identity of the launch draft text, used to retire only the adopted generation. */ + launchDraftCreatedAt?: number isActive: boolean }