From 91f05630fca920fa7482dd5acb313edac8b4b490 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:15:54 -0700 Subject: [PATCH] fix(native-chat): closing a chat no longer throws away the messages it was sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a structured chat's tab cleared the chat's whole outbox: a message still being sent ("Orca will keep trying…") or one queued behind it was deleted, its text kept nowhere. Closing now throws away only a cancelled launch's own prompt that never went out (its notes return to the shelf). Any other message that never went out comes back to the conversation's draft, with its notes following the text. A message that went out may be the host's, so it stays in the outbox and settles when the chat is reopened. The same rule now serves a launch's cancel and a removed workspace's unpublished launches. The image hand-back that this needs moved out of the composer hook into its own module, so the close path doesn't load the composer (and through it the app store) from inside a store slice. --- .../native-chat/native-chat-draft-images.ts | 38 ++++ ...uctured-agent-session-outbox-chat-close.ts | 41 ++++ ...structured-agent-session-outbox-storage.ts | 4 - .../structured-agent-session-returned-send.ts | 2 +- .../use-native-chat-composer-attachments.ts | 32 +-- .../lib/structured-agent-session-launch.ts | 8 +- ...host-structured-launch-bookkeeping.test.ts | 4 +- ...tured-agent-session-tab-retirement.test.ts | 8 +- ...structured-agent-session-tab-retirement.ts | 6 +- ...tructured-chat-close-keeps-outbox.test.tsx | 196 ++++++++++++++++++ .../teardown/worktree-purge-state.ts | 8 +- 11 files changed, 297 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-draft-images.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-chat-close.ts create mode 100644 src/renderer/src/store/slices/tabs/structured-chat-close-keeps-outbox.test.tsx diff --git a/src/renderer/src/components/native-chat/native-chat-draft-images.ts b/src/renderer/src/components/native-chat/native-chat-draft-images.ts new file mode 100644 index 00000000000..314bc2f3b13 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-draft-images.ts @@ -0,0 +1,38 @@ +// The images of a composer's draft, added to without the composer: the one append a hand-back and +// the composer's own picks share. Kept apart from the composer hook, so code that hands a message +// back (a closing chat, a settled send) never loads the composer. + +import { basename } from '@/lib/path' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { + readNativeChatComposerDraft, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' + +/** Adds settled images after the ones the draft holds now, skipping one it already holds, as a + * repeated hand-back does. Saved at once: when Stop gives images back, the copy they came from + * goes right after this. Only an image the user attaches (`fromUser`) takes the place of a + * placeholder with its file name, as a re-pick does. */ +export function appendNativeChatAttachmentCache( + scopeKey: string, + appended: readonly NativeChatComposerImageAttachment[], + options?: { fromUser?: boolean } +): void { + if (appended.length === 0) { + return + } + const images = [...readNativeChatComposerDraft(scopeKey).images] + for (const { id, path, connectionId } of appended) { + // Preview URLs can retain the full clipboard Blob, so only the path is kept. + const image = { id, path, ...(connectionId ? { connectionId } : {}) } + const placeholder = options?.fromUser + ? images.findIndex((held) => held.unavailableName === basename(path)) + : -1 + if (placeholder !== -1) { + images[placeholder] = image + } else if (!images.some((held) => held.id === id)) { + images.push(image) + } + } + updateNativeChatComposerDraft(scopeKey, { images }, 'immediate') +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-chat-close.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-chat-close.ts new file mode 100644 index 00000000000..c442f93a425 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-chat-close.ts @@ -0,0 +1,41 @@ +// What closing a chat does to its outbox: it never throws away a message the person sent. A +// cancelled launch's own prompt, never sent, goes with the launch (its notes back on the shelf). +// Any other message that never went out comes back to the conversation's draft, with its notes +// following the text. One that went out may be the host's, so it stays and settles when the chat +// is reopened. + +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryAwaitsSettlement } from '../../../../shared/structured-agent-session-outbox-admission' +import { + commitStructuredAgentSessionOutbox, + getStructuredAgentSessionOutbox +} from './structured-agent-session-outbox-storage' +import { endStructuredAgentSessionEntry } from './structured-agent-session-entry-endings' +import { returnStructuredAgentSessionMessage } from './structured-agent-session-returned-send' + +function neverWentOut(entry: StructuredAgentSessionOutboxEntry): boolean { + return entry.lastAttemptAt === null && !structuredAgentSessionEntryAwaitsSettlement(entry) +} + +/** Settles a closing chat's outbox. `cancelledLaunch`: the chat is a launch that never published, + * whose own prompt goes with it. */ +export function settleStructuredAgentSessionOutboxForClosedChat( + sessionId: string, + options: { cancelledLaunch: boolean } +): void { + const current = getStructuredAgentSessionOutbox(sessionId) + const kept: StructuredAgentSessionOutboxEntry[] = [] + for (const entry of current) { + if (!neverWentOut(entry)) { + kept.push(entry) + } else if (!(options.cancelledLaunch && entry.source === 'launch')) { + // The draft holds the text before the notes it carried are cleared. + returnStructuredAgentSessionMessage(entry) + endStructuredAgentSessionEntry(entry, 'returned') + } + } + if (kept.length !== current.length) { + // The cancelled launch's prompt ends as discarded here, putting its notes back. + commitStructuredAgentSessionOutbox(sessionId, kept) + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 53e45a5aa14..72c36916c87 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -240,7 +240,3 @@ export function enqueueStructuredAgentSessionLaunchPrompt( ): StructuredAgentSessionOutboxEntry | null { return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch', carriedNoteKeys) } - -export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { - commitStructuredAgentSessionOutbox(sessionId, []) -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-returned-send.ts b/src/renderer/src/components/native-chat/structured-agent-session-returned-send.ts index dfb192a62c9..99f9ddb3e70 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-returned-send.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-returned-send.ts @@ -8,7 +8,7 @@ import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-sessi import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' import { returnNativeChatDraftText } from './native-chat-draft-cache' -import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +import { appendNativeChatAttachmentCache } from './native-chat-draft-images' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' /** diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index f2d29a5e085..ff5c5e6323e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -9,7 +9,6 @@ import { type RefObject } from 'react' import { translate } from '@/i18n/i18n' -import { basename } from '@/lib/path' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget @@ -26,6 +25,9 @@ import { import { verifyRestoredNativeChatComposerDraftImages } from './native-chat-composer-draft-image-check' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' +import { appendNativeChatAttachmentCache } from './native-chat-draft-images' + +export { appendNativeChatAttachmentCache } export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -301,34 +303,6 @@ export function readNativeChatAttachmentCache( return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image })) } -/** Adds settled images after the ones the draft holds now, skipping one it already holds, as a - * repeated hand-back does. Saved at once: when Stop gives images back, the copy they came from - * goes right after this. Only an image the user attaches (`fromUser`) takes the place of a - * placeholder with its file name, as a re-pick does. */ -export function appendNativeChatAttachmentCache( - scopeKey: string, - appended: readonly NativeChatComposerImageAttachment[], - options?: { fromUser?: boolean } -): void { - if (appended.length === 0) { - return - } - const images = [...readNativeChatComposerDraft(scopeKey).images] - for (const { id, path, connectionId } of appended) { - // Preview URLs can retain the full clipboard Blob, so only the path is kept. - const image = { id, path, ...(connectionId ? { connectionId } : {}) } - const placeholder = options?.fromUser - ? images.findIndex((held) => held.unavailableName === basename(path)) - : -1 - if (placeholder !== -1) { - images[placeholder] = image - } else if (!images.some((held) => held.id === id)) { - images.push(image) - } - } - updateNativeChatComposerDraft(scopeKey, { images }, 'immediate') -} - export function clearNativeChatAttachmentCacheForTests(): void { clearNativeChatComposerDraftsForTests() } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 25dba85a1a3..7e9d30146e1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -7,10 +7,8 @@ import { retryStructuredAgentSessionLaunchIntent, StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { - discardStructuredAgentSessionLaunchOutbox, - enqueueStructuredAgentSessionLaunchPrompt -} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { settleStructuredAgentSessionOutboxForClosedChat } from '@/components/native-chat/structured-agent-session-outbox-chat-close' import { launchAndReconcile, reconcileUnknownLaunch, @@ -281,7 +279,7 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin return false } markStructuredAgentSessionLaunchCancelled(worktreeId, sessionId, state.intent.executionHostId) - discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) + settleStructuredAgentSessionOutboxForClosedChat(state.intent.sessionId, { cancelledLaunch: true }) launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId) abandonStructuredAgentSessionLaunchIntent(state.intent) notifyStructuredLaunchListeners() diff --git a/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts b/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts index b52c92ef5ac..8e877aab8b8 100644 --- a/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts +++ b/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts @@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({ vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise>() })) -vi.mock('@/components/native-chat/structured-agent-session-outbox-storage', () => ({ - discardStructuredAgentSessionLaunchOutbox: vi.fn() +vi.mock('@/components/native-chat/structured-agent-session-outbox-chat-close', () => ({ + settleStructuredAgentSessionOutboxForClosedChat: vi.fn() })) vi.mock('./structured-agent-session-close', () => ({ closeStructuredAgentSession: mocks.closeSession diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts index e0c9c562b78..c7a18e85e31 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ vi.fn<(target: RuntimeClientTarget, sessionId: string) => Promise<'closed' | 'unsupported'>>(), callRuntime: vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise>(), - discardOutbox: vi.fn<(sessionId: string) => void>(), + settleOutbox: vi.fn<(sessionId: string, options: { cancelledLaunch: boolean }) => void>(), hasTombstone: vi.fn<(worktreeId: string, sessionId: string) => boolean>(), markCancelled: vi.fn<(worktreeId: string, sessionId: string, executionHostId: string) => boolean>() @@ -17,8 +17,8 @@ vi.mock('@/lib/structured-agent-session-launch-registry', () => ({ hasStructuredAgentSessionLaunchCancellationTombstone: mocks.hasTombstone, markStructuredAgentSessionLaunchCancelled: mocks.markCancelled })) -vi.mock('@/components/native-chat/structured-agent-session-outbox-storage', () => ({ - discardStructuredAgentSessionLaunchOutbox: mocks.discardOutbox +vi.mock('@/components/native-chat/structured-agent-session-outbox-chat-close', () => ({ + settleStructuredAgentSessionOutboxForClosedChat: mocks.settleOutbox })) vi.mock('./structured-agent-session-close', () => ({ closeStructuredAgentSession: mocks.closeSession @@ -89,7 +89,7 @@ describe('structured agent session tab retirement', () => { provisional: true }) expect(mocks.markCancelled).toHaveBeenCalledWith('wt-1', 'session-1', 'local') - expect(mocks.discardOutbox).toHaveBeenCalledWith('session-1') + expect(mocks.settleOutbox).toHaveBeenCalledWith('session-1', { cancelledLaunch: true }) await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1') }) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 86e47568480..d23e4218420 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -8,7 +8,7 @@ import { markStructuredAgentSessionLaunchCancelled } from '@/lib/structured-agent-session-launch-registry' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' -import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { settleStructuredAgentSessionOutboxForClosedChat } from '@/components/native-chat/structured-agent-session-outbox-chat-close' import { closeStructuredAgentSession } from './structured-agent-session-close' import { withLocalSessionTabCloseOwner } from './local-session-tab-close-owner' import { executionHostIdForStructuredTarget } from './structured-agent-session-owner' @@ -76,7 +76,9 @@ export function beginStructuredAgentSessionTabClose(args: { executionHostIdForStructuredTarget(args.target) ) } - discardStructuredAgentSessionLaunchOutbox(args.sessionId) + settleStructuredAgentSessionOutboxForClosedChat(args.sessionId, { + cancelledLaunch: args.provisional + }) retireStructuredAgentSessionTab(args) } diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-keeps-outbox.test.tsx b/src/renderer/src/store/slices/tabs/structured-chat-close-keeps-outbox.test.tsx new file mode 100644 index 00000000000..5bb9c50240a --- /dev/null +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-keeps-outbox.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom + +// Closing a chat's tab never throws away a message the person sent, even with the host out of +// reach: one that went out stays in the outbox and is sent again when the chat is reopened; one +// that never went out comes back to the conversation's draft; only a cancelled launch's own prompt +// goes with the launch, its notes back on the shelf. + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStructuredAgentSessionOutboxEntry } from '../../../../../shared/structured-agent-session-outbox' +import { createStructuredAgentSessionOperationId } from '../../../../../shared/structured-agent-session-mutation' + +const rpc = vi.hoisted(() => ({ + call: vi.fn(async () => { + throw new Error('host unreachable') + }), + session: vi.fn<(...args: unknown[]) => Promise>(async () => { + throw new Error('host unreachable') + }) +})) + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc: rpc.call +})) +vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ + ...(await importOriginal()), + callStructuredAgentSession: rpc.session +})) + +import { + createTestStore, + makeTabGroup, + makeUnifiedTab, + makeWorktree, + seedStore +} from '../store-test-helpers' +import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' +import type * as StructuredAgentSessionClient from '@/runtime/structured-agent-session-client' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey +} from '@/components/native-chat/native-chat-composer-draft-store' +import { clearNativeChatDraftCacheForTests } from '@/components/native-chat/native-chat-draft-cache' +import { + getStructuredAgentSessionOutbox, + readOutbox, + writeOutbox +} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { resetStructuredAgentSessionCarriedNotesForTests } from '@/components/native-chat/structured-agent-session-outbox-carried-notes' +import { useStructuredAgentSessionOutbox } from '@/components/native-chat/use-structured-agent-session-outbox' +import { markStructuredAgentSessionLaunchCancelled } from '@/lib/structured-agent-session-launch-registry' +import { isNoteInFlight } from '@/lib/notes-send-in-flight' + +const WT = 'repo1::/path/wt1' +const SID = 'session-close-1' + +function seed() { + const store = createTestStore() + const chat = makeUnifiedTab({ + id: `agent-session:${SID}`, + entityId: SID, + contentType: 'agent-session', + worktreeId: WT, + groupId: 'g1', + executionHostId: 'local' + }) + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + unifiedTabsByWorktree: { [WT]: [chat] }, + groupsByWorktree: { + [WT]: [makeTabGroup({ id: 'g1', worktreeId: WT, activeTabId: chat.id, tabOrder: [chat.id] })] + } + }) + return { store, chat } +} + +function entry( + text: string, + patch: Partial> +) { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: createStructuredAgentSessionOperationId(createBrowserUuid), + sessionId: SID, + text, + attachments: [], + queuedAt: 1000 + }), + ...patch + } +} + +function draft(): string { + return readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(SID)).text +} + +function accepted(params: unknown) { + const id = + typeof params === 'object' && + params !== null && + 'envelope' in params && + typeof params.envelope === 'object' && + params.envelope !== null && + 'clientOperationId' in params.envelope && + typeof params.envelope.clientOperationId === 'string' + ? params.envelope.clientOperationId + : '' + return { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'e', sequence: 2 }, + value: { + clientMessageId: id, + submission: { + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 1 + } + } + } +} + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + resetStructuredAgentSessionCarriedNotesForTests() + rpc.session.mockReset() + rpc.session.mockRejectedValue(new Error('host unreachable')) + Object.assign(window, { api: { pty: { kill: vi.fn() }, runtimeEnvironments: { call: vi.fn() } } }) +}) + +afterEach(cleanup) + +describe('closing a chat tab while the host is out of reach', () => { + it('keeps a message that went out, and sends it again once the chat is reopened', async () => { + const { store, chat } = seed() + const sent = entry('UNCONFIRMED-TEXT', { state: 'unconfirmed', lastAttemptAt: 2000 }) + writeOutbox(SID, [sent]) + + store.getState().closeUnifiedTab(chat.id) + expect(readOutbox(SID, { recoverDispatching: false })).toMatchObject([ + { clientMessageId: sent.clientMessageId, state: 'unconfirmed' } + ]) + expect(draft()).toBe('') + + // Reopened, with the host back: the same id goes again and the host has it. + rpc.session.mockImplementation(async (_target, _method, params) => accepted(params)) + renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SID, + target: { kind: 'local' }, + fence: 1, + submissions: [], + journalCursor: { epoch: 'e', sequence: 1 } + }) + ) + await vi.waitFor(() => expect(getStructuredAgentSessionOutbox(SID)).toEqual([]), { + timeout: 3000 + }) + const sends = rpc.session.mock.calls.filter((call) => call[1] === 'agentSession.send') + expect(sends.length).toBeGreaterThan(0) + expect(sends.every((call) => JSON.stringify(call[2]).includes(sent.clientMessageId))).toBe(true) + }) + + it("hands a message that never went out back to the conversation's draft", () => { + const { store, chat } = seed() + writeOutbox(SID, [entry('QUEUED-TEXT', { state: 'queued' })]) + + store.getState().closeUnifiedTab(chat.id) + expect(readOutbox(SID)).toEqual([]) + expect(draft()).toBe('QUEUED-TEXT') + }) + + it("discards a cancelled launch's own prompt, putting its notes back", () => { + const { store, chat } = seed() + markStructuredAgentSessionLaunchCancelled(WT, SID, 'local') + writeOutbox(SID, [entry('LAUNCH-TEXT', { source: 'launch', carriedNoteKeys: ['note-a'] })]) + expect(isNoteInFlight('note-a')).toBe(true) + + store.getState().closeUnifiedTab(chat.id) + expect(readOutbox(SID)).toEqual([]) + expect(draft()).toBe('') + expect(isNoteInFlight('note-a')).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 26f86df2810..cdd4211fe2b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -15,7 +15,7 @@ import { shouldRetainStructuredAgentSessionLaunchTab, structuredLaunchStates } from '@/lib/structured-agent-session-launch-registry' -import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { settleStructuredAgentSessionOutboxForClosedChat } from '@/components/native-chat/structured-agent-session-outbox-chat-close' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import { structuredAgentSessionFocusOwner, @@ -44,7 +44,9 @@ export function buildWorktreePurgeState( launch.intent.sessionId, launch.intent.executionHostId ) - discardStructuredAgentSessionLaunchOutbox(launch.intent.sessionId) + settleStructuredAgentSessionOutboxForClosedChat(launch.intent.sessionId, { + cancelledLaunch: true + }) clearWebSessionFocusIntentIfMatches( structuredAgentSessionFocusOwner(launch.intent.target), worktreeId, @@ -67,7 +69,7 @@ export function buildWorktreePurgeState( shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) ) { markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner) - discardStructuredAgentSessionLaunchOutbox(tab.entityId) + settleStructuredAgentSessionOutboxForClosedChat(tab.entityId, { cancelledLaunch: true }) clearWebSessionFocusIntentIfMatches( structuredAgentSessionFocusOwner(target), worktreeId,