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