fix(native-chat): closing a chat no longer throws away the messages it was sending

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.
This commit is contained in:
Brennan Benson
2026-10-04 05:15:54 -07:00
parent e1e690d031
commit 91f05630fc
11 changed files with 297 additions and 50 deletions
@@ -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')
}
@@ -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)
}
}
@@ -240,7 +240,3 @@ export function enqueueStructuredAgentSessionLaunchPrompt(
): StructuredAgentSessionOutboxEntry | null {
return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch', carriedNoteKeys)
}
export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void {
commitStructuredAgentSessionOutbox(sessionId, [])
}
@@ -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'
/**
@@ -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()
}
@@ -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()
@@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({
vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise<unknown>>()
}))
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
@@ -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<unknown>>(),
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')
})
@@ -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)
}
@@ -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<unknown>>(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<typeof RuntimeRpcClient>()),
callRuntimeRpc: rpc.call
}))
vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({
...(await importOriginal<typeof StructuredAgentSessionClient>()),
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<ReturnType<typeof createStructuredAgentSessionOutboxEntry>>
) {
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)
})
})
@@ -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,