diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts new file mode 100644 index 000000000000..c62f7909c141 --- /dev/null +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -0,0 +1,302 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { startNativeChatDraftLoad } from './native-chat-draft-startup' +import { + applyWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot +} from '@/runtime/web-session-tabs-sync' +import { + applyLocalStructuredSessionTabSnapshots, + resetLocalStructuredSessionVersionForTests +} from '@/runtime/local-structured-session-tabs-sync' +import { + ENV, + NOW, + WT, + makeSnapshot, + resetWebSessionTabsSyncTestState +} from '@/runtime/web-session-tabs-sync-test-harness' +import { + clearNativeChatComposerDraftsForTests, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope, + updateNativeChatComposerDraft, + nativeChatComposerDraftWritesSettled, + waitForNativeChatComposerDrafts +} from '@/components/native-chat/native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from '@/components/native-chat/native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + settleNativeChatPendingAttachment +} from '@/components/native-chat/native-chat-pending-attachment-cache' + +const initial = useAppStore.getState() +let stop = (): void => {} +function chat(entityId: string, id = 'pane') { + return { + id, + entityId, + contentType: 'agent-session' as const, + agentSessionAgent: 'codex' as const, + worktreeId: WT, + groupId: 'group', + label: 'Chat', + customLabel: null, + color: null, + createdAt: 1, + sortOrder: 0 + } +} +function snapshot(sessionId: string, source: string, version = 1) { + return makeSnapshot( + [ + { + type: 'agent-session', + id: `agent-session:${sessionId}`, + sessionId, + replacesSessionId: source, + agent: 'codex', + title: 'Chat', + isActive: true + } + ], + { snapshotVersion: version } + ) +} +function publish(frame: ReturnType): void { + if (shouldApplyWebSessionTabsSnapshot(frame, ENV)) { + useAppStore.setState((state) => + applyWebSessionTabsSnapshot(state, frame, ENV, NOW, { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + }) + ) + } +} +async function settled(): Promise { + await Promise.resolve() + await nativeChatComposerDraftWritesSettled() + await Promise.resolve() +} +beforeEach(async () => { + resetWebSessionTabsSyncTestState() + resetLocalStructuredSessionVersionForTests() + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + stop = startNativeChatDraftLoad() + await waitForNativeChatComposerDrafts(1000) +}) +afterEach(() => { + stop() + useAppStore.setState(initial, true) + clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() + localStorage.removeItem('orca:nativeChatComposerDraftJournal:v1') + vi.useRealTimers() +}) + +it('moves text and images through the real host snapshot binding change', async () => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + updateNativeChatComposerDraft( + scope('a'), + { + text: 'typed during clear', + images: [{ id: 'i', path: '/remote/shot.png', connectionId: 'ssh-1' }] + }, + 'immediate' + ) + publish(snapshot('b', 'a')) + await settled() + expect(useAppStore.getState().unifiedTabsByWorktree[WT]?.[0]).toMatchObject({ + id: 'pane', + entityId: 'b' + }) + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: 'typed during clear', + images: [{ id: 'i', path: '/remote/shot.png', connectionId: 'ssh-1' }] + }) + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') +}) + +it('leaves a saved draft on its earlier conversation when this renderer did not observe the clear', async () => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'reachable from history', images: [], savedAt: 1 }) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + await waitForNativeChatComposerDrafts(1000) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('reachable from history') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') +}) + +it('keeps a later closed-history draft through restart and repeated replacement publications', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'first draft' }, 'immediate') + publish(snapshot('b', 'a')) + await settled() + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b'), chat('a', 'history')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'later history draft' }, 'immediate') + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + await settled() + stop() + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + resetWebSessionTabsSyncTestState() + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + await waitForNativeChatComposerDrafts(1000) + publish(snapshot('b', 'a', 2)) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('later history draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('first draft') +}) + +it.each(['local', 'paired'] as const)( + 'does not infer a move from %s unverifiable then verified inventories', + async (host) => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'earlier conversation' }, 'immediate') + const first = makeSnapshot([], { agentSessionsUnverifiable: true }) + const next = snapshot('b', 'a', 2) + for (const frame of [first, next]) { + if (host === 'paired') { + publish(frame) + } else { + useAppStore.setState((state) => + applyLocalStructuredSessionTabSnapshots(state, [frame], undefined, NOW, { + authoritative: true + }) + ) + } + await settled() + } + expect(readNativeChatComposerDraft(scope('a')).text).toBe('earlier conversation') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + publish(snapshot('c', 'b', 3)) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('earlier conversation') + } +) + +it('carries back-to-back hydrated replacements without leaving an intermediate draft', () => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'question' }, 'immediate') + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + publish(snapshot('b', 'a')) + publish(snapshot('c', 'b', 2)) + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + expect(readNativeChatComposerDraft(scope('c')).text).toBe('question') + settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1') + expect(readNativeChatComposerDraft(scope('c')).images).toEqual([ + { id: 'upload', path: '/ssh/image.png', connectionId: 'ssh-1' } + ]) +}) + +it.each(['before', 'after'] as const)( + 'leaves input and an upload settled %s a pre-load replacement in history', + async (settleAt) => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'saved history input', images: [], savedAt: 1 }) + let finish = (): void => {} + storage.loadAll = () => + new Promise((resolve) => { + const drafts = new Map(storage.drafts) + finish = () => resolve(drafts) + }) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + if (settleAt === 'before') { + settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1') + await settled() + } + publish(snapshot('b', 'a')) + if (settleAt === 'after') { + settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1') + } + finish() + await waitForNativeChatComposerDrafts(1000) + await settled() + expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ + text: 'saved history input', + images: [{ id: 'upload', path: '/ssh/image.png', connectionId: 'ssh-1' }] + }) + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ text: '', images: [] }) + } +) + +it('does not replay a skipped clear after the existing load retry succeeds', async () => { + stop() + clearNativeChatComposerDraftsForTests() + vi.useFakeTimers() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'history draft', images: [], savedAt: 1 }) + storage.loadAll = vi + .fn() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockImplementationOnce(async () => new Map(storage.drafts)) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + await waitForNativeChatComposerDrafts(1000) + await vi.advanceTimersByTimeAsync(1000) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('history draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') +}) + +it.each(['draft', 'removal'] as const)( + 'keeps journal recovery authoritative after a skipped pre-load clear: %s', + async (kind) => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'older disk text', images: [], savedAt: 1 }) + localStorage.setItem( + 'orca:nativeChatComposerDraftJournal:v1', + JSON.stringify([ + { + scopeKey: scope('a'), + at: 2, + run: 'earlier-run', + draft: kind === 'draft' ? { text: 'newer recovered text', images: [], savedAt: 2 } : null + } + ]) + ) + let finish = (): void => {} + storage.loadAll = () => + new Promise((resolve) => { + const loaded = new Map(storage.drafts) + finish = () => resolve(loaded) + }) + storage.read = vi.fn(storage.read) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + expect(storage.read).not.toHaveBeenCalled() + finish() + await waitForNativeChatComposerDrafts(1000) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe( + kind === 'draft' ? 'newer recovered text' : '' + ) + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + } +) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index 4d64bdd708f2..18d6c29deba0 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -5,21 +5,34 @@ import { setNativeChatComposerDraftOwnerResolver, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' +import { + moveStructuredAgentSessionDraft, + structuredAgentSessionConversationMoves +} from '@/components/native-chat/structured-agent-session-draft-move' -// Why bounded: loading drafts is bookkeeping and must never hold startup; a slower load still -// fills in every draft not edited meanwhile when it lands. +// A slower load fills drafts not edited meanwhile without holding startup. const DRAFT_LOAD_WAIT_MS = 1_500 /** Before any startup step, so a step that fails can't leave the drafts unloaded. */ -export function startNativeChatDraftLoad(): void { +export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) void hydrateNativeChatComposerDrafts() + return useAppStore.subscribe((state, previous) => { + if (state.unifiedTabsByWorktree === previous.unifiedTabsByWorktree) { + return + } + for (const { from, to } of structuredAgentSessionConversationMoves( + previous.unifiedTabsByWorktree, + state.unifiedTabsByWorktree + )) { + moveStructuredAgentSessionDraft(from, to) + } + }) } -/** Startup waits for the drafts alongside the session read, so a composer shows its draft from - * its first frame. */ +/** Startup waits alongside the session read, so a composer shows its draft from its first frame. */ export function waitForNativeChatDraftsAtStartup(): Promise { return waitForNativeChatComposerDrafts(DRAFT_LOAD_WAIT_MS) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2dd6a57b55a0..62315c377ece 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -174,12 +174,8 @@ const NativeChatComposerPane = forwardRef voi } const SCOPE = 'agent-session:s1' +const pickedDraft: NativeChatComposerDraft = { + text: '$review-long', + images: [], + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } +} beforeEach(() => { localStorage.clear() @@ -115,4 +132,55 @@ describe('native-chat composer draft addition', () => { expect(next.drafts.readNativeChatDraftCache('agent-session:s2')).toBe('please go\n\ngo') expect(storage.drafts.get('agent-session:s2')?.text).toBe('please go\n\ngo') }) + + it('preserves picked nodes while appending literal text and file references', () => { + const result = withNativeChatComposerDraftAddition(pickedDraft, { + text: '$review\n@/report.pdf' + }) + expect(result.text).toBe('$review-long\n\n$review\n@/report.pdf') + expect(result.document?.content).toEqual([ + pickedDraft.document?.content?.[0], + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: '$review' }] }, + { type: 'paragraph', content: [{ type: 'text', text: '@/report.pdf' }] } + ]) + }) + + it('keeps the document unchanged for image-only additions and duplicate text replay', () => { + const imageOnly = withNativeChatComposerDraftAddition(pickedDraft, { + images: [{ id: 'image', path: '/image.png' }] + }) + expect(imageOnly.document).toBe(pickedDraft.document) + const first = withNativeChatComposerDraftAddition(pickedDraft, { text: 'go' }) + expect(withNativeChatComposerDraftAddition(first, { text: 'go' }).document).toBe(first.document) + }) + + it('keeps picked nodes when a hand-back waits for the stored draft to load', async () => { + storage.drafts.set(SCOPE, { ...pickedDraft, savedAt: 1 }) + const { using, land } = slowLoading() + const next = await reload({ using, hydrate: false }) + await next.store.waitForNativeChatComposerDrafts(1) + next.drafts.appendNativeChatDraftCache(SCOPE, 'go') + land() + await next.store.waitForNativeChatComposerDrafts(1_000) + await next.store.nativeChatComposerDraftWritesSettled() + const result = next.store.readNativeChatComposerDraft(SCOPE) + expect(result.text).toBe('$review-long\n\ngo') + expect(result.document?.content?.[0]).toEqual(pickedDraft.document?.content?.[0]) + expect(storage.drafts.get(SCOPE)?.document).toEqual(result.document) + }) + + it('replays an unconfirmed append after a crash without flattening picked nodes', async () => { + storage.drafts.set(SCOPE, { ...pickedDraft, savedAt: 1 }) + const crashing = { ...storage, write: () => new Promise(() => {}) } + const crashed = await reload({ using: crashing }) + expect(crashed.drafts.appendNativeChatDraftCache(SCOPE, 'go')).toBe(true) + crashed.store.clearNativeChatComposerDraftsForTests() + const next = await reload() + const result = next.store.readNativeChatComposerDraft(SCOPE) + expect(result.text).toBe('$review-long\n\ngo') + expect(result.document?.content?.[0]).toEqual(pickedDraft.document?.content?.[0]) + await next.store.nativeChatComposerDraftWritesSettled() + expect(storage.drafts.get(SCOPE)?.document).toEqual(result.document) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts index 76b82af53935..441ce2264cc9 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts @@ -3,6 +3,7 @@ // loaded later, or replayed from the journal after a crash. import { appendReturnedDraftText } from '../../../../shared/returned-draft-text' +import { appendPromptDocumentText } from './native-chat-prompt-document-text' import { basename } from '@/lib/path' import type { NativeChatComposerDraft, @@ -25,7 +26,7 @@ export function withNativeChatComposerDraftAddition( draft: NativeChatComposerDraft, addition: NativeChatComposerDraftAddition, options: { once?: boolean } = {} -): Pick { +): Pick { const text = appendReturnedDraftText(draft.text, addition.text ?? '') const images = [...draft.images] for (const { id, path, connectionId } of addition.images ?? []) { @@ -42,5 +43,5 @@ export function withNativeChatComposerDraftAddition( images[placeholder] = image } } - return { text, images } + return { text, images, document: appendPromptDocumentText(draft.document, draft.text, text) } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts index c1b979e36456..dc4a210d6cc6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts @@ -145,7 +145,6 @@ export function replayNativeChatComposerDraftJournal( drafts.set(scopeKey, { ...from, ...next, - ...(next.text === from.text ? {} : { document: undefined }), savedAt: at }) changed.add(scopeKey) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index 02de2aa92bf1..53da6ccfc2e0 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -34,7 +34,6 @@ let hydration: Promise | null = null let started = false let failedLoads = 0 let retryTimer: ReturnType | null = null - /** The appends this load could not have read: made after it began reading, or never written. */ function withAppends( scopeKey: string, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index 60dddf06d2f7..6a4713f70d4d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -183,13 +183,37 @@ export function updateNativeChatComposerDraft( } } +export function isNativeChatComposerDraftHydrated(): boolean { + return load.hydrated +} + +/** Both scope changes are visible together; storage follows the same save path as an edit. */ +export function moveNativeChatComposerDraft(from: string, to: string): void { + if (from === to || !load.hydrated || !isEmptyDraft(readNativeChatComposerDraft(to))) { + return + } + const source = records.get(from) + if (!source) { + return + } + records.set(to, { ...source, savedAt: nextSavedAt() }) + records.delete(from) + if (unverifiedScopes.delete(from)) { + unverifiedScopes.add(to) + } + persistNativeChatComposerDraft(to, 'immediate') + persistNativeChatComposerDraft(from, 'immediate') + notifyScope(to) + notifyScope(from) +} + function withAddition( draft: T, addition: NativeChatComposerDraftAddition, options: { once?: boolean } = {} ): T { const next = withNativeChatComposerDraftAddition(draft, addition, options) - return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) } + return { ...draft, ...next } } /** @@ -206,13 +230,13 @@ export function appendToNativeChatComposerDraft( ): boolean { const before = records.get(scopeKey) const current = readNativeChatComposerDraft(scopeKey) - const { text, images } = withAddition(current, addition) + const { text, images, document } = withAddition(current, addition) updateNativeChatComposerDraft( scopeKey, { text, images, - ...(text === current.text ? {} : { document: undefined }), + document, ...(owner ? { owner } : {}) }, 'immediate', diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 8d086d5ac35f..f4f7eee53bd8 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -1,5 +1,6 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { JSONContent } from '@tiptap/react' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' // The composer's in-progress draft text and its editor document, keyed by the same stable pane // scope as image attachments. The composer unmounts when the pane toggles back to the hosted // terminal, so without this the typed-but-unsent draft would be lost on every TUI/GUI round-trip. @@ -40,13 +41,17 @@ const appendListeners = new Map /** Puts text back after whatever is typed, and tells a mounted composer to show it. True once it * is durable, so the copy it came from may go. */ -export function appendNativeChatDraftCache(scopeKey: string, text: string): boolean { +export function appendNativeChatDraftCache( + scopeKey: string, + text: string, + owner?: NativeChatComposerDraftOwner +): boolean { if (text === '') { return true } const previous = readNativeChatDraftCache(scopeKey) // Durable now: the copy it came from (a send handed back, a queued card) goes right after this. - const durable = appendToNativeChatComposerDraft(scopeKey, { text }) + const durable = appendToNativeChatComposerDraft(scopeKey, { text }, owner) appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous)) return durable } diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts index 3dccd946f89b..d689b9881f67 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts @@ -7,6 +7,7 @@ import { nativeChatPendingAttachmentSnapshot, revealNativeChatPendingAttachment, settleNativeChatPendingAttachment, + settleNativeChatPendingAttachmentReferences, subscribeToNativeChatPendingAttachments, takeNativeChatPendingAttachment } from './native-chat-pending-attachment-cache' @@ -89,6 +90,25 @@ describe('the pane pending attachment cache', () => { expect(readNativeChatComposerDraft(conversation).images).toEqual([]) }) + it('keeps reference settlement owned by the workspace after its chat closes', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + let open = true + setNativeChatComposerDraftOwnerResolver((scopeKey) => + open && scopeKey === conversation ? OWNER : undefined + ) + addPending(conversation, 'file') + open = false + + settleNativeChatPendingAttachmentReferences(conversation, [ + { id: 'file', path: '/store/a.pdf' } + ]) + + expect(readNativeChatComposerDraft(conversation).text).toBe('@/store/a.pdf') + expect(nativeChatPendingAttachmentSnapshot(conversation)).toEqual([]) + deleteNativeChatComposerDraftsOwnedBy(OWNER) + expect(readNativeChatComposerDraft(conversation).text).toBe('') + }) + it('drops the chips begun in a removed workspace on that host only', () => { const resolved = new Map([ ['tab-1:leaf', OWNER], diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index 3d4e477d0cee..1dd2a4dd96a1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -10,6 +10,8 @@ import { useCallback, useSyncExternalStore } from 'react' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' import { appendToNativeChatComposerDraft, nativeChatDraftScopeTabId, @@ -19,6 +21,8 @@ import { const EMPTY: readonly NativeChatComposerImageAttachment[] = Object.freeze([]) const pendingCache = new Map() +// An upload's original scope survives only until that upload settles or is removed. +const origins = new Map() // Why: a chip can outlive its tab, which is what names the draft's owner, so it is named at once. const pendingOwners = new Map() const listeners = new Map void>>() @@ -31,6 +35,11 @@ export function nativeChatPendingAttachmentSnapshot( } function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { + for (const chip of nativeChatPendingAttachmentSnapshot(scopeKey)) { + if (!next.includes(chip)) { + origins.delete(chip) + } + } if (next.length === 0) { pendingCache.delete(scopeKey) pendingOwners.delete(scopeKey) @@ -76,14 +85,52 @@ export function addNativeChatPendingAttachment( if (owner) { pendingOwners.set(scopeKey, owner) } + origins.set(pending, scopeKey) writePending(scopeKey, [...nativeChatPendingAttachmentSnapshot(scopeKey), pending]) } +/** Current ownership of an operation, including callbacks held by its earlier composer. */ +export function nativeChatPendingAttachmentScope(scopeKey: string, id: string): string | undefined { + if (nativeChatPendingAttachmentSnapshot(scopeKey).some((chip) => chip.id === id)) { + return scopeKey + } + for (const [scope, chips] of pendingCache) { + if (chips.some((chip) => chip.id === id && origins.get(chip) === scopeKey)) { + return scope + } + } + return undefined +} + +/** Pending work follows the draft; its original callbacks still address the same operation. */ +export function moveNativeChatPendingAttachments(from: string, to: string): void { + if (from === to) { + return + } + const source = nativeChatPendingAttachmentSnapshot(from) + if (source.length === 0) { + return + } + const owner = pendingOwners.get(from) + const sourceOrigins = source.map((chip) => origins.get(chip) ?? from) + writePending(from, []) + if (owner && !pendingOwners.has(to)) { + pendingOwners.set(to, owner) + } + source.forEach((chip, index) => origins.set(chip, sourceOrigins[index])) + writePending(to, [...nativeChatPendingAttachmentSnapshot(to), ...source]) +} + /** Removes a pending chip and returns it; undefined when the user already removed it. */ export function takeNativeChatPendingAttachment( scopeKey: string, id: string ): NativeChatComposerImageAttachment | undefined { + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + return undefined + } + scopeKey = scope const current = nativeChatPendingAttachmentSnapshot(scopeKey) const taken = current.find((attachment) => attachment.id === id) if (taken) { @@ -103,6 +150,11 @@ export function settleNativeChatPendingAttachment( path: string, connectionId?: string | null ): boolean { + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + return false + } + scopeKey = scope const owner = pendingOwners.get(scopeKey) if (!takeNativeChatPendingAttachment(scopeKey, id)) { return false @@ -115,15 +167,54 @@ export function settleNativeChatPendingAttachment( return true } +/** Settles references against their operation owners before releasing their pending chips. */ +export function settleNativeChatPendingAttachmentReferences( + scopeKey: string, + references: { id: string; path: string }[], + insertAtCaret?: (paths: string[]) => void +): void { + const batches = new Map< + string, + { paths: string[]; owner: NativeChatComposerDraftOwner | undefined } + >() + for (const { id, path } of references) { + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + continue + } + const batch = batches.get(scope) ?? { paths: [], owner: pendingOwners.get(scope) } + if (takeNativeChatPendingAttachment(scope, id)) { + batch.paths.push(path) + batches.set(scope, batch) + } + } + for (const [scope, { paths, owner }] of batches) { + if (scope === scopeKey && insertAtCaret) { + insertAtCaret(paths) + } else { + appendNativeChatDraftCache(scope, paths.map(formatNativeChatFileReference).join(' '), owner) + } + } +} + /** Shows a pending chip that was held out of sight, such as while a server was asked first. */ export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + return + } + scopeKey = scope const current = nativeChatPendingAttachmentSnapshot(scopeKey) if (current.some((attachment) => attachment.id === id && attachment.hidden)) { writePending( scopeKey, current.map((attachment) => { const { hidden: _hidden, ...shown } = attachment - return attachment.id === id ? shown : attachment + if (attachment.id === id) { + origins.set(shown, origins.get(attachment) ?? scopeKey) + return shown + } + return attachment }) ) } @@ -166,4 +257,5 @@ export function dropNativeChatPendingAttachmentsOwnedBy(owner: NativeChatCompose export function clearNativeChatPendingAttachmentsForTests(): void { pendingCache.clear() pendingOwners.clear() + origins.clear() } diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts new file mode 100644 index 000000000000..24f3056c1ee4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest' +import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' + +it.each(['spaces', 'hard breaks', 'empty paragraphs'])( + 'trims trailing %s while keeping picked skills and earlier paragraphs', + (trailing) => { + const first = { type: 'paragraph', content: [{ type: 'text', text: 'first' }] } + const picked = { type: 'nativeChatSkill', attrs: { token: '$review' } } + const draft: NativeChatComposerDraft = { + text: `first\n$review${trailing === 'spaces' ? ' ' : '\n\n'}`, + images: [], + document: { + type: 'doc', + content: [ + first, + { + type: 'paragraph', + content: [ + picked, + ...(trailing === 'spaces' + ? [{ type: 'text', text: ' ' }] + : trailing === 'hard breaks' + ? [{ type: 'hardBreak' }, { type: 'hardBreak' }] + : []) + ] + }, + ...(trailing === 'empty paragraphs' + ? [ + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [] } + ] + : []) + ] + } + } + const result = withNativeChatComposerDraftAddition(draft, { text: 'go' }) + expect(result.text).toBe('first\n$review\n\ngo') + expect(result.document?.content).toEqual([ + first, + { type: 'paragraph', content: [picked] }, + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: 'go' }] } + ]) + } +) + +it('replaces an all-whitespace document with literal returned text', () => { + const result = withNativeChatComposerDraftAddition( + { + text: ' \n', + images: [], + document: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: ' ' }] }, + { type: 'paragraph', content: [] } + ] + } + }, + { text: '$review' } + ) + expect(result.document?.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: '$review' }] } + ]) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts new file mode 100644 index 000000000000..6151a994c95d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts @@ -0,0 +1,59 @@ +import type { JSONContent } from '@tiptap/react' + +export function promptTextContent(text: string): JSONContent { + return { + type: 'doc', + content: text.split('\n').map((line) => ({ + type: 'paragraph', + content: line ? [{ type: 'text', text: line }] : [] + })) + } +} + +/** Applies an appended text change without replacing existing picked nodes. */ +export function appendPromptDocumentText( + document: JSONContent | undefined, + before: string, + after: string +): JSONContent | undefined { + if (!document || before === after) { + return document + } + const kept = before.trimEnd() + if (kept === '') { + return promptTextContent(after) + } + const blocks = [...(document.content ?? [])] + while (blocks.length > 0) { + const block = blocks.pop() + const content = [...(block?.content ?? [])] + while (content.length > 0) { + const node = content.at(-1) + if (node?.type === 'hardBreak') { + content.pop() + } else if (node?.type === 'text') { + const text = node.text?.trimEnd() ?? '' + if (text === '') { + content.pop() + } else { + content[content.length - 1] = { ...node, text } + break + } + } else { + break + } + } + if (content.length > 0 && block) { + const [first, ...rest] = promptTextContent(after.slice(kept.length)).content ?? [] + return { + ...document, + content: [ + ...blocks, + { ...block, content: [...content, ...(first?.content ?? [])] }, + ...rest + ] + } + } + } + return promptTextContent(after) +} diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts index 7ed5fd8c1306..96bba1a75fda 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts @@ -1,6 +1,7 @@ import { NativeChatSkillPill } from './NativeChatSkillPill' -import { ReactNodeViewRenderer, Node, type JSONContent } from '@tiptap/react' +import { ReactNodeViewRenderer, Node } from '@tiptap/react' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +export { promptTextContent } from './native-chat-prompt-document-text' export const NativeChatSkill = Node.create({ name: 'nativeChatSkill', @@ -24,16 +25,6 @@ export const NativeChatSkill = Node.create({ ] }) -export function promptTextContent(text: string): JSONContent { - return { - type: 'doc', - content: text.split('\n').map((line) => ({ - type: 'paragraph', - content: line ? [{ type: 'text', text: line }] : [] - })) - } -} - /** Each boundary maps a plain-text caret to a document position, including atomic skills. */ export function promptTextMap(doc: ProseMirrorNode): { text: string; positions: number[] } { let text = '' diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 0d655d8de8a3..e51be382d945 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -18,7 +18,7 @@ export type NativeChatPendingAttachmentChips = { drop: (id: string) => boolean /** Adds stored files as `@path` references to the scope's draft, which keeps them through an * input-method composition or a remount. */ - attachReferences: (paths: string[]) => void + attachReferences: (references: { id: string; path: string }[]) => void } /** @@ -86,7 +86,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { return } const notAttached: { name: string; reason: string }[] = [] - const references: string[] = [] + const references: { id: string; path: string }[] = [] for (const { path, chipId } of pending) { const storedPath = stored.get(path) if (!storedPath) { @@ -100,9 +100,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { continue } // Other files become `@path` references, as every attach does, unless their chip was removed. - if (args.chips.drop(chipId)) { - references.push(storedPath) - } + references.push({ id: chipId, path: storedPath }) } // Inserting at the caret clears the notice, so what failed is said after. if (references.length > 0) { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx new file mode 100644 index 000000000000..5181b8a3546d --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { cleanup, renderHook } from '@testing-library/react' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + readNativeChatComposerDraft, + updateNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + revealNativeChatPendingAttachment, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(() => { + cleanup() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) + +it.each(['local', 'paired'] as const)( + 'keeps a pending-only %s paste whose upload settles after replacement unmounts the composer', + async (host) => { + const view = renderHook(() => + useNativeChatPasteLifetime({ + targetKey: 'a', + attachmentScopeKey: scope('a'), + beginPendingImageAttachment: () => 'upload', + resolvePendingImageAttachment: (id, path, connection) => { + settleNativeChatPendingAttachment(scope('a'), id, path, connection) + }, + dropPendingImageAttachment: (id) => { + takeNativeChatPendingAttachment(scope('a'), id) + } + }) + ) + const lifetime = view.result.current + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + lifetime.track( + 'upload', + '', + host === 'paired' + ? { kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 } + : { kind: 'local' } + ) + await moveStructuredAgentSessionDraft('a', 'b') + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toMatchObject([ + { id: 'upload', pending: true } + ]) + view.unmount() + expect( + lifetime.keepStoreUploadAfterUnmount('upload', { + status: 'saved', + tempPath: '/stored/image.png' + }) + ).toBe(true) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([ + { id: 'upload', path: '/stored/image.png' } + ]) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toEqual([]) + } +) + +it('keeps original upload callbacks through repeated clears and reveal', async () => { + addNativeChatPendingAttachment(scope('a'), { + id: 'upload', + path: '', + pending: true, + hidden: true + }) + await moveStructuredAgentSessionDraft('a', 'b') + await moveStructuredAgentSessionDraft('b', 'c') + revealNativeChatPendingAttachment(scope('a'), 'upload') + expect(nativeChatPendingAttachmentSnapshot(scope('c'))).toEqual([ + { id: 'upload', path: '', pending: true } + ]) + expect(settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1')).toBe( + true + ) + expect(readNativeChatComposerDraft(scope('c')).images).toEqual([ + { id: 'upload', path: '/ssh/image.png', connectionId: 'ssh-1' } + ]) +}) + +it('does not attach a moved upload the user already removed', async () => { + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + await moveStructuredAgentSessionDraft('a', 'b') + takeNativeChatPendingAttachment(scope('b'), 'upload') + expect(settleNativeChatPendingAttachment(scope('a'), 'upload', '/stored/image.png')).toBe(false) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) +}) + +it('keeps source ownership when the replacement already holds a pending upload', () => { + updateNativeChatComposerDraft(scope('a'), { text: 'source history draft' }, 'immediate') + addNativeChatPendingAttachment(scope('a'), { id: 'source-upload', path: '', pending: true }) + addNativeChatPendingAttachment(scope('b'), { id: 'target-upload', path: '', pending: true }) + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('source history draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toMatchObject([{ id: 'source-upload' }]) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toMatchObject([{ id: 'target-upload' }]) + settleNativeChatPendingAttachment(scope('a'), 'source-upload', '/ssh/source.png', 'ssh-1') + settleNativeChatPendingAttachment(scope('b'), 'target-upload', '/ssh/target.png', 'ssh-1') + expect(readNativeChatComposerDraft(scope('a')).images[0]?.path).toBe('/ssh/source.png') + expect(readNativeChatComposerDraft(scope('b')).images[0]?.path).toBe('/ssh/target.png') +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx new file mode 100644 index 000000000000..8e29e5c0182b --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment happy-dom +import { createRef, useRef } from 'react' +import { act, cleanup, render, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { attachNativeChatSessionAttachmentPaths } from './native-chat-session-attachment-drop' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot +} from './native-chat-pending-attachment-cache' + +// Only host replies are controlled; pending-chip ownership and upload completion are production code. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn(async () => ({ + runtimeId: 'paired-host', + capabilities: ['agent-session.attachments.v1'] + })) +})) +let previousApi: typeof window.api +beforeEach(async () => { + previousApi = window.api + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(async () => { + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() + window.api = previousApi +}) +async function uploadFixture( + extension: string, + { removed = false, withSkill = false, move = true } = {} +) { + const path = `/srv/agent-session-attachments/u1/report.${extension}` + const sourcePath = `/local/report.${extension}` + let finish = () => {} + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { + fs: { + uploadPathsToAgentSessionAttachments: () => + new Promise((resolve) => { + finish = () => resolve({ uploaded: [{ sourcePath, path }], skipped: [], failed: [] }) + }) + } + } + }) + updateNativeChatComposerDraft( + scope('a'), + { + text: withSkill ? '$review-long' : 'next question', + ...(withSkill + ? { + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } + } + : {}) + }, + 'immediate' + ) + const view = renderHook(() => { + const textareaRef = useRef(null) + return useNativeChatComposerAttachments({ + attachmentScopeKey: scope('a'), + allowWithoutTarget: true, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret: () => {}, + setDraft: (updater) => + updateNativeChatComposerDraft( + scope('a'), + { text: updater(readNativeChatComposerDraft(scope('a')).text) }, + 'immediate' + ), + setNotice: () => {} + }) + }) + let uploaded = Promise.resolve() + act(() => { + uploaded = attachNativeChatSessionAttachmentPaths({ + paths: [sourcePath], + owner: { + kind: 'runtime-session', + environmentId: 'paired', + sessionId: 'a', + pairingRevision: 1 + }, + chips: view.result.current.pendingChips, + isAbandoned: () => false, + ownerStillCurrent: () => true, + setNotice: () => {} + }) + }) + await Promise.resolve() + await Promise.resolve() + const chip = nativeChatPendingAttachmentSnapshot(scope('a'))[0] + if (!chip) { + throw new Error('Expected pending upload chip') + } + expect(chip.pendingName).toBe(`report.${extension}`) + const targetScope = scope(move ? 'b' : 'a') + if (move) { + act(() => moveStructuredAgentSessionDraft('a', 'b')) + } + expect(nativeChatPendingAttachmentSnapshot(targetScope)).toMatchObject([ + { id: chip.id, pendingName: `report.${extension}` } + ]) + if (removed) { + act(() => view.result.current.removeImageAttachment(chip.id)) + } + view.unmount() + finish() + await uploaded + return { + path, + targetScope, + source: readNativeChatComposerDraft(scope('a')), + target: readNativeChatComposerDraft(targetScope), + pending: nativeChatPendingAttachmentSnapshot(targetScope) + } +} +it('a moved paired PDF upload settles its reference into the replacement draft', async () => { + const result = await uploadFixture('pdf') + expect(result.pending).toEqual([]) + expect(result.target.text).toContain(`@${result.path}`) + expect(result.source.text).toBe('') +}) +it('control: a moved paired image upload settles into the replacement draft', async () => { + const result = await uploadFixture('png') + expect(result.pending).toEqual([]) + expect(result.target.text).toBe('next question') + expect(result.target.images).toHaveLength(1) + expect(result.target.images[0].path).toBe(result.path) + expect(result.source.text).toBe('') +}) + +it('does not attach a moved file reference the user removed while uploading', async () => { + const result = await uploadFixture('pdf', { removed: true }) + expect(result.pending).toEqual([]) + expect(result.target.text).toBe('next question') + expect(result.source.text).toBe('') +}) + +it.each([ + { extension: 'pdf', move: true }, + { extension: 'png', move: true }, + { extension: 'pdf', move: false }, + { extension: 'png', move: false } +])( + 'preserves picked skills after $extension upload with move=$move', + async ({ extension, move }) => { + const result = await uploadFixture(extension, { withSkill: true, move }) + expect(result.pending).toEqual([]) + if (move) { + expect(result.source.text).toBe('') + } + const inputRef = createRef() + const view = render( + {}} + onSelect={() => {}} + /> + ) + expect(inputRef.current?.value).toContain('$review-long') + if (extension === 'pdf') { + expect(inputRef.current?.value).toContain(`@${result.path}`) + } else { + expect(result.target.images[0]?.path).toBe(result.path) + } + await vi.waitFor(() => { + expect(view.container.querySelectorAll('[data-native-chat-skill]')).toHaveLength(1) + expect(view.container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review') + }) + } +) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts new file mode 100644 index 000000000000..3bee83b2ff9d --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -0,0 +1,266 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' +import { + moveStructuredAgentSessionDraft, + structuredAgentSessionConversationMoves +} from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + isNativeChatComposerDraftUnverified, + readNativeChatComposerDraft, + subscribeToNativeChatComposerDraft, + nativeChatComposerDraftWritesSettled, + flushNativeChatComposerDrafts, + isNativeChatComposerDraftUnsaved, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' + +const scope = structuredAgentSessionDraftScopeKey +const SSH_IMAGE = { id: 'i1', path: '/remote/shot.png', connectionId: 'ssh-1' } + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) + +afterEach(() => { + clearNativeChatComposerDraftsForTests() +}) + +describe('moving a cleared conversation draft', () => { + it('moves text, SSH images and skill chips whole into the new conversation', async () => { + const document = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before ' }, + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: ' after' } + ] + } + ] + } + updateNativeChatComposerDraft( + scope('a'), + { text: 'before $review after', document, images: [SSH_IMAGE] }, + 'immediate' + ) + + await moveStructuredAgentSessionDraft('a', 'b') + + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: 'before $review after', + document, + images: [SSH_IMAGE] + }) + expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ text: '', images: [] }) + }) + + it.each(['text', 'image', 'skill'] as const)( + 'keeps both drafts intact when the destination has %s input', + (kind) => { + const source = { + text: '$review-long', + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'nativeChatSkill', attrs: { token: '$review-long' } }] + } + ] + }, + images: [SSH_IMAGE] + } + const destination = + kind === 'image' + ? { text: '', images: [SSH_IMAGE] } + : kind === 'text' + ? { text: '$review-long', images: [] } + : { + text: '$review-long', + images: [], + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } + } + updateNativeChatComposerDraft(scope('a'), source, 'immediate') + updateNativeChatComposerDraft(scope('b'), destination, 'immediate') + const originalSource = readNativeChatComposerDraft(scope('a')) + const originalTarget = readNativeChatComposerDraft(scope('b')) + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('a'))).toBe(originalSource) + expect(readNativeChatComposerDraft(scope('b'))).toBe(originalTarget) + } + ) + + it('moves nothing from an empty draft', async () => { + updateNativeChatComposerDraft(scope('b'), { text: 'mine' }, 'immediate') + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('mine') + }) + + it('keeps the moved draft in memory through a refused save and repairs it on a normal flush', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + updateNativeChatComposerDraft(scope('a'), { text: 'still owed' }, 'immediate') + await nativeChatComposerDraftWritesSettled() + storage.refuseWrites = true + moveStructuredAgentSessionDraft('a', 'b') + await nativeChatComposerDraftWritesSettled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('still owed') + expect(isNativeChatComposerDraftUnsaved(scope('b'))).toBe(true) + storage.refuseWrites = false + flushNativeChatComposerDrafts() + await nativeChatComposerDraftWritesSettled() + expect(storage.drafts.get(scope('b'))?.text).toBe('still owed') + expect(storage.drafts.has(scope('a'))).toBe(false) + expect(isNativeChatComposerDraftUnsaved(scope('b'))).toBe(false) + }) + + it('publishes both scope changes before any subscriber observes the move', () => { + updateNativeChatComposerDraft( + scope('a'), + { text: 'question', images: [SSH_IMAGE] }, + 'immediate' + ) + const seen: { source: string; target: string }[] = [] + const observe = (): void => { + seen.push({ + source: readNativeChatComposerDraft(scope('a')).text, + target: readNativeChatComposerDraft(scope('b')).text + }) + } + const unsubscribeSource = subscribeToNativeChatComposerDraft(scope('a'), observe) + const unsubscribeTarget = subscribeToNativeChatComposerDraft(scope('b'), observe) + moveStructuredAgentSessionDraft('a', 'b') + unsubscribeSource() + unsubscribeTarget() + expect(seen).toEqual([ + { source: '', target: 'question' }, + { source: '', target: 'question' } + ]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([SSH_IMAGE]) + }) + + it('saves the whole moved draft through the store and restores it after restart', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + const document = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'nativeChatSkill', attrs: { token: '/review' } }] } + ] + } + updateNativeChatComposerDraft( + scope('a'), + { text: '/review', document, images: [SSH_IMAGE] }, + 'immediate' + ) + + await moveStructuredAgentSessionDraft('a', 'b') + expect(storage.drafts.get(scope('b'))).toMatchObject({ document, images: [SSH_IMAGE] }) + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: '/review', + document, + images: [SSH_IMAGE] + }) + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + }) + + it('keeps unavailable images intact in the whole transferred record', () => { + const image = { id: 'missing', path: '', unavailableName: 'notes.png' } + updateNativeChatComposerDraft(scope('a'), { text: 'moved', images: [image] }, 'immediate') + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: 'moved', + images: [image] + }) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) + }) + + it('keeps new history input entered after the synchronous move', async () => { + updateNativeChatComposerDraft(scope('a'), { text: 'moved' }, 'immediate') + const moving = moveStructuredAgentSessionDraft('a', 'b') + updateNativeChatComposerDraft(scope('a'), { text: 'new old-chat draft' }, 'immediate') + await moving + expect(readNativeChatComposerDraft(scope('a')).text).toBe('new old-chat draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('moved') + }) + + it('keeps a later history write even when it recreates the moved text', async () => { + updateNativeChatComposerDraft(scope('a'), { text: 'same question' }, 'immediate') + const moving = moveStructuredAgentSessionDraft('a', 'b') + updateNativeChatComposerDraft(scope('a'), { text: '' }, 'immediate') + updateNativeChatComposerDraft(scope('a'), { text: 'same question' }, 'immediate') + await moving + expect(readNativeChatComposerDraft(scope('a')).text).toBe('same question') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('same question') + }) + + it('moves a restored source only after the load has finished', async () => { + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'saved before quit', images: [SSH_IMAGE], savedAt: 1 }) + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('saved before quit') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + expect(isNativeChatComposerDraftUnverified(scope('b'))).toBe(true) + }) +}) + +function chat(id: string, entityId: string, contentType: Tab['contentType'] = 'agent-session') { + return { + id, + entityId, + contentType, + groupId: 'g', + worktreeId: 'wt', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } satisfies Tab +} + +describe('which chats moved conversation', () => { + it('finds a tab that now shows another conversation, and nothing else', () => { + const previous = { + wt: [chat('t1', 'a'), chat('t2', 'c'), chat('term', 'pty-1', 'terminal')], + other: [chat('t9', 'z')] + } + const next = { + wt: [chat('t1', 'b'), chat('t2', 'c'), chat('t3', 'a'), chat('term', 'pty-2', 'terminal')], + other: previous.other, + added: [chat('t4', 'y')] + } + expect(structuredAgentSessionConversationMoves(previous, next)).toEqual([ + { from: 'a', to: 'b' } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts new file mode 100644 index 000000000000..b6daa7911c77 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -0,0 +1,56 @@ +// Unsent input follows its chat tab when /clear changes that tab's conversation. + +import { + isNativeChatComposerDraftHydrated, + moveNativeChatComposerDraft, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey +} from './native-chat-composer-draft-store' +import { + moveNativeChatPendingAttachments, + nativeChatPendingAttachmentSnapshot +} from './native-chat-pending-attachment-cache' +import type { Tab } from '../../../../shared/tab-types' + +/** Moves loaded input into an empty replacement; conflicting input stays in history. */ +export function moveStructuredAgentSessionDraft(fromSessionId: string, toSessionId: string): void { + if (fromSessionId === toSessionId || !isNativeChatComposerDraftHydrated()) { + return + } + const from = structuredAgentSessionDraftScopeKey(fromSessionId) + const to = structuredAgentSessionDraftScopeKey(toSessionId) + const target = readNativeChatComposerDraft(to) + if ( + target.text !== '' || + target.images.length > 0 || + nativeChatPendingAttachmentSnapshot(to).length > 0 + ) { + return + } + moveNativeChatPendingAttachments(from, to) + moveNativeChatComposerDraft(from, to) +} + +/** Each chat whose tab now shows another conversation, whichever client ran the clear. */ +export function structuredAgentSessionConversationMoves( + previous: Readonly>, + next: Readonly> +): { from: string; to: string }[] { + const moves: { from: string; to: string }[] = [] + for (const [worktreeId, tabs] of Object.entries(next)) { + const before = previous[worktreeId] + if (!before || before === tabs) { + continue + } + const shown = new Map( + before.flatMap((tab) => (tab.contentType === 'agent-session' ? [[tab.id, tab.entityId]] : [])) + ) + for (const tab of tabs) { + const was = tab.contentType === 'agent-session' ? shown.get(tab.id) : undefined + if (was !== undefined && was !== tab.entityId) { + moves.push({ from: was, to: tab.entityId }) + } + } + } + return moves +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx new file mode 100644 index 000000000000..abf962089f7a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(async () => { + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) +const owners: Record = { + local: { kind: 'local' }, + ssh: { + kind: 'ssh', + connectionId: 'ssh-1', + worktreePath: '/remote/worktree', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 1 + }, + paired: { kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 } +} +async function fixture(host: string, reason: string, removed = false, status = 'saved') { + let releaseLoad = () => {} + let loading = Promise.resolve() + if (reason === 'preload') { + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + const held = new Promise((resolve) => { + releaseLoad = resolve + }) + setNativeChatComposerDraftStorageForTests({ + ...storage, + loadAll: async () => { + await held + return storage.loadAll() + } + }) + loading = hydrateNativeChatComposerDrafts() + } + updateNativeChatComposerDraft(scope('a'), { text: 'source history text' }, 'immediate') + if (reason === 'target-text') { + updateNativeChatComposerDraft(scope('b'), { text: 'target input' }, 'immediate') + } + if (reason === 'target-pending') { + addNativeChatPendingAttachment(scope('b'), { id: 'target', path: '', pending: true }) + } + const view = renderHook(() => + useNativeChatPasteLifetime({ + targetKey: 'a', + attachmentScopeKey: scope('a'), + beginPendingImageAttachment: () => 'source', + resolvePendingImageAttachment: (id, path, connectionId) => { + settleNativeChatPendingAttachment(scope('a'), id, path, connectionId) + }, + dropPendingImageAttachment: (id) => { + takeNativeChatPendingAttachment(scope('a'), id) + } + }) + ) + const lifetime = view.result.current + addNativeChatPendingAttachment(scope('a'), { id: 'source', path: '', pending: true }) + const owner = owners[host] + if (!owner) { + throw new Error('Expected upload owner') + } + lifetime.track('source', '', owner) + moveStructuredAgentSessionDraft('a', 'b') + if (removed) { + takeNativeChatPendingAttachment(scope('a'), 'source') + } + const before = nativeChatPendingAttachmentSnapshot(scope('a')).map((chip) => chip.id) + view.unmount() + const after = nativeChatPendingAttachmentSnapshot(scope('a')).map((chip) => chip.id) + const kept = lifetime.keepStoreUploadAfterUnmount('source', { + status, + tempPath: '/stored/image.png' + }) + releaseLoad() + await loading + return { + before, + after, + kept, + source: readNativeChatComposerDraft(scope('a')), + target: readNativeChatComposerDraft(scope('b')) + } +} +it.each( + ['preload', 'target-text', 'target-pending'].flatMap((reason) => + ['local', 'ssh', 'paired'].map((host) => ({ reason, host })) + ) +)('skipped clear preserves $host pending upload in source ($reason)', async ({ host, reason }) => { + const result = await fixture(host, reason) + expect(result.before).toEqual(['source']) + expect(result.after).toEqual(['source']) + expect(result.kept).toBe(true) + expect(result.source.text).toBe('source history text') + expect(result.source.images).toEqual([ + { + id: 'source', + path: '/stored/image.png', + ...(host === 'ssh' ? { connectionId: 'ssh-1' } : {}) + } + ]) + expect(result.target.images).toEqual([]) +}) +it.each(['local', 'ssh'])( + 'control: moved $s upload survives teardown and settles in destination', + async (host) => { + const result = await fixture(host, 'empty') + expect(result.source.text).toBe('') + expect(result.source.images).toEqual([]) + expect(result.target.images).toEqual([ + { + id: 'source', + path: '/stored/image.png', + ...(host === 'ssh' ? { connectionId: 'ssh-1' } : {}) + } + ]) + } +) + +it.each(['local', 'ssh'])('does not settle a removed %s upload after teardown', async (host) => { + const result = await fixture(host, 'target-text', true) + expect(result.kept).toBe(false) + expect(result.source.images).toEqual([]) + expect(result.target.images).toEqual([]) +}) +it.each(['local', 'ssh'])('drops a failed %s upload after teardown', async (host) => { + const result = await fixture(host, 'target-text', false, 'error') + expect(result.kept).toBe(true) + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(result.source.images).toEqual([]) + expect(result.target.images).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 5208dfe6e8ac..511008f478f5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -477,13 +477,14 @@ describe('useNativeChatComposerAttachments', () => { it('keeps an upload that finishes while the composer is unmounted, for when it returns', async () => { const probe = await renderProbe('pty-gone') const chips = probe.latest().pendingChips - const begun: { image?: string | null; removed?: string | null } = {} + const begun: { image?: string | null; removed?: string | null; reference?: string | null } = {} act(() => { begun.image = chips.begin(undefined, 'shot.png') begun.removed = chips.begin(undefined, 'old.png') + begun.reference = chips.begin(undefined, 'notes.pdf') }) - const { image, removed } = begun - if (!image || !removed) { + const { image, removed, reference } = begun + if (!image || !removed || !reference) { throw new Error('expected pending chips') } act(() => probe.latest().removeImageAttachment(removed)) @@ -492,7 +493,7 @@ describe('useNativeChatComposerAttachments', () => { chips.resolve(image, '/srv/agent-session-attachments/u1/shot.png') chips.resolve(removed, '/srv/agent-session-attachments/u2/old.png') - chips.attachReferences(['/srv/agent-session-attachments/u3/notes.pdf']) + chips.attachReferences([{ id: reference, path: '/srv/agent-session-attachments/u3/notes.pdf' }]) expect(readNativeChatAttachmentCache('pty-gone')).toEqual([ { id: image, path: '/srv/agent-session-attachments/u1/shot.png' } @@ -533,9 +534,14 @@ describe('useNativeChatComposerAttachments', () => { it('inserts a stored file at the caret while the composer is showing and not composing', async () => { const probe = await renderProbe('pty-caret', true) - act(() => - probe.latest().pendingChips.attachReferences(['/srv/agent-session-attachments/u6/a.pdf']) - ) + act(() => { + const chips = probe.latest().pendingChips + const id = chips.begin(undefined, 'a.pdf') + if (!id) { + throw new Error('expected pending chip') + } + chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u6/a.pdf' }]) + }) expect(probe.draft()).toBe('@/srv/agent-session-attachments/u6/a.pdf ') expect(readNativeChatDraftCache('pty-caret')).toBe('') act(() => probe.root.unmount()) @@ -555,8 +561,7 @@ describe('useNativeChatComposerAttachments', () => { const id: string = chipId // The upload finishes mid-composition: the reference waits for the composition to settle. act(() => { - expect(chips.drop(id)).toBe(true) - chips.attachReferences(['/srv/agent-session-attachments/u4/notes.pdf']) + chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u4/notes.pdf' }]) }) // A prompt card takes the composer's place before the composition settles. act(() => probe.root.unmount()) 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 b360492493ff..c8b9e84a872a 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 @@ -28,15 +28,13 @@ import { clearNativeChatPendingAttachments, revealNativeChatPendingAttachment, settleNativeChatPendingAttachment, + settleNativeChatPendingAttachmentReferences, takeNativeChatPendingAttachment, useNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' -import { - appendNativeChatAttachmentCache, - appendNativeChatDraftCache -} from './native-chat-draft-cache' +import { appendNativeChatAttachmentCache } from './native-chat-draft-cache' import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews' -import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' +import { createUuidV4 } from '../../../../shared/uuid-v4' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -117,7 +115,6 @@ export function useNativeChatComposerAttachments({ ], [pending, previews, restoring, settled] ) - const imageAttachmentCounter = useRef(0) const mountedRef = useRef(true) useEffect(() => { mountedRef.current = true @@ -126,10 +123,7 @@ export function useNativeChatComposerAttachments({ } }, []) - const nextAttachmentId = useCallback((): string => { - imageAttachmentCounter.current += 1 - return `${Date.now()}-${imageAttachmentCounter.current}` - }, []) + const nextAttachmentId = useCallback(() => createUuidV4(), []) // Client-local paths cannot cross into a runtime target; workspace-owned // paths may only bypass this after the internal drop ownership gate. @@ -245,14 +239,13 @@ export function useNativeChatComposerAttachments({ drop: dropPendingImageAttachment, // At the caret, as every attach does; mid-composition or once the composer is gone, into the // scope's draft, which keeps it until the composition settles or the composer comes back. - attachReferences: (paths) => { - if (mountedRef.current && !isComposing()) { - attachResolvedPaths(paths, null) - return - } - appendNativeChatDraftCache( + attachReferences: (references) => { + settleNativeChatPendingAttachmentReferences( attachmentScopeKey, - paths.map(formatNativeChatFileReference).join(' ') + references, + mountedRef.current && !isComposing() + ? (paths) => attachResolvedPaths(paths, null) + : undefined ) } }), diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 1701c069b342..448a7044548b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -24,9 +24,9 @@ import { export type UseNativeChatComposerPasteArgs = { targetKey?: string + attachmentScopeKey?: string agent: AgentType - /** Live composer-disabled state (no pty / presence-lock); read at await-resume - * via a ref so a flip mid-paste doesn't write into a guarded composer. */ + /** Re-read after awaits so a mid-paste disable guards the composer. */ disabled: boolean caret: number /** Resolved at paste time: SSH panes must save the clipboard image on the @@ -49,6 +49,7 @@ export type UseNativeChatComposerPasteArgs = { export function useNativeChatComposerPaste({ targetKey, + attachmentScopeKey, disabled, caret, resolveAttachmentOwner, @@ -71,6 +72,7 @@ export function useNativeChatComposerPaste({ const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } = useNativeChatPasteLifetime({ targetKey, + attachmentScopeKey, beginPendingImageAttachment, resolvePendingImageAttachment, revealPendingImageAttachment, @@ -188,16 +190,15 @@ export function useNativeChatComposerPaste({ // Beside pasted text, a server too old to store the image drops it quietly, so its chip stays // out of sight until the server answers; Send waits for it from the start all the same. const awaitServer = Boolean(text) && owner.kind === 'runtime-session' - const chip = startImageChip(owner, imageFile, { + const { id: pendingId, reveal } = startImageChip(owner, imageFile, { hidden: awaitServer, canShow: () => ownerAcceptsClipboardImage(owner) && canPaste() }) - const pendingId = chip.id void (async () => { const saved = await saveClipboardImageForOwner( owner, async () => Boolean(text), - awaitServer ? chip.reveal : undefined + awaitServer ? reveal : undefined ) if (keepStoreUploadAfterUnmount(pendingId, saved)) { return diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 74d257e5af1a..f92095aaad12 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -587,9 +587,9 @@ describe('useNativeChatExternalAttachments', () => { '/srv/agent-session-attachments/u1/shot.png', null ) - expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') + expect(chips.drop).not.toHaveBeenCalled() expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) // The client never reads its own disk for these paths: the server stores the bytes. expect(mocks.stat).not.toHaveBeenCalled() @@ -653,11 +653,11 @@ describe('useNativeChatExternalAttachments', () => { ) ) expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u1/notes.md' + { id: 'chip-1', path: '/srv/agent-session-attachments/u1/notes.md' } ]) }) - it('does not insert a file whose chip the user removed while it uploaded', async () => { + it('keeps each file operation ID through reference settlement, including removed chips', async () => { const upload = deferred() mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) const chips = trackingChips() @@ -681,7 +681,8 @@ describe('useNativeChatExternalAttachments', () => { ) expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' }, + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) }) @@ -709,7 +710,7 @@ describe('useNativeChatExternalAttachments', () => { expect(chips.drop).toHaveBeenCalledWith('chip-1') expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) // The cause, the size limit here, rides in the same notice. expect(notices).toEqual([ diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index f801303dab14..52275278a7bb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -1,14 +1,13 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { nativeChatPendingAttachmentScope } from './native-chat-pending-attachment-cache' /** - * The pending chips of pastes this composer started, released when it unmounts or changes target. - * A paste uploading into a paired server's store outlives the composer, which a prompt card - * unmounts: its chip stays pending in the scope's attachment cache, where its result settles for - * the composer's return (`native-chat-pending-attachment-cache.ts`). + * Preview URLs belong to this composer; pending uploads belong to the draft's attachment cache. */ export function useNativeChatPasteLifetime(args: { targetKey?: string + attachmentScopeKey?: string beginPendingImageAttachment: ( previewUrl?: string, pendingName?: string, @@ -33,7 +32,12 @@ export function useNativeChatPasteLifetime(args: { saved: { status: string; tempPath?: string } ) => boolean } { - const { targetKey, beginPendingImageAttachment, revealPendingImageAttachment } = args + const { + targetKey, + attachmentScopeKey, + beginPendingImageAttachment, + revealPendingImageAttachment + } = args const { resolvePendingImageAttachment, dropPendingImageAttachment } = args const dropPendingRef = useRef(dropPendingImageAttachment) useLayoutEffect(() => { @@ -44,7 +48,7 @@ export function useNativeChatPasteLifetime(args: { targetKey, active: false, pending: new Map(), - storeUploads: new Set() + uploads: new Map() }), [targetKey] ) @@ -56,19 +60,20 @@ export function useNativeChatPasteLifetime(args: { if (preview.startsWith('blob:')) { URL.revokeObjectURL(preview) } - if (!lifetime.storeUploads.has(id)) { + if ( + lifetime.uploads.get(id)?.kind !== 'runtime-session' && + !(attachmentScopeKey && nativeChatPendingAttachmentScope(attachmentScopeKey, id)) + ) { dropPendingRef.current(id) } } lifetime.pending.clear() } - }, [lifetime]) + }, [attachmentScopeKey, lifetime]) const track = useCallback( (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => { lifetime.pending.set(pendingId, preview) - if (owner.kind === 'runtime-session') { - lifetime.storeUploads.add(pendingId) - } + lifetime.uploads.set(pendingId, owner) }, [lifetime] ) @@ -112,17 +117,33 @@ export function useNativeChatPasteLifetime(args: { ) const keepStoreUploadAfterUnmount = useCallback( (pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => { - if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) { + if (!pendingId || lifetime.active) { + if (pendingId) { + lifetime.uploads.delete(pendingId) + } + return false + } + const owner = lifetime.uploads.get(pendingId) + if ( + owner?.kind !== 'runtime-session' && + !(attachmentScopeKey && nativeChatPendingAttachmentScope(attachmentScopeKey, pendingId)) + ) { + lifetime.uploads.delete(pendingId) return false } + lifetime.uploads.delete(pendingId) if (saved.status === 'saved' && saved.tempPath) { - resolvePendingImageAttachment(pendingId, saved.tempPath, null) + resolvePendingImageAttachment( + pendingId, + saved.tempPath, + owner?.kind === 'ssh' ? owner.connectionId : null + ) } else { dropPendingImageAttachment(pendingId) } return true }, - [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] + [attachmentScopeKey, dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] ) return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx new file mode 100644 index 000000000000..404cfc679586 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { writeNativeChatDraftCache } from './native-chat-draft-cache' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + settleNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' + +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('@/lib/worker-terminal-takeover-report', () => ({ + reportStructuredSessionUserInput: vi.fn() +})) + +const from = structuredAgentSessionDraftScopeKey('session-a') +const to = structuredAgentSessionDraftScopeKey('session-b') + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) + +afterEach(() => { + clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() +}) + +// The host moves the chat before the command reply lands. +async function clearHarness() { + updateNativeChatComposerDraft(from, { text: '/clear' }, 'immediate') + let reply: (accepted: boolean) => void = () => {} + const transport: NativeChatStructuredComposerTransport = { + send: vi.fn(() => true), + dispatchCommand: (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + agent: 'codex', + snapshot: [], + invokeAction: async () => true, + setOption: async () => true, + conversationCommands: ['clear'], + runConversationCommand: () => + new Promise((resolve) => { + reply = (accepted) => resolve({ accepted, error: accepted ? null : 'not cleared' }) + }) + }), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: 'session-a', + runtimeEnvironmentId: null + } + const { result } = renderHook(() => + useNativeChatStructuredComposerSend({ + agent: 'codex', + draftScopeKey: from, + imageAttachments: [], + structuredTransport: transport, + isComposing: () => false, + clearSkillOrigin: vi.fn(), + setDraft: (value) => writeNativeChatDraftCache(from, value), + setCaret: vi.fn() + }) + ) + + return { transport, send: result.current, reply: (accepted: boolean) => reply(accepted) } +} + +it('moves only what was typed after /clear when the chat moves before the reply', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + expect(readNativeChatComposerDraft(from).text).toBe('') + updateNativeChatComposerDraft(from, { text: 'next question' }, 'immediate') + await moveStructuredAgentSessionDraft('session-a', 'session-b') + reply(true) + await sent + + expect(readNativeChatComposerDraft(to).text).toBe('next question') + expect(readNativeChatComposerDraft(from).text).toBe('') +}) + +it('restores a refused clear command if no new draft replaced it', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + expect(readNativeChatComposerDraft(from).text).toBe('') + reply(false) + await sent + expect(readNativeChatComposerDraft(from).text).toBe('/clear') +}) + +it('leaves text and images typed after a refused clear untouched', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + const image = { id: 'image', path: '/remote/image.png', connectionId: 'ssh-1' } + updateNativeChatComposerDraft(from, { text: 'next question', images: [image] }, 'immediate') + reply(false) + await sent + expect(readNativeChatComposerDraft(from)).toMatchObject({ + text: 'next question', + images: [image] + }) +}) + +it('leaves the new composer empty when clear succeeds with no following input', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + await moveStructuredAgentSessionDraft('session-a', 'session-b') + reply(true) + await sent + expect(readNativeChatComposerDraft(to)).toMatchObject({ text: '', images: [] }) + expect(readNativeChatComposerDraft(from)).toMatchObject({ text: '', images: [] }) +}) + +it('does not restore a refused clear beside a pending-only image and sends that image next', async () => { + const { send, reply, transport } = await clearHarness() + const sent = send('/clear') + addNativeChatPendingAttachment(from, { id: 'new-image', path: '', pending: true }) + reply(false) + await sent + expect(readNativeChatComposerDraft(from).text).toBe('') + expect(nativeChatPendingAttachmentSnapshot(from)).toHaveLength(1) + settleNativeChatPendingAttachment(from, 'new-image', '/stored/new-image.png') + const next = readNativeChatComposerDraft(from) + await send(next.text, next.images) + expect(transport.send).toHaveBeenCalledWith('', [ + { id: 'new-image', path: '/stored/new-image.png' } + ]) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index b52e0483d46b..43e02156207b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -15,9 +15,11 @@ import { nativeChatNoticeFromError } from './native-chat-composer-notice' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { translate } from '@/i18n/i18n' import { + clearNativeChatComposerDraftIfUnchanged, readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' +import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache' import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' @@ -91,10 +93,34 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.onSubmitted?.() } const submitted = sentFrom ?? readNativeChatComposerDraft(draftScopeKey) + // Consume /clear before the host replaces the conversation and its composer. + const clearedAtSubmit = + hostCommand && + /^\/clear(?:\s|$)/i.test(text) && + clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted) + if (clearedAtSubmit) { + setDraft('') + setCaret(0) + clearSkillOrigin() + } + const restoreCommand = (): void => { + const current = readNativeChatComposerDraft(draftScopeKey) + if ( + clearedAtSubmit && + current.text === '' && + current.images.length === 0 && + nativeChatPendingAttachmentSnapshot(draftScopeKey).length === 0 + ) { + updateNativeChatComposerDraft(draftScopeKey, submitted, 'immediate') + setDraft(submitted.text) + setCaret(submitted.text.length) + } + } await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error, revealsTranscript }) => { structuredTransport.onError(error) if (!accepted) { + restoreCommand() return } emitNativeChatMessageSent({ agent, runtime: structuredTransport.runtime }) @@ -108,6 +134,9 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.sessionId, structuredTransport.runtimeEnvironmentId ) + if (clearedAtSubmit) { + return + } // Why: the send settles after a round trip, while this or another composer of the same // conversation may have changed the draft; only what was sent leaves it. const left = nativeChatComposerDraftLeftAfterSend( @@ -125,6 +154,7 @@ export function useNativeChatStructuredComposerSend({ clearSkillOrigin() }) .catch((error) => { + restoreCommand() const notice = nativeChatNoticeFromError( error, agentSessionWriteNoticeText([hostCommand ? 'notDoneCommand' : 'notDoneSend'])