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,