From b7f8b085bdcccedb2249ff52ab5e9cd84fa0a8d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:56:27 -0700 Subject: [PATCH] fix(mobile): hold the launch-draft prefill until the transcript settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../use-mobile-native-chat-controller.ts | 3 +- .../use-mobile-native-chat-drafts.test.ts | 47 ++++++++++++++++++- .../session/use-mobile-native-chat-drafts.ts | 13 ++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 483de427a09..439dab87a6b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -144,7 +144,8 @@ export function useMobileNativeChatController(args: { tabId: activeSessionTabId, sessionId: activeChatSessionId, messages: nativeChatSession.messages, - launchDraft: activeChatResolution ? (activeSessionTab?.launchDraft ?? null) : null + launchDraft: activeChatResolution ? (activeSessionTab?.launchDraft ?? null) : null, + transcriptLoading: nativeChatSession.status === 'loading' }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null 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 0e0df467949..5388f39e358 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -44,12 +44,14 @@ describe('useMobileNativeChatDrafts', () => { tabId, sessionId = `session-${tabId}`, messages = [], - launchDraft = null + launchDraft = null, + transcriptLoading = false }: { tabId: string sessionId?: string | null messages?: NativeChatMessage[] launchDraft?: string | null + transcriptLoading?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -57,7 +59,8 @@ describe('useMobileNativeChatDrafts', () => { tabId, sessionId, messages, - launchDraft + launchDraft, + transcriptLoading }) return null } @@ -793,6 +796,46 @@ describe('useMobileNativeChatDrafts', () => { 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('clears an untouched prefill when the host stops publishing the launch draft', async () => { await mount('a') await act(async () => diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 34bef478544..cb75940e808 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -43,6 +43,9 @@ export function useMobileNativeChatDrafts(args: { messages: readonly NativeChatMessage[] /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string | null + /** The transcript read is still in flight, so `messages` is not yet the + * session's real history and cannot be trusted to decline the seed. */ + transcriptLoading?: boolean }): { composerText: string setComposerText: Dispatch> @@ -59,7 +62,7 @@ export function useMobileNativeChatDrafts(args: { onUnconfirmed: () => void ) => void } { - const { hostId, worktreeId, tabId, sessionId, messages, launchDraft } = args + const { hostId, worktreeId, tabId, sessionId, messages, launchDraft, transcriptLoading } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null const [drafts, setDrafts] = useState>({}) @@ -85,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { if (!draftKey || !launchDraft?.trim() || seededLaunchDraftByKeyRef.current.has(draftKey)) { return } + // Why: `session.tabs` carries launchDraft before the transcript read settles, + // and an empty in-flight list would let the decline below miss 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)) { @@ -95,7 +104,7 @@ export function useMobileNativeChatDrafts(args: { setDrafts((previous) => (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous ) - }, [draftKey, launchDraft, messages]) + }, [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