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.
This commit is contained in:
Brennan Benson
2026-07-27 16:56:27 -07:00
parent db58121b57
commit b7f8b085bd
3 changed files with 58 additions and 5 deletions
@@ -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
@@ -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 () =>
@@ -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<SetStateAction<string>>
@@ -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<Record<string, string>>({})
@@ -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