fix(native-chat): a chat whose draft holds text is never reused or claimed as empty

The empty-chat check read the pane's old draft key, while the composer and
every hand-back write the conversation's. A chat with typed or given-back
text read as empty, so a bare new chat reused it and a text request claimed
it. It reads the conversation's draft now.
This commit is contained in:
Brennan Benson
2026-10-04 17:47:36 -07:00
parent a0f0b765f9
commit d4e469b697
3 changed files with 27 additions and 26 deletions
@@ -113,10 +113,8 @@ import {
clearNativeChatDraftCacheForTests, clearNativeChatDraftCacheForTests,
writeNativeChatDraftCache writeNativeChatDraftCache
} from '@/components/native-chat/native-chat-draft-cache' } from '@/components/native-chat/native-chat-draft-cache'
import { import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
structuredAgentSessionPaneKey, import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store'
structuredAgentSessionTabId
} from '../../../shared/structured-agent-session-projection'
import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
import { import {
beginStructuredAgentSessionProvisionalLaunch, beginStructuredAgentSessionProvisionalLaunch,
@@ -279,8 +277,8 @@ describe('a second "new chat" with no text', () => {
it('opens a new chat when the idle chat has a typed draft', async () => { it('opens a new chat when the idle chat has a typed draft', async () => {
pick('plus-pick-1') pick('plus-pick-1')
await publishIdle(first.sessionId) await publishIdle(first.sessionId)
const tabId = structuredAgentSessionTabId(first.sessionId) // Under the conversation's key, which the composer and every hand-back write.
writeNativeChatDraftCache(structuredAgentSessionPaneKey(tabId, first.sessionId), 'half a q') writeNativeChatDraftCache(structuredAgentSessionDraftScopeKey(first.sessionId), 'half a q')
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId) expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
}) })
@@ -288,8 +286,7 @@ describe('a second "new chat" with no text', () => {
it('opens a new chat when the idle chat has an image in its composer', async () => { it('opens a new chat when the idle chat has an image in its composer', async () => {
pick('plus-pick-1') pick('plus-pick-1')
await publishIdle(first.sessionId) await publishIdle(first.sessionId)
const tabId = structuredAgentSessionTabId(first.sessionId) appendNativeChatAttachmentCache(structuredAgentSessionDraftScopeKey(first.sessionId), [
appendNativeChatAttachmentCache(structuredAgentSessionPaneKey(tabId, first.sessionId), [
{ id: 'shot', path: '/tmp/shot.png' } { id: 'shot', path: '/tmp/shot.png' }
]) ])
@@ -484,6 +481,15 @@ describe('a "new chat" with text', () => {
) )
}) })
it('leaves a starting chat whose draft holds text, given back or typed, to its user', () => {
mocks.launch.mockImplementation(() => new Promise(() => undefined))
const blank = pick('plus-pick-1')
writeNativeChatDraftCache(structuredAgentSessionDraftScopeKey(blank.sessionId), 'my words')
expect(pick('notes-send', { prompt: 'review notes' }).sessionId).toBe(second.sessionId)
expect(readOutbox(blank.sessionId)).toEqual([])
})
it('takes an empty chat still starting, as before', () => { it('takes an empty chat still starting, as before', () => {
mocks.launch.mockImplementation(() => new Promise(() => undefined)) mocks.launch.mockImplementation(() => new Promise(() => undefined))
const blank = pick('plus-pick-1') const blank = pick('plus-pick-1')
@@ -67,10 +67,7 @@ import {
readNativeChatDraftCache, readNativeChatDraftCache,
writeNativeChatDraftCache writeNativeChatDraftCache
} from '@/components/native-chat/native-chat-draft-cache' } from '@/components/native-chat/native-chat-draft-cache'
import { import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store'
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../../shared/structured-agent-session-projection'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import { import {
getStructuredAgentSessionLaunchLifecycle, getStructuredAgentSessionLaunchLifecycle,
@@ -520,10 +517,8 @@ describe('an empty chat still starting', () => {
it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => { it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' }) const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' })
const paneKey = structuredAgentSessionPaneKey( // The conversation's draft, which its composer writes.
structuredAgentSessionTabId(blank.sessionId), const paneKey = structuredAgentSessionDraftScopeKey(blank.sessionId)
blank.sessionId
)
writeNativeChatDraftCache(paneKey, 'half a question') writeNativeChatDraftCache(paneKey, 'half a question')
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend) const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend)
@@ -1,19 +1,19 @@
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../../shared/structured-agent-session-projection'
import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache'
// The draft store, not the composer hook: the launch must not load the composer (a store cycle). // The draft store, not the composer hook: the launch must not load the composer (a store cycle).
import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import {
readNativeChatComposerDraft,
structuredAgentSessionDraftScopeKey
} from '@/components/native-chat/native-chat-composer-draft-store'
/** A starting chat is empty until its user sends into it or puts text or images in its composer; /** A starting chat is empty until its user sends into it or puts text or images in its draft (typed,
* after that it is theirs, and another request never goes into it. */ * or given back); after that it is theirs, and another request never goes into it. Read by the
* conversation's key, which the composer and every hand-back write. */
export function isStructuredLaunchChatEmpty(sessionId: string): boolean { export function isStructuredLaunchChatEmpty(sessionId: string): boolean {
const paneKey = structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) const scopeKey = structuredAgentSessionDraftScopeKey(sessionId)
return ( return (
getStructuredAgentSessionOutbox(sessionId).length === 0 && getStructuredAgentSessionOutbox(sessionId).length === 0 &&
readNativeChatDraftCache(paneKey).trim() === '' && readNativeChatDraftCache(scopeKey).trim() === '' &&
readNativeChatComposerDraft(paneKey).images.length === 0 readNativeChatComposerDraft(scopeKey).images.length === 0
) )
} }