From 7de0176f407c8ea4ea0d1a85bb9b5cef44aaaff2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:09:25 -0700 Subject: [PATCH 01/38] fix(native-chat): an unsent draft survives a reload or quit The composer's draft text, its editor document and settled image refs lived only in module memory, so a reload, quit or crash lost whatever was typed, including text a Stop had just given back. They are now saved per pane scope in localStorage: typing is debounced and flushed when the window hides or closes, text given back to the composer is saved at once, an emptied or sent draft is removed at once, and only the newest drafts within the composer caches' existing bound are kept. --- .../native-chat/native-chat-draft-cache.ts | 56 +++- .../native-chat-draft-storage.test.ts | 169 ++++++++++++ .../native-chat/native-chat-draft-storage.ts | 243 ++++++++++++++++++ .../use-native-chat-composer-attachments.ts | 32 ++- 4 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-draft-storage.ts 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 333ab7a2c50c..f8e1dc734a37 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 @@ -3,14 +3,47 @@ import type { JSONContent } from '@tiptap/react' // 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. Mirrors the attachment cache -// so both halves of an unsent message survive toggles and reconnects. +// so both halves of an unsent message survive toggles and reconnects; both are +// saved through native-chat-draft-storage, so they survive a reload or quit too. import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { + clearPersistedNativeChatDraftsForTests, + flushPersistedNativeChatDrafts, + persistNativeChatDraftPart, + readPersistedNativeChatDraft +} from './native-chat-draft-storage' -const draftCache = new Map() +type CachedDraft = { text: string; document?: JSONContent } + +const draftCache = new Map() + +/** The scope's draft, read back from storage when this run has not held it yet (a reload). */ +function cachedDraft(scopeKey: string): CachedDraft | undefined { + const cached = draftCache.get(scopeKey) + if (cached) { + return cached + } + const persisted = readPersistedNativeChatDraft(scopeKey) + if (!persisted?.text) { + return undefined + } + const restored = { + text: persisted.text, + ...(persisted.document ? { document: persisted.document } : {}) + } + setBoundedScopeCacheEntry(draftCache, scopeKey, restored) + return restored +} + +function setCachedDraft(scopeKey: string, draft: CachedDraft): void { + // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate. + setBoundedScopeCacheEntry(draftCache, scopeKey, draft) + persistNativeChatDraftPart(scopeKey, { text: draft.text, document: draft.document }, 'deferred') +} export function readNativeChatDraftCache(scopeKey: string): string { - return draftCache.get(scopeKey)?.text ?? '' + return cachedDraft(scopeKey)?.text ?? '' } export function writeNativeChatDraftCache(scopeKey: string, draft: string): void { @@ -18,13 +51,13 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void // scope key never resurrects cleared text. if (draft === '') { draftCache.delete(scopeKey) + persistNativeChatDraftPart(scopeKey, { text: '', document: undefined }, 'immediate') return } - // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate. - setBoundedScopeCacheEntry(draftCache, scopeKey, { + const cached = cachedDraft(scopeKey) + setCachedDraft(scopeKey, { text: draft, - document: - draftCache.get(scopeKey)?.text === draft ? draftCache.get(scopeKey)?.document : undefined + ...(cached?.text === draft && cached.document ? { document: cached.document } : {}) }) } @@ -44,6 +77,8 @@ export function appendNativeChatDraftCache(scopeKey: string, text: string): void scopeKey, appendNativeChatDraftText(readNativeChatDraftCache(scopeKey), text) ) + // Saved now: the copy it came from (an outbox entry, a queued card) goes right after this. + flushPersistedNativeChatDrafts() appendListeners.get(scopeKey)?.forEach((listener) => listener(text)) } @@ -64,13 +99,14 @@ export function subscribeToNativeChatDraftAppend( export function clearNativeChatDraftCacheForTests(): void { draftCache.clear() + clearPersistedNativeChatDraftsForTests() } export function readNativeChatDraftDocument( scopeKey: string, text: string ): JSONContent | undefined { - const cached = draftCache.get(scopeKey) + const cached = cachedDraft(scopeKey) return cached?.text === text ? cached.document : undefined } @@ -80,8 +116,8 @@ export function writeNativeChatDraftDocument( document: JSONContent ): void { if (!text) { - draftCache.delete(scopeKey) + writeNativeChatDraftCache(scopeKey, '') return } - setBoundedScopeCacheEntry(draftCache, scopeKey, { text, document }) + setCachedDraft(scopeKey, { text, document }) } diff --git a/src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts new file mode 100644 index 000000000000..77b48a6557b8 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' +import type * as DraftCache from './native-chat-draft-cache' +import type * as DraftStorage from './native-chat-draft-storage' +import type * as ComposerAttachments from './use-native-chat-composer-attachments' + +const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' + +type DraftModules = { + drafts: typeof DraftCache + attachments: typeof ComposerAttachments + storage: typeof DraftStorage +} + +/** A fresh renderer: module memory is gone, localStorage is not. */ +async function reload(): Promise { + vi.resetModules() + return { + drafts: await import('./native-chat-draft-cache'), + attachments: await import('./use-native-chat-composer-attachments'), + storage: await import('./native-chat-draft-storage') + } +} + +function storedDraftKeys(): string[] { + return Object.keys(localStorage).filter((key) => key.startsWith(DRAFT_KEY_PREFIX)) +} + +let modules: DraftModules + +// The attachment module's import graph is slow to transform cold; later reloads reuse it. +beforeAll(async () => { + await reload() +}, 300_000) + +beforeEach(async () => { + localStorage.clear() + modules = await reload() +}) + +afterEach(() => { + vi.useRealTimers() + localStorage.clear() +}) + +describe('native-chat draft storage', () => { + it('keeps a typed draft across a reload once its debounced write lands', async () => { + vi.useFakeTimers() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'half a thought') + expect(storedDraftKeys()).toEqual([]) + vi.advanceTimersByTime(250) + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('half a thought') + }) + + it('writes a still-debounced draft when the window goes away', async () => { + vi.useFakeTimers() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'typed just before reload') + window.dispatchEvent(new Event('pagehide')) + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typed just before reload') + }) + + it('saves text and images given back to the composer at once, before their other copy goes', async () => { + vi.useFakeTimers() + modules.drafts.appendNativeChatDraftCache('tab-1:pane', 'withdrawn by Stop') + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'withdrawn-a-1', path: '/repo/shot.png' }, + { id: 'withdrawn-a-2', path: '/tmp/orca-paste-1.png', connectionId: 'ssh-1' } + ]) + + // No timer advanced: a crash right after the restore still keeps it. + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('withdrawn by Stop') + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'withdrawn-a-1', path: '/repo/shot.png' }, + { id: 'withdrawn-a-2', path: '/tmp/orca-paste-1.png', connectionId: 'ssh-1' } + ]) + }) + + it('appends a given-back message after a draft restored from a reload', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine') + modules.storage.flushPersistedNativeChatDrafts() + + const reloaded = await reload() + reloaded.drafts.appendNativeChatDraftCache('tab-1:pane', 'returned') + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('mine\n\nreturned') + }) + + it('removes the saved draft as soon as it is sent or emptied', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'about to send') + modules.storage.flushPersistedNativeChatDrafts() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', '') + + expect(storedDraftKeys()).toEqual([]) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('') + }) + + it('keeps the images when only the text is cleared', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'caption') + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'a', path: '/repo/a.png' } + ]) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', '') + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('') + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'a', path: '/repo/a.png' } + ]) + }) + + it('restores the editor document with its text', async () => { + const document = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'with /skill' }] }] + } + modules.drafts.writeNativeChatDraftDocument('tab-1:pane', 'with /skill', document) + modules.storage.flushPersistedNativeChatDrafts() + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftDocument('tab-1:pane', 'with /skill')).toEqual( + document + ) + }) + + it('keeps an oversized draft in memory only, so it never crowds the outbox out of storage', async () => { + const huge = 'x'.repeat(250_000) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', huge) + modules.storage.flushPersistedNativeChatDrafts() + + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe(huge) + expect(storedDraftKeys()).toEqual([]) + }) + + it('keeps only the newest drafts and drops unreadable ones', async () => { + localStorage.setItem(`${DRAFT_KEY_PREFIX}broken`, '{not json') + const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 5 + vi.useFakeTimers() + for (let index = 0; index < total; index += 1) { + vi.setSystemTime(1_000 + index) + modules.drafts.writeNativeChatDraftCache(`scope-${index}`, `draft-${index}`) + modules.storage.flushPersistedNativeChatDrafts() + } + + const keys = storedDraftKeys() + expect(keys).toHaveLength(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) + expect(keys).not.toContain(`${DRAFT_KEY_PREFIX}broken`) + expect(keys).not.toContain(`${DRAFT_KEY_PREFIX}scope-0`) + expect(keys).toContain(`${DRAFT_KEY_PREFIX}scope-${total - 1}`) + }) + + it('keeps working from memory when storage refuses the write', async () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + try { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'still here') + expect(() => modules.storage.flushPersistedNativeChatDrafts()).not.toThrow() + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('still here') + } finally { + setItem.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-draft-storage.ts new file mode 100644 index 000000000000..ae80bf6c331c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-draft-storage.ts @@ -0,0 +1,243 @@ +// The composer's unsent message — text, its editor document and settled image refs — kept in +// localStorage per pane scope, so a reload or quit keeps what was typed or given back to it. The +// in-memory caches (native-chat-draft-cache, the attachment cache) stay the working copy; this is +// what they fall back to on a miss. + +import type { JSONContent } from '@tiptap/react' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' + +const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' +const PERSIST_DEBOUNCE_MS = 250 +// Why: drafts share the origin's storage quota with the outbox, which must never fail to save a +// send because a pasted log filled it; a larger draft stays in memory only. +const MAX_PERSISTED_DRAFT_CHARS = 200_000 + +export type PersistedNativeChatDraftAttachment = { + id: string + path: string + connectionId?: string +} + +export type PersistedNativeChatDraft = { + text: string + document?: JSONContent + attachments: PersistedNativeChatDraftAttachment[] +} + +const EMPTY_DRAFT: PersistedNativeChatDraft = { text: '', attachments: [] } + +const pendingDrafts = new Map() +let flushTimer: ReturnType | null = null +let flushOnHideInstalled = false + +function storageKey(scopeKey: string): string { + return `${DRAFT_KEY_PREFIX}${encodeURIComponent(scopeKey)}` +} + +function draftStorage(): Storage | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Only the editor's own root node is restored; anything else falls back to the plain text. */ +function isEditorDocument(value: unknown): value is JSONContent { + return isRecord(value) && value.type === 'doc' +} + +function parseAttachment(value: unknown): PersistedNativeChatDraftAttachment | null { + if (!isRecord(value)) { + return null + } + const { id, path, connectionId } = value + if (typeof id !== 'string' || typeof path !== 'string' || path === '') { + return null + } + return { id, path, ...(typeof connectionId === 'string' ? { connectionId } : {}) } +} + +function parseStoredDraft( + raw: string | null +): (PersistedNativeChatDraft & { savedAt: number }) | null { + if (raw === null) { + return null + } + try { + const value: unknown = JSON.parse(raw) + if (!isRecord(value)) { + return null + } + const { text, document, attachments, savedAt } = value + if (typeof text !== 'string' || typeof savedAt !== 'number' || !Array.isArray(attachments)) { + return null + } + return { + text, + ...(isEditorDocument(document) ? { document } : {}), + attachments: attachments.flatMap((attachment) => parseAttachment(attachment) ?? []), + savedAt + } + } catch { + return null + } +} + +function isEmptyDraft(draft: PersistedNativeChatDraft): boolean { + return draft.text === '' && draft.attachments.length === 0 +} + +/** The serialized draft, without its document when that alone makes it too large; null when even + * the text and image refs are. */ +function serializeDraft(draft: PersistedNativeChatDraft, savedAt: number): string | null { + const withDocument = JSON.stringify({ ...draft, savedAt }) + if (withDocument.length <= MAX_PERSISTED_DRAFT_CHARS) { + return withDocument + } + const { document: _dropped, ...plain } = draft + const withoutDocument = JSON.stringify({ ...plain, savedAt }) + return withoutDocument.length <= MAX_PERSISTED_DRAFT_CHARS ? withoutDocument : null +} + +/** Keeps the newest drafts within the composer caches' own bound; the rest are for panes long + * closed. Malformed records go too. */ +function pruneStoredDrafts(storage: Storage): void { + const stored: { key: string; savedAt: number }[] = [] + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index) + if (key?.startsWith(DRAFT_KEY_PREFIX)) { + stored.push({ key, savedAt: parseStoredDraft(storage.getItem(key))?.savedAt ?? -1 }) + } + } + stored.sort((left, right) => right.savedAt - left.savedAt) + for (const [index, { key, savedAt }] of stored.entries()) { + if (savedAt < 0 || index >= NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { + storage.removeItem(key) + } + } +} + +export function flushPersistedNativeChatDrafts(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + const storage = draftStorage() + if (!storage || pendingDrafts.size === 0) { + pendingDrafts.clear() + return + } + const savedAt = Date.now() + for (const [scopeKey, draft] of pendingDrafts) { + try { + const serialized = isEmptyDraft(draft) ? null : serializeDraft(draft, savedAt) + if (serialized === null) { + storage.removeItem(storageKey(scopeKey)) + } else { + storage.setItem(storageKey(scopeKey), serialized) + } + } catch { + // Over quota: the composer's memory copy still holds it for this run. + } + } + pendingDrafts.clear() + try { + pruneStoredDrafts(storage) + } catch { + // Pruning is bookkeeping; a failure leaves extra drafts for the next flush. + } +} + +function installFlushOnHide(): void { + if (flushOnHideInstalled || typeof window === 'undefined') { + return + } + flushOnHideInstalled = true + // Why: a debounced write still pending when the window reloads or closes would lose the last + // keystrokes. + window.addEventListener('pagehide', flushPersistedNativeChatDrafts) + window.addEventListener('beforeunload', flushPersistedNativeChatDrafts) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flushPersistedNativeChatDrafts() + } + }) +} + +export function readPersistedNativeChatDraft(scopeKey: string): PersistedNativeChatDraft | null { + const pending = pendingDrafts.get(scopeKey) + if (pending) { + return pending + } + const storage = draftStorage() + if (!storage) { + return null + } + try { + const stored = parseStoredDraft(storage.getItem(storageKey(scopeKey))) + if (!stored) { + return null + } + const { savedAt: _savedAt, ...draft } = stored + return draft + } catch { + return null + } +} + +/** + * Records one part of a scope's draft. `immediate` is for text given back to the composer (a + * Stop's withdrawn message, an edited queued card): its other copy is about to go, so it is saved + * before that. Typing is `deferred` and coalesced. + */ +export function persistNativeChatDraftPart( + scopeKey: string, + part: Partial, + mode: 'immediate' | 'deferred' +): void { + if (!draftStorage()) { + return + } + const base = readPersistedNativeChatDraft(scopeKey) ?? EMPTY_DRAFT + const next: PersistedNativeChatDraft = { ...base, ...part } + if (next.document === undefined) { + delete next.document + } + pendingDrafts.set(scopeKey, next) + // Clearing is immediate too, so a stored copy never outlives a sent or emptied draft. + if (mode === 'immediate' || isEmptyDraft(next)) { + flushPersistedNativeChatDrafts() + return + } + installFlushOnHide() + if (flushTimer === null) { + flushTimer = setTimeout(flushPersistedNativeChatDrafts, PERSIST_DEBOUNCE_MS) + } +} + +export function clearPersistedNativeChatDraftsForTests(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + pendingDrafts.clear() + const storage = draftStorage() + if (!storage) { + return + } + const keys: string[] = [] + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index) + if (key?.startsWith(DRAFT_KEY_PREFIX)) { + keys.push(key) + } + } + for (const key of keys) { + storage.removeItem(key) + } +} 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 341e242901fa..2b87124db166 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 @@ -7,6 +7,12 @@ import { } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { + clearPersistedNativeChatDraftsForTests, + flushPersistedNativeChatDrafts, + persistNativeChatDraftPart, + readPersistedNativeChatDraft +} from './native-chat-draft-storage' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' @@ -222,7 +228,16 @@ const attachmentCache = new Map() export function readNativeChatAttachmentCache( scopeKey: string ): NativeChatComposerImageAttachment[] { - return [...(attachmentCache.get(scopeKey) ?? [])] + const cached = attachmentCache.get(scopeKey) + if (cached) { + return [...cached] + } + // Not held this run (a reload): read back the settled chips the draft saved. + const persisted = readPersistedNativeChatDraft(scopeKey)?.attachments ?? [] + if (persisted.length > 0) { + setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...persisted]) + } + return [...persisted] } function writeNativeChatAttachmentCache( @@ -239,10 +254,22 @@ function writeNativeChatAttachmentCache( .map(({ previewUrl: _previewUrl, ...attachment }) => attachment) if (attachments.length === 0) { attachmentCache.delete(scopeKey) + persistNativeChatDraftPart(scopeKey, { attachments: [] }, 'immediate') return } // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments]) + persistNativeChatDraftPart( + scopeKey, + { + attachments: attachments.map(({ id, path, connectionId }) => ({ + id, + path, + ...(connectionId ? { connectionId } : {}) + })) + }, + 'deferred' + ) } // Only a write from outside the composer notifies; its own writes already hold the chips. @@ -263,6 +290,8 @@ export function appendNativeChatAttachmentCache( ...readNativeChatAttachmentCache(scopeKey), ...appended ]) + // Saved now: the copy it came from goes right after this. + flushPersistedNativeChatDrafts() appendListeners.get(scopeKey)?.forEach((listener) => listener(appended)) } @@ -283,4 +312,5 @@ function subscribeToNativeChatAttachmentAppend( export function clearNativeChatAttachmentCacheForTests(): void { attachmentCache.clear() + clearPersistedNativeChatDraftsForTests() } From ea7f24584f28bfe7b06168aef6ca56dcb5bc9e84 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:21:28 -0700 Subject: [PATCH 02/38] fix(native-chat): save draft images outside the state updater --- .../native-chat/native-chat-draft-storage.ts | 7 +++++++ .../use-native-chat-composer-attachments.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-draft-storage.ts index ae80bf6c331c..842a6a5d39e1 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-storage.ts @@ -208,6 +208,13 @@ export function persistNativeChatDraftPart( if (next.document === undefined) { delete next.document } + // A composer mounting or re-rendering with what is already saved writes nothing. + if (JSON.stringify(next) === JSON.stringify(base)) { + if (mode === 'immediate' && pendingDrafts.has(scopeKey)) { + flushPersistedNativeChatDrafts() + } + return + } pendingDrafts.set(scopeKey, next) // Clearing is immediate too, so a stored copy never outlives a sent or emptied draft. if (mode === 'immediate' || isEmptyDraft(next)) { 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 2b87124db166..e984d37900dd 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 @@ -59,6 +59,12 @@ export function useNativeChatComposerAttachments({ ) const imageAttachmentCounter = useRef(0) + // Read from the scope cache the updater below writes, never from this state, so a scope change + // can't save one pane's chips under another. + useEffect(() => { + persistNativeChatAttachmentCache(attachmentScopeKey) + }, [attachmentScopeKey, imageAttachments]) + useEffect( () => subscribeToNativeChatAttachmentAppend(attachmentScopeKey, (appended) => @@ -254,11 +260,15 @@ function writeNativeChatAttachmentCache( .map(({ previewUrl: _previewUrl, ...attachment }) => attachment) if (attachments.length === 0) { attachmentCache.delete(scopeKey) - persistNativeChatDraftPart(scopeKey, { attachments: [] }, 'immediate') return } // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments]) +} + +/** Saves the scope's cached chips with its draft, so a reload gives them back. */ +function persistNativeChatAttachmentCache(scopeKey: string): void { + const attachments = attachmentCache.get(scopeKey) ?? [] persistNativeChatDraftPart( scopeKey, { @@ -268,7 +278,7 @@ function writeNativeChatAttachmentCache( ...(connectionId ? { connectionId } : {}) })) }, - 'deferred' + attachments.length === 0 ? 'immediate' : 'deferred' ) } @@ -290,6 +300,7 @@ export function appendNativeChatAttachmentCache( ...readNativeChatAttachmentCache(scopeKey), ...appended ]) + persistNativeChatAttachmentCache(scopeKey) // Saved now: the copy it came from goes right after this. flushPersistedNativeChatDrafts() appendListeners.get(scopeKey)?.forEach((listener) => listener(appended)) From a483c65868850015d28520d4dc1c242c5fbe9ccf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:49:12 -0700 Subject: [PATCH 03/38] fix(native-chat): one store owns each unsent draft The draft's text and its image chips were saved by two writers that each rebuilt the stored record from what storage held, so a draft that once went over the size cap, or one refused write, lost its images on the next save. One store now holds each composer scope's whole draft in memory and writes storage from it: typing is coalesced, returned text, chips and clears land at once, a refused write stays pending for the next flush, and all drafts together keep to a budget so the outbox can still save a send. Closing a tab drops its panes' drafts. --- .../native-chat-composer-draft-storage.ts | 251 ++++++++++++++ .../native-chat-composer-draft-store.test.ts | 328 ++++++++++++++++++ .../native-chat-composer-draft-store.ts | 231 ++++++++++++ .../native-chat-composer-scope-cache.ts | 8 +- .../native-chat/native-chat-draft-cache.ts | 88 ++--- .../native-chat-draft-storage.test.ts | 169 --------- .../native-chat/native-chat-draft-storage.ts | 250 ------------- .../use-native-chat-composer-attachments.ts | 89 ++--- .../native-chat/use-native-chat-draft.ts | 31 +- .../native-chat-launch-draft-teardown.test.ts | 56 ++- .../store/slices/tabs/tabs-close-actions.ts | 2 + .../src/store/terminals/terminal-tab-close.ts | 5 + 12 files changed, 949 insertions(+), 559 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-draft-storage.ts diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts new file mode 100644 index 000000000000..7d8f3a40ff56 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -0,0 +1,251 @@ +// The stored form of composer drafts: one localStorage record per scope, written whole from the +// draft store's memory record, and the bounds that keep all of them small. + +import type { JSONContent } from '@tiptap/react' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' + +const STORAGE_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' +// Why: drafts share the origin's localStorage quota (Chromium: 10 MiB of UTF-16, ~5M chars) with +// the outbox, which must always be able to save a send, so all drafts together keep to a fifth. +const MAX_STORED_TOTAL_CHARS = 1_000_000 +const MAX_STORED_DRAFT_CHARS = 200_000 + +export type NativeChatComposerDraftImage = { + id: string + path: string + connectionId?: string +} + +export type NativeChatComposerDraft = { + readonly text: string + /** The editor's document for exactly `text`; dropped when the text changes without one. */ + readonly document?: JSONContent + readonly images: readonly NativeChatComposerDraftImage[] +} + +export type StoredNativeChatComposerDraft = NativeChatComposerDraft & { readonly savedAt: number } + +type StoredDraftSize = { chars: number; savedAt: number } + +// Size and age of each stored draft, read from storage once per run so bounds never re-parse it. +let storedDrafts: Map | null = null +let storedTotalChars = 0 + +export function nativeChatComposerDraftStorageKey(scopeKey: string): string { + return `${STORAGE_KEY_PREFIX}${encodeURIComponent(scopeKey)}` +} + +export function nativeChatComposerDraftStorage(): Storage | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Only the editor's own root node is restored; anything else falls back to the plain text. */ +function isEditorDocument(value: unknown): value is JSONContent { + return isRecord(value) && value.type === 'doc' +} + +function parseImage(value: unknown): NativeChatComposerDraftImage | null { + if (!isRecord(value)) { + return null + } + const { id, path, connectionId } = value + if (typeof id !== 'string' || typeof path !== 'string' || path === '') { + return null + } + return { id, path, ...(typeof connectionId === 'string' ? { connectionId } : {}) } +} + +export function parseStoredNativeChatComposerDraft( + raw: string | null +): StoredNativeChatComposerDraft | null { + if (raw === null) { + return null + } + try { + const value: unknown = JSON.parse(raw) + if (!isRecord(value)) { + return null + } + const { text, document, images, savedAt } = value + if (typeof text !== 'string' || typeof savedAt !== 'number' || !Array.isArray(images)) { + return null + } + return { + text, + ...(isEditorDocument(document) ? { document } : {}), + images: images.flatMap((image) => parseImage(image) ?? []), + savedAt + } + } catch { + return null + } +} + +function indexStoredDraft(key: string, size: StoredDraftSize): void { + if (!storedDrafts) { + return + } + storedTotalChars += size.chars - (storedDrafts.get(key)?.chars ?? 0) + storedDrafts.set(key, size) +} + +function unindexStoredDraft(key: string): void { + storedTotalChars -= storedDrafts?.get(key)?.chars ?? 0 + storedDrafts?.delete(key) +} + +/** Reads every stored draft's size once per run; unreadable records go. */ +function storedDraftIndex(storage: Storage): Map { + if (storedDrafts) { + return storedDrafts + } + const index = new Map() + const unreadable: string[] = [] + let total = 0 + for (let position = 0; position < storage.length; position += 1) { + const key = storage.key(position) + if (!key?.startsWith(STORAGE_KEY_PREFIX)) { + continue + } + const raw = storage.getItem(key) + const stored = parseStoredNativeChatComposerDraft(raw) + if (raw === null || !stored) { + unreadable.push(key) + continue + } + index.set(key, { chars: key.length + raw.length, savedAt: stored.savedAt }) + total += key.length + raw.length + } + for (const key of unreadable) { + storage.removeItem(key) + } + storedDrafts = index + storedTotalChars = total + return index +} + +/** Indexes what storage already holds before this run's first write, so bounds count it. */ +export function indexStoredNativeChatComposerDrafts(storage: Storage): void { + try { + storedDraftIndex(storage) + } catch { + // Bookkeeping only: writes still land, unbounded until a later flush indexes them. + } +} + +/** The record as stored: without its document when that alone makes it too large; null when even + * its text and images are. */ +function serializeDraft({ + text, + document, + images, + savedAt +}: StoredNativeChatComposerDraft): string | null { + const whole = JSON.stringify({ text, ...(document ? { document } : {}), images, savedAt }) + if (whole.length <= MAX_STORED_DRAFT_CHARS) { + return whole + } + if (!document) { + return null + } + const withoutDocument = JSON.stringify({ text, images, savedAt }) + return withoutDocument.length <= MAX_STORED_DRAFT_CHARS ? withoutDocument : null +} + +export function removeStoredNativeChatComposerDraft(storage: Storage, key: string): void { + try { + storage.removeItem(key) + unindexStoredDraft(key) + } catch { + // Storage gone: nothing left to read back either. + } +} + +/** + * Stores the whole record; false when storage refused it. One too large stays in memory only, and + * its older stored copy goes so it can never come back in place of this one. + */ +export function writeStoredNativeChatComposerDraft( + storage: Storage, + scopeKey: string, + draft: StoredNativeChatComposerDraft +): boolean { + const key = nativeChatComposerDraftStorageKey(scopeKey) + const serialized = serializeDraft(draft) + try { + if (serialized === null) { + storage.removeItem(key) + unindexStoredDraft(key) + } else { + storage.setItem(key, serialized) + indexStoredDraft(key, { chars: key.length + serialized.length, savedAt: draft.savedAt }) + } + return true + } catch { + return false + } +} + +/** Keeps the stored drafts within the count and total budget, dropping the oldest first. */ +export function enforceStoredNativeChatComposerDraftBounds(storage: Storage): void { + const index = storedDrafts + if (!index) { + return + } + const withinBounds = (): boolean => + index.size <= NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX && storedTotalChars <= MAX_STORED_TOTAL_CHARS + if (withinBounds()) { + return + } + const oldestFirst = [...index.entries()].sort((left, right) => left[1].savedAt - right[1].savedAt) + for (const [key] of oldestFirst) { + if (withinBounds()) { + return + } + removeStoredNativeChatComposerDraft(storage, key) + } +} + +/** Removes every stored draft whose scope starts with `scopePrefix`. */ +export function removeStoredNativeChatComposerDraftsByScopePrefix( + storage: Storage, + scopePrefix: string +): void { + try { + const keyPrefix = nativeChatComposerDraftStorageKey(scopePrefix) + for (const key of storedDraftIndex(storage).keys()) { + if (key.startsWith(keyPrefix)) { + removeStoredNativeChatComposerDraft(storage, key) + } + } + } catch { + // Cleanup only; the count and size bounds still retire these drafts. + } +} + +export function clearStoredNativeChatComposerDraftsForTests(): void { + storedDrafts = null + storedTotalChars = 0 + const storage = nativeChatComposerDraftStorage() + if (!storage) { + return + } + const keys: string[] = [] + for (let position = 0; position < storage.length; position += 1) { + const key = storage.key(position) + if (key?.startsWith(STORAGE_KEY_PREFIX)) { + keys.push(key) + } + } + for (const key of keys) { + storage.removeItem(key) + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts new file mode 100644 index 000000000000..11da403af24f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -0,0 +1,328 @@ +// @vitest-environment happy-dom +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' +import type * as DraftStore from './native-chat-composer-draft-store' +import type * as DraftCache from './native-chat-draft-cache' +import type * as ComposerAttachments from './use-native-chat-composer-attachments' +import { writeOutbox } from './structured-agent-session-outbox-storage' + +const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' +// Chromium's per-origin localStorage quota: 10 MiB of UTF-16, keys included. +const CHROMIUM_QUOTA_CHARS = 5 * 1024 * 1024 + +type DraftModules = { + drafts: typeof DraftCache + attachments: typeof ComposerAttachments + store: typeof DraftStore +} + +/** A fresh renderer: module memory is gone, localStorage is not. */ +async function reload(): Promise { + vi.resetModules() + return { + drafts: await import('./native-chat-draft-cache'), + attachments: await import('./use-native-chat-composer-attachments'), + store: await import('./native-chat-composer-draft-store') + } +} + +/** Storage that refuses writes past a quota, the way Chromium's does, or every write when told. */ +class QuotaStorage { + private readonly items = new Map() + refuseWrites = false + writes = 0 + constructor(private readonly quotaChars: number) {} + get length(): number { + return this.items.size + } + key(index: number): string | null { + return [...this.items.keys()][index] ?? null + } + getItem(key: string): string | null { + return this.items.get(key) ?? null + } + setItem(key: string, value: string): void { + this.writes += 1 + if (this.refuseWrites) { + throw new DOMException('quota', 'QuotaExceededError') + } + const used = [...this.items].reduce( + (total, [itemKey, itemValue]) => + itemKey === key ? total : total + itemKey.length + itemValue.length, + 0 + ) + if (used + key.length + value.length > this.quotaChars) { + throw new DOMException('quota', 'QuotaExceededError') + } + this.items.set(key, value) + } + removeItem(key: string): void { + this.items.delete(key) + } + clear(): void { + this.items.clear() + } +} + +let storage: QuotaStorage + +function storedDraftKeys(): string[] { + return Array.from({ length: storage.length }, (_, index) => storage.key(index) ?? '').filter( + (key) => key.startsWith(DRAFT_KEY_PREFIX) + ) +} + +function storedDraft(scopeKey: string): Record | null { + const raw = storage.getItem(`${DRAFT_KEY_PREFIX}${encodeURIComponent(scopeKey)}`) + return raw === null ? null : JSON.parse(raw) +} + +const IMAGES = [ + { id: 'a-1', path: '/repo/shot.png' }, + { id: 'a-2', path: '/tmp/orca-paste-1.png', connectionId: 'ssh-1' } +] + +const SKILL_DOCUMENT = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'with /skill' }] }] +} + +let modules: DraftModules + +// The attachment module's import graph is slow to transform cold; later reloads reuse it. +beforeAll(async () => { + await reload() +}, 300_000) + +beforeEach(async () => { + storage = new QuotaStorage(CHROMIUM_QUOTA_CHARS) + vi.stubGlobal('localStorage', storage) + modules = await reload() +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('native-chat composer draft store', () => { + it('gives back the text, its editor document and the images after a reload', async () => { + vi.useFakeTimers() + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) + modules.drafts.writeNativeChatDraftDocument('tab-1:pane', 'with /skill', SKILL_DOCUMENT) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'with /skill') + expect(storedDraft('tab-1:pane')?.text).toBe('') + vi.advanceTimersByTime(250) + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('with /skill') + expect(reloaded.drafts.readNativeChatDraftDocument('tab-1:pane', 'with /skill')).toEqual( + SKILL_DOCUMENT + ) + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) + }) + + it('writes a still-deferred draft when the window goes away', async () => { + vi.useFakeTimers() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'typed just before reload') + window.dispatchEvent(new Event('pagehide')) + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typed just before reload') + }) + + it('saves text and images given back to the composer at once, before their other copy goes', async () => { + vi.useFakeTimers() + modules.drafts.appendNativeChatDraftCache('tab-1:pane', 'withdrawn by Stop') + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) + + // No timer advanced: a crash right after the restore still keeps it. + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('withdrawn by Stop') + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) + }) + + it('appends a given-back message after a draft restored from a reload', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine') + modules.store.flushNativeChatComposerDrafts() + + const reloaded = await reload() + reloaded.drafts.appendNativeChatDraftCache('tab-1:pane', 'returned') + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('mine\n\nreturned') + }) + + it('removes the saved draft as soon as it is sent, even with a typed change still deferred', async () => { + vi.useFakeTimers() + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'about to send') + modules.store.flushNativeChatComposerDrafts() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'about to send!') + + // The send clears the text, then the images; the text clear lands before any timer. + modules.drafts.writeNativeChatDraftCache('tab-1:pane', '') + expect(storedDraft('tab-1:pane')).toMatchObject({ text: '', images: IMAGES }) + modules.store.updateNativeChatComposerDraft('tab-1:pane', { images: [] }, 'immediate') + expect(storedDraftKeys()).toEqual([]) + + vi.advanceTimersByTime(1_000) + expect(storedDraftKeys()).toEqual([]) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('') + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([]) + }) + + it('removes an emptied draft without waiting for a flush', () => { + vi.useFakeTimers() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'about to send') + modules.store.flushNativeChatComposerDrafts() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', '') + + expect(storedDraftKeys()).toEqual([]) + }) + + it('keeps the images after a long paste is trimmed back, and after a failed write', async () => { + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'x'.repeat(250_000)) + modules.store.flushNativeChatComposerDrafts() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'short again') + modules.store.flushNativeChatComposerDrafts() + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('short again') + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) + + storage.refuseWrites = true + reloaded.drafts.writeNativeChatDraftCache('tab-1:pane', 'refused') + reloaded.store.flushNativeChatComposerDrafts() + expect(storedDraft('tab-1:pane')?.text).toBe('short again') + storage.refuseWrites = false + reloaded.drafts.writeNativeChatDraftCache('tab-1:pane', 'lands') + reloaded.store.flushNativeChatComposerDrafts() + + const reloadedAgain = await reload() + expect(reloadedAgain.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('lands') + expect(reloadedAgain.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) + }) + + it('keeps a refused draft in memory and writes it on the next flush', async () => { + storage.refuseWrites = true + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'still here') + expect(() => modules.store.flushNativeChatComposerDrafts()).not.toThrow() + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('still here') + expect(storedDraftKeys()).toEqual([]) + + storage.refuseWrites = false + window.dispatchEvent(new Event('pagehide')) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('still here') + }) + + it('keeps the text but not the document when the document alone makes the draft too large', async () => { + const document = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'y'.repeat(150_000) }] }], + attrs: { padding: 'z'.repeat(100_000) } + } + modules.drafts.writeNativeChatDraftDocument('tab-1:pane', 'with a big document', document) + modules.store.flushNativeChatComposerDrafts() + + expect(modules.drafts.readNativeChatDraftDocument('tab-1:pane', 'with a big document')).toBe( + document + ) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('with a big document') + expect( + reloaded.drafts.readNativeChatDraftDocument('tab-1:pane', 'with a big document') + ).toBeUndefined() + }) + + it('keeps a draft too large to store in memory only, and never brings back its older copy', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'older') + modules.store.flushNativeChatComposerDrafts() + const huge = 'x'.repeat(250_000) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', huge) + modules.store.flushNativeChatComposerDrafts() + + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe(huge) + expect(storedDraftKeys()).toEqual([]) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('') + }) + + it('writes nothing when a change leaves the draft as it was', () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'same') + modules.store.flushNativeChatComposerDrafts() + const writes = storage.writes + + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'same') + modules.store.updateNativeChatComposerDraft('tab-1:pane', { images: [] }, 'immediate') + modules.store.flushNativeChatComposerDrafts() + expect(storage.writes).toBe(writes) + }) + + it('keeps only the newest drafts and drops unreadable ones', () => { + storage.setItem(`${DRAFT_KEY_PREFIX}broken`, '{not json') + const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 5 + for (let index = 0; index < total; index += 1) { + modules.drafts.writeNativeChatDraftCache(`scope-${index}`, `draft-${index}`) + modules.store.flushNativeChatComposerDrafts() + } + + const keys = storedDraftKeys() + expect(keys).toHaveLength(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) + expect(keys).not.toContain(`${DRAFT_KEY_PREFIX}broken`) + expect(keys).not.toContain(`${DRAFT_KEY_PREFIX}scope-0`) + expect(keys).toContain(`${DRAFT_KEY_PREFIX}scope-${total - 1}`) + }) + + it('saves a draft pushed out of memory before its deferred write landed', () => { + vi.useFakeTimers() + modules.drafts.writeNativeChatDraftCache('scope-first', 'oldest unsaved') + for (let index = 0; index < NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX; index += 1) { + modules.drafts.writeNativeChatDraftCache(`scope-${index}`, `draft-${index}`) + } + + expect(storedDraft('scope-first')?.text).toBe('oldest unsaved') + expect(modules.drafts.readNativeChatDraftCache('scope-first')).toBe('oldest unsaved') + }) + + it('keeps every draft together within a budget, so a large send still fits in the outbox', async () => { + for (let index = 0; index < 40; index += 1) { + modules.drafts.writeNativeChatDraftCache(`tab-${index}:pane`, 'd'.repeat(190_000)) + modules.store.flushNativeChatComposerDrafts() + } + expect(modules.drafts.readNativeChatDraftCache('tab-0:pane')).toHaveLength(190_000) + const storedChars = storedDraftKeys().reduce( + (total, key) => total + key.length + (storage.getItem(key)?.length ?? 0), + 0 + ) + expect(storedChars).toBeLessThanOrEqual(1_000_000) + expect(storedDraftKeys()).toContain(`${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-39:pane')}`) + + const send = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-1', + sessionId: 'session-1', + text: 'p'.repeat(3_000_000), + attachments: [], + queuedAt: 1 + }) + expect(writeOutbox('session-1', [send])).toBe(true) + }) + + it('drops the drafts of a closed tab and leaves other tabs alone', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane-a', 'a') + modules.drafts.writeNativeChatDraftCache('tab-1:pane-b', 'b') + modules.drafts.writeNativeChatDraftCache('tab-10:pane', 'other tab') + modules.store.flushNativeChatComposerDrafts() + modules.drafts.writeNativeChatDraftCache('tab-1:pane-a', 'a, still deferred') + + modules.store.deleteNativeChatComposerDraftsForTab('tab-1') + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane-a')).toBe('') + modules.store.flushNativeChatComposerDrafts() + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane-a')).toBe('') + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane-b')).toBe('') + expect(reloaded.drafts.readNativeChatDraftCache('tab-10:pane')).toBe('other tab') + }) +}) 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 new file mode 100644 index 000000000000..6c983bb4d665 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -0,0 +1,231 @@ +// The one owner of each composer scope's unsent message: its text, the editor document for that +// text and its settled image refs. Every writer changes the whole record here in memory, and +// storage is written from that record, so a reload or quit gives the draft back and a failed or +// skipped write is repaired by the next one. + +import type { JSONContent } from '@tiptap/react' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { + clearStoredNativeChatComposerDraftsForTests, + enforceStoredNativeChatComposerDraftBounds, + indexStoredNativeChatComposerDrafts, + nativeChatComposerDraftStorage, + nativeChatComposerDraftStorageKey, + parseStoredNativeChatComposerDraft, + removeStoredNativeChatComposerDraft, + removeStoredNativeChatComposerDraftsByScopePrefix, + writeStoredNativeChatComposerDraft, + type NativeChatComposerDraft, + type NativeChatComposerDraftImage, + type StoredNativeChatComposerDraft +} from './native-chat-composer-draft-storage' + +const PERSIST_DEBOUNCE_MS = 250 + +export type NativeChatComposerDraftChange = { + text?: string + /** Present, even as undefined, to replace the document. */ + document?: JSONContent + images?: readonly NativeChatComposerDraftImage[] +} + +const EMPTY_DRAFT: NativeChatComposerDraft = { text: '', images: [] } + +const records = new Map() +// Scopes whose record storage does not hold yet; a refused write stays here for the next flush. +const dirtyScopes = new Set() +let lastSavedAt = 0 +let flushTimer: ReturnType | null = null +let flushOnHideInstalled = false + +function isEmptyDraft(draft: NativeChatComposerDraft): boolean { + return draft.text === '' && draft.images.length === 0 +} + +function sameImages( + left: readonly NativeChatComposerDraftImage[], + right: readonly NativeChatComposerDraftImage[] +): boolean { + return ( + left.length === right.length && + left.every( + (image, index) => + image.id === right[index].id && + image.path === right[index].path && + image.connectionId === right[index].connectionId + ) + ) +} + +/** Monotonic within a run, so drafts changed in the same millisecond still age in order. */ +function nextSavedAt(): number { + lastSavedAt = Math.max(Date.now(), lastSavedAt + 1) + return lastSavedAt +} + +function writeRecord( + storage: Storage, + scopeKey: string, + record: StoredNativeChatComposerDraft +): void { + if (writeStoredNativeChatComposerDraft(storage, scopeKey, record)) { + dirtyScopes.delete(scopeKey) + } +} + +// A draft leaving memory before its write landed is written on the way out. +function writeEvictedRecord(scopeKey: string, record: StoredNativeChatComposerDraft): void { + const storage = dirtyScopes.has(scopeKey) ? nativeChatComposerDraftStorage() : null + if (storage) { + writeRecord(storage, scopeKey, record) + } + dirtyScopes.delete(scopeKey) +} + +export function flushNativeChatComposerDrafts(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + if (dirtyScopes.size === 0) { + return + } + const storage = nativeChatComposerDraftStorage() + if (!storage) { + dirtyScopes.clear() + return + } + indexStoredNativeChatComposerDrafts(storage) + for (const scopeKey of dirtyScopes) { + const record = records.get(scopeKey) + if (record) { + writeRecord(storage, scopeKey, record) + } else { + dirtyScopes.delete(scopeKey) + } + } + enforceStoredNativeChatComposerDraftBounds(storage) +} + +function installFlushOnHide(): void { + if ( + flushOnHideInstalled || + typeof window === 'undefined' || + typeof window.addEventListener !== 'function' || + typeof document === 'undefined' + ) { + return + } + flushOnHideInstalled = true + // Why: a deferred write still pending when the window reloads or closes would lose the last + // keystrokes. + window.addEventListener('pagehide', flushNativeChatComposerDrafts) + window.addEventListener('beforeunload', flushNativeChatComposerDrafts) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flushNativeChatComposerDrafts() + } + }) +} + +/** The scope's record, read from storage the first time this run asks for it. */ +function loadRecord(scopeKey: string): StoredNativeChatComposerDraft | undefined { + const held = records.get(scopeKey) + if (held) { + return held + } + const storage = nativeChatComposerDraftStorage() + if (!storage) { + return undefined + } + try { + const stored = parseStoredNativeChatComposerDraft( + storage.getItem(nativeChatComposerDraftStorageKey(scopeKey)) + ) + if (!stored) { + return undefined + } + setBoundedScopeCacheEntry(records, scopeKey, stored, writeEvictedRecord) + return stored + } catch { + return undefined + } +} + +export function readNativeChatComposerDraft(scopeKey: string): NativeChatComposerDraft { + return loadRecord(scopeKey) ?? EMPTY_DRAFT +} + +/** + * Changes fields of the scope's draft. `deferred` is for typing, coalesced into one write; + * `immediate` saves now: text or images given back from a copy about to be deleted, and clears. + * An emptied draft is removed at once, so a sent message never comes back. + */ +export function updateNativeChatComposerDraft( + scopeKey: string, + change: NativeChatComposerDraftChange, + persist: 'immediate' | 'deferred' +): void { + const current = loadRecord(scopeKey) ?? EMPTY_DRAFT + const text = change.text ?? current.text + const document = 'document' in change ? change.document : current.document + const images = change.images ?? current.images + if ( + text === current.text && + document === current.document && + sameImages(images, current.images) + ) { + if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { + flushNativeChatComposerDrafts() + } + return + } + if (isEmptyDraft({ text, images })) { + records.delete(scopeKey) + dirtyScopes.delete(scopeKey) + const storage = nativeChatComposerDraftStorage() + if (storage) { + removeStoredNativeChatComposerDraft(storage, nativeChatComposerDraftStorageKey(scopeKey)) + } + return + } + const record: StoredNativeChatComposerDraft = { + text, + ...(document ? { document } : {}), + images: [...images], + savedAt: nextSavedAt() + } + setBoundedScopeCacheEntry(records, scopeKey, record, writeEvictedRecord) + dirtyScopes.add(scopeKey) + installFlushOnHide() + if (persist === 'immediate') { + flushNativeChatComposerDrafts() + return + } + flushTimer ??= setTimeout(flushNativeChatComposerDrafts, PERSIST_DEBOUNCE_MS) +} + +/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. */ +export function deleteNativeChatComposerDraftsForTab(tabId: string): void { + const scopePrefix = `${tabId}:` + for (const scopeKey of records.keys()) { + if (scopeKey.startsWith(scopePrefix)) { + records.delete(scopeKey) + dirtyScopes.delete(scopeKey) + } + } + const storage = nativeChatComposerDraftStorage() + if (storage) { + removeStoredNativeChatComposerDraftsByScopePrefix(storage, scopePrefix) + } +} + +export function clearNativeChatComposerDraftsForTests(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + records.clear() + dirtyScopes.clear() + clearStoredNativeChatComposerDraftsForTests() +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts index fc26e303a9f2..40ab2a612420 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -10,15 +10,17 @@ export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128 export function setBoundedScopeCacheEntry( cache: Map, scopeKey: string, - value: T + value: T, + onEvict?: (evictedKey: string, evicted: T) => void ): void { cache.delete(scopeKey) cache.set(scopeKey, value) while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { - const oldest = cache.keys().next().value + const oldest = cache.entries().next().value if (oldest === undefined) { break } - cache.delete(oldest) + cache.delete(oldest[0]) + onEvict?.(oldest[0], oldest[1]) } } 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 f8e1dc734a37..6cab022307e6 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,64 +1,30 @@ import type { JSONContent } from '@tiptap/react' -// Module-level cache for the composer's in-progress draft text, 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. Mirrors the attachment cache -// so both halves of an unsent message survive toggles and reconnects; both are -// saved through native-chat-draft-storage, so they survive a reload or quit too. +// 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. +// A view of native-chat-composer-draft-store, which owns the whole draft and keeps it across a +// reload or quit. -import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import { - clearPersistedNativeChatDraftsForTests, - flushPersistedNativeChatDrafts, - persistNativeChatDraftPart, - readPersistedNativeChatDraft -} from './native-chat-draft-storage' - -type CachedDraft = { text: string; document?: JSONContent } - -const draftCache = new Map() - -/** The scope's draft, read back from storage when this run has not held it yet (a reload). */ -function cachedDraft(scopeKey: string): CachedDraft | undefined { - const cached = draftCache.get(scopeKey) - if (cached) { - return cached - } - const persisted = readPersistedNativeChatDraft(scopeKey) - if (!persisted?.text) { - return undefined - } - const restored = { - text: persisted.text, - ...(persisted.document ? { document: persisted.document } : {}) - } - setBoundedScopeCacheEntry(draftCache, scopeKey, restored) - return restored -} - -function setCachedDraft(scopeKey: string, draft: CachedDraft): void { - // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate. - setBoundedScopeCacheEntry(draftCache, scopeKey, draft) - persistNativeChatDraftPart(scopeKey, { text: draft.text, document: draft.document }, 'deferred') -} + clearNativeChatComposerDraftsForTests, + readNativeChatComposerDraft, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' export function readNativeChatDraftCache(scopeKey: string): string { - return cachedDraft(scopeKey)?.text ?? '' + return readNativeChatComposerDraft(scopeKey).text } export function writeNativeChatDraftCache(scopeKey: string, draft: string): void { - // An empty draft carries no state worth retaining; drop the entry so a stale - // scope key never resurrects cleared text. - if (draft === '') { - draftCache.delete(scopeKey) - persistNativeChatDraftPart(scopeKey, { text: '', document: undefined }, 'immediate') + if (readNativeChatComposerDraft(scopeKey).text === draft) { return } - const cached = cachedDraft(scopeKey) - setCachedDraft(scopeKey, { - text: draft, - ...(cached?.text === draft && cached.document ? { document: cached.document } : {}) - }) + // Cleared at once, so a sent or emptied draft never resurfaces. + updateNativeChatComposerDraft( + scopeKey, + { text: draft, document: undefined }, + draft === '' ? 'immediate' : 'deferred' + ) } export function appendNativeChatDraftText(draft: string, text: string): string { @@ -73,12 +39,15 @@ export function appendNativeChatDraftCache(scopeKey: string, text: string): void if (text === '') { return } - writeNativeChatDraftCache( + // Saved now: the copy it came from (an outbox entry, a queued card) goes right after this. + updateNativeChatComposerDraft( scopeKey, - appendNativeChatDraftText(readNativeChatDraftCache(scopeKey), text) + { + text: appendNativeChatDraftText(readNativeChatDraftCache(scopeKey), text), + document: undefined + }, + 'immediate' ) - // Saved now: the copy it came from (an outbox entry, a queued card) goes right after this. - flushPersistedNativeChatDrafts() appendListeners.get(scopeKey)?.forEach((listener) => listener(text)) } @@ -98,16 +67,15 @@ export function subscribeToNativeChatDraftAppend( } export function clearNativeChatDraftCacheForTests(): void { - draftCache.clear() - clearPersistedNativeChatDraftsForTests() + clearNativeChatComposerDraftsForTests() } export function readNativeChatDraftDocument( scopeKey: string, text: string ): JSONContent | undefined { - const cached = cachedDraft(scopeKey) - return cached?.text === text ? cached.document : undefined + const draft = readNativeChatComposerDraft(scopeKey) + return draft.text === text ? draft.document : undefined } export function writeNativeChatDraftDocument( @@ -119,5 +87,5 @@ export function writeNativeChatDraftDocument( writeNativeChatDraftCache(scopeKey, '') return } - setCachedDraft(scopeKey, { text, document }) + updateNativeChatComposerDraft(scopeKey, { text, document }, 'deferred') } diff --git a/src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts deleted file mode 100644 index 77b48a6557b8..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-draft-storage.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' -import type * as DraftCache from './native-chat-draft-cache' -import type * as DraftStorage from './native-chat-draft-storage' -import type * as ComposerAttachments from './use-native-chat-composer-attachments' - -const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' - -type DraftModules = { - drafts: typeof DraftCache - attachments: typeof ComposerAttachments - storage: typeof DraftStorage -} - -/** A fresh renderer: module memory is gone, localStorage is not. */ -async function reload(): Promise { - vi.resetModules() - return { - drafts: await import('./native-chat-draft-cache'), - attachments: await import('./use-native-chat-composer-attachments'), - storage: await import('./native-chat-draft-storage') - } -} - -function storedDraftKeys(): string[] { - return Object.keys(localStorage).filter((key) => key.startsWith(DRAFT_KEY_PREFIX)) -} - -let modules: DraftModules - -// The attachment module's import graph is slow to transform cold; later reloads reuse it. -beforeAll(async () => { - await reload() -}, 300_000) - -beforeEach(async () => { - localStorage.clear() - modules = await reload() -}) - -afterEach(() => { - vi.useRealTimers() - localStorage.clear() -}) - -describe('native-chat draft storage', () => { - it('keeps a typed draft across a reload once its debounced write lands', async () => { - vi.useFakeTimers() - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'half a thought') - expect(storedDraftKeys()).toEqual([]) - vi.advanceTimersByTime(250) - - const reloaded = await reload() - expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('half a thought') - }) - - it('writes a still-debounced draft when the window goes away', async () => { - vi.useFakeTimers() - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'typed just before reload') - window.dispatchEvent(new Event('pagehide')) - - const reloaded = await reload() - expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typed just before reload') - }) - - it('saves text and images given back to the composer at once, before their other copy goes', async () => { - vi.useFakeTimers() - modules.drafts.appendNativeChatDraftCache('tab-1:pane', 'withdrawn by Stop') - modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ - { id: 'withdrawn-a-1', path: '/repo/shot.png' }, - { id: 'withdrawn-a-2', path: '/tmp/orca-paste-1.png', connectionId: 'ssh-1' } - ]) - - // No timer advanced: a crash right after the restore still keeps it. - const reloaded = await reload() - expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('withdrawn by Stop') - expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ - { id: 'withdrawn-a-1', path: '/repo/shot.png' }, - { id: 'withdrawn-a-2', path: '/tmp/orca-paste-1.png', connectionId: 'ssh-1' } - ]) - }) - - it('appends a given-back message after a draft restored from a reload', async () => { - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine') - modules.storage.flushPersistedNativeChatDrafts() - - const reloaded = await reload() - reloaded.drafts.appendNativeChatDraftCache('tab-1:pane', 'returned') - expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('mine\n\nreturned') - }) - - it('removes the saved draft as soon as it is sent or emptied', async () => { - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'about to send') - modules.storage.flushPersistedNativeChatDrafts() - modules.drafts.writeNativeChatDraftCache('tab-1:pane', '') - - expect(storedDraftKeys()).toEqual([]) - const reloaded = await reload() - expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('') - }) - - it('keeps the images when only the text is cleared', async () => { - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'caption') - modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ - { id: 'a', path: '/repo/a.png' } - ]) - modules.drafts.writeNativeChatDraftCache('tab-1:pane', '') - - const reloaded = await reload() - expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('') - expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ - { id: 'a', path: '/repo/a.png' } - ]) - }) - - it('restores the editor document with its text', async () => { - const document = { - type: 'doc', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'with /skill' }] }] - } - modules.drafts.writeNativeChatDraftDocument('tab-1:pane', 'with /skill', document) - modules.storage.flushPersistedNativeChatDrafts() - - const reloaded = await reload() - expect(reloaded.drafts.readNativeChatDraftDocument('tab-1:pane', 'with /skill')).toEqual( - document - ) - }) - - it('keeps an oversized draft in memory only, so it never crowds the outbox out of storage', async () => { - const huge = 'x'.repeat(250_000) - modules.drafts.writeNativeChatDraftCache('tab-1:pane', huge) - modules.storage.flushPersistedNativeChatDrafts() - - expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe(huge) - expect(storedDraftKeys()).toEqual([]) - }) - - it('keeps only the newest drafts and drops unreadable ones', async () => { - localStorage.setItem(`${DRAFT_KEY_PREFIX}broken`, '{not json') - const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 5 - vi.useFakeTimers() - for (let index = 0; index < total; index += 1) { - vi.setSystemTime(1_000 + index) - modules.drafts.writeNativeChatDraftCache(`scope-${index}`, `draft-${index}`) - modules.storage.flushPersistedNativeChatDrafts() - } - - const keys = storedDraftKeys() - expect(keys).toHaveLength(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) - expect(keys).not.toContain(`${DRAFT_KEY_PREFIX}broken`) - expect(keys).not.toContain(`${DRAFT_KEY_PREFIX}scope-0`) - expect(keys).toContain(`${DRAFT_KEY_PREFIX}scope-${total - 1}`) - }) - - it('keeps working from memory when storage refuses the write', async () => { - const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new DOMException('quota', 'QuotaExceededError') - }) - try { - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'still here') - expect(() => modules.storage.flushPersistedNativeChatDrafts()).not.toThrow() - expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('still here') - } finally { - setItem.mockRestore() - } - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-draft-storage.ts deleted file mode 100644 index 842a6a5d39e1..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-draft-storage.ts +++ /dev/null @@ -1,250 +0,0 @@ -// The composer's unsent message — text, its editor document and settled image refs — kept in -// localStorage per pane scope, so a reload or quit keeps what was typed or given back to it. The -// in-memory caches (native-chat-draft-cache, the attachment cache) stay the working copy; this is -// what they fall back to on a miss. - -import type { JSONContent } from '@tiptap/react' -import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' - -const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' -const PERSIST_DEBOUNCE_MS = 250 -// Why: drafts share the origin's storage quota with the outbox, which must never fail to save a -// send because a pasted log filled it; a larger draft stays in memory only. -const MAX_PERSISTED_DRAFT_CHARS = 200_000 - -export type PersistedNativeChatDraftAttachment = { - id: string - path: string - connectionId?: string -} - -export type PersistedNativeChatDraft = { - text: string - document?: JSONContent - attachments: PersistedNativeChatDraftAttachment[] -} - -const EMPTY_DRAFT: PersistedNativeChatDraft = { text: '', attachments: [] } - -const pendingDrafts = new Map() -let flushTimer: ReturnType | null = null -let flushOnHideInstalled = false - -function storageKey(scopeKey: string): string { - return `${DRAFT_KEY_PREFIX}${encodeURIComponent(scopeKey)}` -} - -function draftStorage(): Storage | null { - try { - return typeof localStorage === 'undefined' ? null : localStorage - } catch { - return null - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** Only the editor's own root node is restored; anything else falls back to the plain text. */ -function isEditorDocument(value: unknown): value is JSONContent { - return isRecord(value) && value.type === 'doc' -} - -function parseAttachment(value: unknown): PersistedNativeChatDraftAttachment | null { - if (!isRecord(value)) { - return null - } - const { id, path, connectionId } = value - if (typeof id !== 'string' || typeof path !== 'string' || path === '') { - return null - } - return { id, path, ...(typeof connectionId === 'string' ? { connectionId } : {}) } -} - -function parseStoredDraft( - raw: string | null -): (PersistedNativeChatDraft & { savedAt: number }) | null { - if (raw === null) { - return null - } - try { - const value: unknown = JSON.parse(raw) - if (!isRecord(value)) { - return null - } - const { text, document, attachments, savedAt } = value - if (typeof text !== 'string' || typeof savedAt !== 'number' || !Array.isArray(attachments)) { - return null - } - return { - text, - ...(isEditorDocument(document) ? { document } : {}), - attachments: attachments.flatMap((attachment) => parseAttachment(attachment) ?? []), - savedAt - } - } catch { - return null - } -} - -function isEmptyDraft(draft: PersistedNativeChatDraft): boolean { - return draft.text === '' && draft.attachments.length === 0 -} - -/** The serialized draft, without its document when that alone makes it too large; null when even - * the text and image refs are. */ -function serializeDraft(draft: PersistedNativeChatDraft, savedAt: number): string | null { - const withDocument = JSON.stringify({ ...draft, savedAt }) - if (withDocument.length <= MAX_PERSISTED_DRAFT_CHARS) { - return withDocument - } - const { document: _dropped, ...plain } = draft - const withoutDocument = JSON.stringify({ ...plain, savedAt }) - return withoutDocument.length <= MAX_PERSISTED_DRAFT_CHARS ? withoutDocument : null -} - -/** Keeps the newest drafts within the composer caches' own bound; the rest are for panes long - * closed. Malformed records go too. */ -function pruneStoredDrafts(storage: Storage): void { - const stored: { key: string; savedAt: number }[] = [] - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index) - if (key?.startsWith(DRAFT_KEY_PREFIX)) { - stored.push({ key, savedAt: parseStoredDraft(storage.getItem(key))?.savedAt ?? -1 }) - } - } - stored.sort((left, right) => right.savedAt - left.savedAt) - for (const [index, { key, savedAt }] of stored.entries()) { - if (savedAt < 0 || index >= NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { - storage.removeItem(key) - } - } -} - -export function flushPersistedNativeChatDrafts(): void { - if (flushTimer !== null) { - clearTimeout(flushTimer) - flushTimer = null - } - const storage = draftStorage() - if (!storage || pendingDrafts.size === 0) { - pendingDrafts.clear() - return - } - const savedAt = Date.now() - for (const [scopeKey, draft] of pendingDrafts) { - try { - const serialized = isEmptyDraft(draft) ? null : serializeDraft(draft, savedAt) - if (serialized === null) { - storage.removeItem(storageKey(scopeKey)) - } else { - storage.setItem(storageKey(scopeKey), serialized) - } - } catch { - // Over quota: the composer's memory copy still holds it for this run. - } - } - pendingDrafts.clear() - try { - pruneStoredDrafts(storage) - } catch { - // Pruning is bookkeeping; a failure leaves extra drafts for the next flush. - } -} - -function installFlushOnHide(): void { - if (flushOnHideInstalled || typeof window === 'undefined') { - return - } - flushOnHideInstalled = true - // Why: a debounced write still pending when the window reloads or closes would lose the last - // keystrokes. - window.addEventListener('pagehide', flushPersistedNativeChatDrafts) - window.addEventListener('beforeunload', flushPersistedNativeChatDrafts) - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - flushPersistedNativeChatDrafts() - } - }) -} - -export function readPersistedNativeChatDraft(scopeKey: string): PersistedNativeChatDraft | null { - const pending = pendingDrafts.get(scopeKey) - if (pending) { - return pending - } - const storage = draftStorage() - if (!storage) { - return null - } - try { - const stored = parseStoredDraft(storage.getItem(storageKey(scopeKey))) - if (!stored) { - return null - } - const { savedAt: _savedAt, ...draft } = stored - return draft - } catch { - return null - } -} - -/** - * Records one part of a scope's draft. `immediate` is for text given back to the composer (a - * Stop's withdrawn message, an edited queued card): its other copy is about to go, so it is saved - * before that. Typing is `deferred` and coalesced. - */ -export function persistNativeChatDraftPart( - scopeKey: string, - part: Partial, - mode: 'immediate' | 'deferred' -): void { - if (!draftStorage()) { - return - } - const base = readPersistedNativeChatDraft(scopeKey) ?? EMPTY_DRAFT - const next: PersistedNativeChatDraft = { ...base, ...part } - if (next.document === undefined) { - delete next.document - } - // A composer mounting or re-rendering with what is already saved writes nothing. - if (JSON.stringify(next) === JSON.stringify(base)) { - if (mode === 'immediate' && pendingDrafts.has(scopeKey)) { - flushPersistedNativeChatDrafts() - } - return - } - pendingDrafts.set(scopeKey, next) - // Clearing is immediate too, so a stored copy never outlives a sent or emptied draft. - if (mode === 'immediate' || isEmptyDraft(next)) { - flushPersistedNativeChatDrafts() - return - } - installFlushOnHide() - if (flushTimer === null) { - flushTimer = setTimeout(flushPersistedNativeChatDrafts, PERSIST_DEBOUNCE_MS) - } -} - -export function clearPersistedNativeChatDraftsForTests(): void { - if (flushTimer !== null) { - clearTimeout(flushTimer) - flushTimer = null - } - pendingDrafts.clear() - const storage = draftStorage() - if (!storage) { - return - } - const keys: string[] = [] - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index) - if (key?.startsWith(DRAFT_KEY_PREFIX)) { - keys.push(key) - } - } - for (const key of keys) { - storage.removeItem(key) - } -} 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 e984d37900dd..2f7bd83e1357 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 @@ -6,13 +6,11 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import { - clearPersistedNativeChatDraftsForTests, - flushPersistedNativeChatDrafts, - persistNativeChatDraftPart, - readPersistedNativeChatDraft -} from './native-chat-draft-storage' + clearNativeChatComposerDraftsForTests, + readNativeChatComposerDraft, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' @@ -57,19 +55,16 @@ export function useNativeChatComposerAttachments({ const [imageAttachments, setImageAttachments] = useState( () => readNativeChatAttachmentCache(attachmentScopeKey) ) + // The chips shown, so each change is computed and saved where it happens, not in a state updater. + const imageAttachmentsRef = useRef(imageAttachments) const imageAttachmentCounter = useRef(0) - // Read from the scope cache the updater below writes, never from this state, so a scope change - // can't save one pane's chips under another. - useEffect(() => { - persistNativeChatAttachmentCache(attachmentScopeKey) - }, [attachmentScopeKey, imageAttachments]) - useEffect( () => - subscribeToNativeChatAttachmentAppend(attachmentScopeKey, (appended) => - setImageAttachments((prev) => [...prev, ...appended]) - ), + subscribeToNativeChatAttachmentAppend(attachmentScopeKey, (appended) => { + imageAttachmentsRef.current = [...imageAttachmentsRef.current, ...appended] + setImageAttachments(imageAttachmentsRef.current) + }), [attachmentScopeKey] ) @@ -79,11 +74,10 @@ export function useNativeChatComposerAttachments({ previous: NativeChatComposerImageAttachment[] ) => NativeChatComposerImageAttachment[] ) => { - setImageAttachments((prev) => { - const next = updater(prev) - writeNativeChatAttachmentCache(attachmentScopeKey, next) - return next - }) + const next = updater(imageAttachmentsRef.current) + imageAttachmentsRef.current = next + setImageAttachments(next) + writeNativeChatAttachmentCache(attachmentScopeKey, next) }, [attachmentScopeKey] ) @@ -229,56 +223,28 @@ function removeAttachmentById( return attachments.filter((attachment) => attachment.id !== id) } -const attachmentCache = new Map() - export function readNativeChatAttachmentCache( scopeKey: string ): NativeChatComposerImageAttachment[] { - const cached = attachmentCache.get(scopeKey) - if (cached) { - return [...cached] - } - // Not held this run (a reload): read back the settled chips the draft saved. - const persisted = readPersistedNativeChatDraft(scopeKey)?.attachments ?? [] - if (persisted.length > 0) { - setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...persisted]) - } - return [...persisted] + return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image })) } function writeNativeChatAttachmentCache( scopeKey: string, - cacheable: readonly NativeChatComposerImageAttachment[] + attachments: readonly NativeChatComposerImageAttachment[] ): void { // A pending chip's save resolves into THIS hook instance; restoring one into a - // remount would strand it pending forever, so only settled chips are cached. - const attachments = cacheable - .filter((attachment) => !attachment.pending) - // Preview URLs can retain the full clipboard Blob (or a large data URL) for - // the lifetime of the scope cache. Settled attachments reload from their - // authorized path after a remount, so never retain the transient preview. - .map(({ previewUrl: _previewUrl, ...attachment }) => attachment) - if (attachments.length === 0) { - attachmentCache.delete(scopeKey) - return - } - // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. - setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments]) -} - -/** Saves the scope's cached chips with its draft, so a reload gives them back. */ -function persistNativeChatAttachmentCache(scopeKey: string): void { - const attachments = attachmentCache.get(scopeKey) ?? [] - persistNativeChatDraftPart( + // remount would strand it pending forever, so only settled chips are kept. + // Preview URLs can retain the full clipboard Blob (or a large data URL), so + // never keep the transient preview: settled chips reload from their path. + updateNativeChatComposerDraft( scopeKey, { - attachments: attachments.map(({ id, path, connectionId }) => ({ - id, - path, - ...(connectionId ? { connectionId } : {}) - })) + images: attachments.flatMap(({ id, path, connectionId, pending }) => + pending ? [] : [{ id, path, ...(connectionId ? { connectionId } : {}) }] + ) }, - attachments.length === 0 ? 'immediate' : 'deferred' + 'immediate' ) } @@ -296,13 +262,11 @@ export function appendNativeChatAttachmentCache( if (appended.length === 0) { return } + // Saved now: the copy it came from goes right after this. writeNativeChatAttachmentCache(scopeKey, [ ...readNativeChatAttachmentCache(scopeKey), ...appended ]) - persistNativeChatAttachmentCache(scopeKey) - // Saved now: the copy it came from goes right after this. - flushPersistedNativeChatDrafts() appendListeners.get(scopeKey)?.forEach((listener) => listener(appended)) } @@ -322,6 +286,5 @@ function subscribeToNativeChatAttachmentAppend( } export function clearNativeChatAttachmentCacheForTests(): void { - attachmentCache.clear() - clearPersistedNativeChatDraftsForTests() + clearNativeChatComposerDraftsForTests() } diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft.ts b/src/renderer/src/components/native-chat/use-native-chat-draft.ts index 5653c0a77a85..3f5dd6bd1a65 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-draft.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-draft.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { appendNativeChatDraftText, readNativeChatDraftCache, @@ -22,6 +22,11 @@ export function useNativeChatDraft( flushDraftAppends: () => void } { const [draft, setDraftState] = useState(() => readNativeChatDraftCache(scopeKey)) + // The text shown, so a change is computed and saved where it happens, not in a state updater. + const shownRef = useRef(draft) + useLayoutEffect(() => { + shownRef.current = draft + }, [draft]) // Appended while composing: the editor's own writes would erase it, so the cache keeps it after them. const pendingAppendRef = useRef<{ scopeKey: string; text: string } | null>(null) @@ -38,7 +43,8 @@ export function useNativeChatDraft( () => subscribeToNativeChatDraftAppend(scopeKey, (text) => { if (!isComposing()) { - setDraftState(readNativeChatDraftCache(scopeKey)) + shownRef.current = readNativeChatDraftCache(scopeKey) + setDraftState(shownRef.current) return } const pending = pendingAppendRef.current @@ -55,17 +61,16 @@ export function useNativeChatDraft( // forms as a useState setter so call sites are drop-in. const setDraft = useCallback( (next: string | ((previous: string) => string)) => { - setDraftState((previous) => { - const resolved = typeof next === 'function' ? next(previous) : next - const pending = pendingAppendRef.current - writeNativeChatDraftCache( - scopeKey, - pending?.scopeKey === scopeKey - ? appendNativeChatDraftText(resolved, pending.text) - : resolved - ) - return resolved - }) + const resolved = typeof next === 'function' ? next(shownRef.current) : next + shownRef.current = resolved + setDraftState(resolved) + const pending = pendingAppendRef.current + writeNativeChatDraftCache( + scopeKey, + pending?.scopeKey === scopeKey + ? appendNativeChatDraftText(resolved, pending.text) + : resolved + ) }, [scopeKey] ) diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index d87c283ad238..718c8a89271a 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -38,7 +38,18 @@ const mockApi = { // @ts-expect-error -- minimal window.api stub for the store under test globalThis.window = { api: mockApi } -import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' +import { + createTestStore, + seedStore, + makeWorktree, + makeTab, + makeTabGroup, + makeUnifiedTab +} from './store-test-helpers' +import { + readNativeChatComposerDraft, + updateNativeChatComposerDraft +} from '@/components/native-chat/native-chat-composer-draft-store' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' @@ -151,4 +162,47 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(patch.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() expect(patch.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() }) + + it('a user tab close drops the unsent composer drafts of that tab’s panes only', () => { + const store = createTestStore() + seedDrafts(store) + updateNativeChatComposerDraft(`${TAB1}:leaf-a`, { text: 'unsent' }, 'immediate') + updateNativeChatComposerDraft(`${TAB2}:leaf-a`, { text: 'kept' }, 'immediate') + + store.getState().closeTab(TAB2, { reason: 'pty-exit' }) + store.getState().closeTab(TAB1) + + expect(readNativeChatComposerDraft(`${TAB1}:leaf-a`).text).toBe('') + expect(readNativeChatComposerDraft(`${TAB2}:leaf-a`).text).toBe('kept') + }) + + it('closing a structured chat tab drops its unsent composer draft', () => { + const store = createTestStore() + const chat = makeUnifiedTab({ + id: 'chat-tab', + entityId: 'session-1', + contentType: 'agent-session', + worktreeId: WT1, + groupId: 'group-1' + }) + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' })] }, + unifiedTabsByWorktree: { [WT1]: [chat] }, + groupsByWorktree: { + [WT1]: [ + makeTabGroup({ + id: 'group-1', + worktreeId: WT1, + activeTabId: chat.id, + tabOrder: [chat.id] + }) + ] + } + }) + updateNativeChatComposerDraft('chat-tab:pane', { text: 'unsent' }, 'immediate') + + store.getState().closeUnifiedTab(chat.id) + + expect(readNativeChatComposerDraft('chat-tab:pane').text).toBe('') + }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index b9187c8b0d8d..1efd4f3e0ff2 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -18,6 +18,7 @@ import { import { structuredAgentSessionTabId } from '../../../../../shared/structured-agent-session-projection' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-owner' +import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' export function createTabsCloseActions( set: TabsSliceSet, @@ -68,6 +69,7 @@ export function createTabsCloseActions( provisional }) get().clearNativeChatLaunchDraft(structuredAgentSessionTabId(tab.entityId)) + deleteNativeChatComposerDraftsForTab(tab.id) } // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. const nextActiveTabId = diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 8227521d2ecd..23c77a520a33 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -17,6 +17,7 @@ import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { removePaneKeysByTabPrefix } from '../slices/agent-status-pane-keyed-records' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' +import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' export function createTerminalTabCloseActions( set: TerminalStoreSet, @@ -250,6 +251,10 @@ export function createTerminalTabCloseActions( if (intentReason && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { commitTerminalSurfaceClose(closingWorktreeId, { kind: 'tab', tabId }, intentReason) } + // Why only a user close: it is the explicit abandon; the drafts' count and size bounds retire the rest. + if (closeReason === 'user') { + deleteNativeChatComposerDraftsForTab(tabId) + } // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) for (const tabs of Object.values(get().unifiedTabsByWorktree)) { From 809147e4df638b4406ab95f8d7fed6283df5509b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:57:52 -0700 Subject: [PATCH 04/38] test(native-chat): returned text is stored before its images arrive --- .../native-chat/native-chat-composer-draft-store.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 11da403af24f..e60a3c7f7ad9 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -134,10 +134,11 @@ describe('native-chat composer draft store', () => { it('saves text and images given back to the composer at once, before their other copy goes', async () => { vi.useFakeTimers() + // No timer advanced: a crash right after either restore still keeps it. modules.drafts.appendNativeChatDraftCache('tab-1:pane', 'withdrawn by Stop') + expect(storedDraft('tab-1:pane')?.text).toBe('withdrawn by Stop') modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) - // No timer advanced: a crash right after the restore still keeps it. const reloaded = await reload() expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('withdrawn by Stop') expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) From 7b19d0f92f2a6cc068f0fcca99b12aa506185c4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:09:03 -0700 Subject: [PATCH 05/38] fix(native-chat): keep returned text and drop launch-seed copies across reloads A message given back from the outbox could be larger than one draft was allowed to store, so the saved draft was deleted and the returned text was gone after a reload. One draft may now use the whole draft budget, which holds the largest message a send accepts. An adopted launch link is also parked in the agent's input line. Restoring it after a reload, with no seed left to replace that copy, typed it twice, so the untouched copy is shown but not saved. A composer reused for another pane now shows that pane's images, so adding one never saves the previous pane's over them. --- ...ive-chat-composer-draft-lifecycle.test.tsx | 255 ++++++++++++++++++ .../native-chat-composer-draft-storage.ts | 5 +- .../native-chat-composer-draft-store.test.ts | 58 +++- .../native-chat-composer-draft-store.ts | 37 ++- .../native-chat/native-chat-draft-cache.ts | 9 +- .../use-native-chat-composer-attachments.ts | 11 +- .../native-chat/use-native-chat-draft.ts | 7 +- ...native-chat-launch-draft-adoption.test.tsx | 4 +- .../use-native-chat-launch-draft-adoption.ts | 5 +- 9 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx new file mode 100644 index 000000000000..da22f0fd0149 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -0,0 +1,255 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import type * as DraftHook from './use-native-chat-draft' +import type * as AttachmentsHook from './use-native-chat-composer-attachments' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) +const mocks = vi.hoisted(() => { + const launchDrafts: Record = {} + return { launchDrafts } +}) +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ nativeChatLaunchDraftByTabId: mocks.launchDrafts }), + { + getState: () => ({ + nativeChatLaunchDraftByTabId: mocks.launchDrafts, + markNativeChatLaunchDraftAdopted: (tabId: string) => { + const current = mocks.launchDrafts[tabId] + if (current) { + mocks.launchDrafts[tabId] = { ...current, adopted: true } + } + }, + clearNativeChatLaunchDraft: (tabId: string) => { + delete mocks.launchDrafts[tabId] + } + }) + } + ) +})) + +const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' + +type ComposerApi = { + draft: string + setDraft: ReturnType['setDraft'] + attachments: ReturnType +} + +let root: Root | null = null +let host: HTMLElement | null = null + +async function mount(element: React.ReactElement): Promise { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + await act(async () => root?.render(element)) +} + +async function unmount(): Promise { + await act(async () => root?.unmount()) + root = null + host?.remove() + host = null +} + +function storedDraft(scopeKey: string): unknown { + const raw = localStorage.getItem(`${DRAFT_KEY_PREFIX}${encodeURIComponent(scopeKey)}`) + return raw === null ? null : JSON.parse(raw) +} + +/** A fresh renderer: module memory is gone, localStorage is not. */ +async function loadHooks(): Promise<{ + draftHook: typeof DraftHook + attachmentsHook: typeof AttachmentsHook +}> { + vi.resetModules() + return { + draftHook: await import('./use-native-chat-draft'), + attachmentsHook: await import('./use-native-chat-composer-attachments') + } +} + +function composer( + hooks: Awaited>, + onRender: (api: ComposerApi) => void +): (props: { scopeKey: string }) => null { + return function Composer({ scopeKey }) { + const { draft, setDraft } = hooks.draftHook.useNativeChatDraft(scopeKey, () => false) + const [, setCaret] = useState(0) + const attachments = hooks.attachmentsHook.useNativeChatComposerAttachments({ + attachmentScopeKey: scopeKey, + allowWithoutTarget: true, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef: { current: null }, + setCaret, + setDraft: () => {}, + setNotice: () => {} + }) + onRender({ draft, setDraft, attachments }) + return null + } +} + +beforeEach(() => { + localStorage.clear() + mocks.launchDrafts = {} +}) + +afterEach(async () => { + await unmount() + vi.useRealTimers() + localStorage.clear() +}) + +describe('native-chat composer draft lifecycle', () => { + it('clears the saved draft when a send settles after the composer unmounted', async () => { + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => { + seen.api = next + }), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => { + seen.api?.setDraft('/goal ship it') + hooks.attachmentsHook.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'i1', path: '/tmp/orca-paste-1.png' } + ]) + }) + window.dispatchEvent(new Event('pagehide')) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: '/goal ship it' }) + const held = seen.api + + // A question prompt replaces the composer while the host accepts the command. + await unmount() + held?.setDraft('') + held?.attachments.clearImageAttachments() + + expect(storedDraft('tab-1:pane')).toBeNull() + const reloaded = await loadHooks() + const restored: { api?: ComposerApi } = {} + await mount( + createElement( + composer(reloaded, (next) => { + restored.api = next + }), + { scopeKey: 'tab-1:pane' } + ) + ) + expect(restored.api).toMatchObject({ draft: '', attachments: { imageAttachments: [] } }) + }) + + it('leaves nothing saved when a send clears a draft whose typing was still deferred', async () => { + vi.useFakeTimers() + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => { + seen.api = next + }), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => { + seen.api?.attachments.attachResolvedPaths(['/repo/a.png']) + }) + await act(async () => { + seen.api?.setDraft('hello') + }) + await act(async () => { + seen.api?.setDraft('') + seen.api?.attachments.clearImageAttachments() + }) + expect(storedDraft('tab-1:pane')).toBeNull() + + await act(async () => { + vi.advanceTimersByTime(1_000) + }) + window.dispatchEvent(new Event('pagehide')) + expect(storedDraft('tab-1:pane')).toBeNull() + }) + + it('shows the next pane’s saved chips when reused for it, and adds to them', async () => { + localStorage.setItem( + `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-2:pane')}`, + JSON.stringify({ text: 'b caption', images: [{ id: 'b1', path: '/repo/b.png' }], savedAt: 1 }) + ) + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + const Composer = composer(hooks, (next) => { + seen.api = next + }) + await mount(createElement(Composer, { scopeKey: 'tab-1:pane' })) + await act(async () => { + seen.api?.attachments.attachResolvedPaths(['/repo/a.png']) + }) + await act(async () => root?.render(createElement(Composer, { scopeKey: 'tab-2:pane' }))) + expect(seen.api).toMatchObject({ attachments: { imageAttachments: [{ id: 'b1' }] } }) + + await act(async () => { + seen.api?.attachments.attachResolvedPaths(['/repo/c.png']) + }) + expect(storedDraft('tab-2:pane')).toMatchObject({ + text: 'b caption', + images: [{ id: 'b1', path: '/repo/b.png' }, { path: '/repo/c.png' }] + }) + expect(storedDraft('tab-1:pane')).toMatchObject({ images: [{ path: '/repo/a.png' }] }) + }) + + it('does not bring back an untouched launch link after a reload, when no seed is left to replace it', async () => { + const link = 'https://github.com/o/r/issues/12' + const seed: NativeChatLaunchDraft = { + tabId: 'tab-1', + agent: 'claude', + text: link, + createdAt: 1 + } + mocks.launchDrafts['tab-1'] = seed + const hooks = await loadHooks() + const { useNativeChatLaunchDraftAdoption } = + await import('./use-native-chat-launch-draft-adoption') + let shown = '' + function Composer({ launchDraft }: { launchDraft: NativeChatLaunchDraft }): null { + const { draft, setDraft } = hooks.draftHook.useNativeChatDraft('tab-1:leaf', () => false) + useNativeChatLaunchDraftAdoption({ + terminalTabId: 'tab-1', + agent: 'claude', + launchDraft, + launchDraftResolved: false, + draft, + setDraft, + setCaret: () => {}, + ownsTabWideLaunchDraft: true + }) + shown = draft + return null + } + await mount(createElement(Composer, { launchDraft: seed })) + expect(shown).toBe(link) + window.dispatchEvent(new Event('pagehide')) + await unmount() + + mocks.launchDrafts = {} + const reloaded = await loadHooks() + const restored: { draft?: string } = {} + function Reloaded(): null { + restored.draft = reloaded.draftHook.useNativeChatDraft('tab-1:leaf', () => false).draft + return null + } + await mount(createElement(Reloaded)) + expect(restored.draft).toBe('') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index 7d8f3a40ff56..e502ae2a3004 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -8,7 +8,10 @@ const STORAGE_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' // Why: drafts share the origin's localStorage quota (Chromium: 10 MiB of UTF-16, ~5M chars) with // the outbox, which must always be able to save a send, so all drafts together keep to a fifth. const MAX_STORED_TOTAL_CHARS = 1_000_000 -const MAX_STORED_DRAFT_CHARS = 200_000 +// Why the whole budget: text given back from the outbox (up to MAX_PROMPT_BYTES of JSON per +// message) must stay saved, so one draft may push older ones out rather than go unsaved. The +// margin is for its key. +const MAX_STORED_DRAFT_CHARS = MAX_STORED_TOTAL_CHARS - 1_000 export type NativeChatComposerDraftImage = { id: string diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index e60a3c7f7ad9..618b3c607585 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -5,6 +5,7 @@ import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-sco import type * as DraftStore from './native-chat-composer-draft-store' import type * as DraftCache from './native-chat-draft-cache' import type * as ComposerAttachments from './use-native-chat-composer-attachments' +import { MAX_PROMPT_BYTES } from '../../../../shared/rpc-contract/structured-agent-session-params' import { writeOutbox } from './structured-agent-session-outbox-storage' const DRAFT_KEY_PREFIX = 'orca:nativeChatComposerDraft:v1:' @@ -184,7 +185,7 @@ describe('native-chat composer draft store', () => { it('keeps the images after a long paste is trimmed back, and after a failed write', async () => { modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) - modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'x'.repeat(250_000)) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'x'.repeat(1_000_000)) modules.store.flushNativeChatComposerDrafts() modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'short again') modules.store.flushNativeChatComposerDrafts() @@ -206,6 +207,57 @@ describe('native-chat composer draft store', () => { expect(reloadedAgain.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) }) + it('keeps a given-back message of the largest size a send allows, with what was typed before', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'my earlier typing') + modules.store.flushNativeChatComposerDrafts() + const returned = 'a line with "quotes"\n'.repeat(Math.floor((MAX_PROMPT_BYTES - 100) / 24)) + expect(JSON.stringify([{ type: 'text', text: returned }]).length).toBeLessThanOrEqual( + MAX_PROMPT_BYTES + ) + + modules.drafts.appendNativeChatDraftCache('tab-1:pane', returned) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe( + `my earlier typing\n\n${returned}` + ) + }) + + it('shows an unsaved text without storing it, and stores it once the user changes it', async () => { + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'https://example.com/issue/1', { + unsaved: true + }) + modules.drafts.writeNativeChatDraftDocument('tab-1:pane', 'https://example.com/issue/1', { + type: 'doc' + }) + modules.store.flushNativeChatComposerDrafts() + + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe( + 'https://example.com/issue/1' + ) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: '', images: IMAGES }) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'https://example.com/issue/1 please') + modules.store.flushNativeChatComposerDrafts() + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe( + 'https://example.com/issue/1 please' + ) + }) + + it('keeps the images of a draft pushed out of storage when its text is edited again', async () => { + modules.drafts.writeNativeChatDraftCache('old:pane', 'caption') + modules.attachments.appendNativeChatAttachmentCache('old:pane', IMAGES) + for (let index = 0; index < 6; index += 1) { + modules.drafts.writeNativeChatDraftCache(`tab-${index}:pane`, 'd'.repeat(190_000)) + modules.store.flushNativeChatComposerDrafts() + } + expect(storedDraft('old:pane')).toBeNull() + + modules.drafts.writeNativeChatDraftCache('old:pane', 'caption edited') + modules.store.flushNativeChatComposerDrafts() + expect(storedDraft('old:pane')).toMatchObject({ text: 'caption edited', images: IMAGES }) + }) + it('keeps a refused draft in memory and writes it on the next flush', async () => { storage.refuseWrites = true modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'still here') @@ -223,7 +275,7 @@ describe('native-chat composer draft store', () => { const document = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'y'.repeat(150_000) }] }], - attrs: { padding: 'z'.repeat(100_000) } + attrs: { padding: 'z'.repeat(1_000_000) } } modules.drafts.writeNativeChatDraftDocument('tab-1:pane', 'with a big document', document) modules.store.flushNativeChatComposerDrafts() @@ -241,7 +293,7 @@ describe('native-chat composer draft store', () => { it('keeps a draft too large to store in memory only, and never brings back its older copy', async () => { modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'older') modules.store.flushNativeChatComposerDrafts() - const huge = 'x'.repeat(250_000) + const huge = 'x'.repeat(1_000_000) modules.drafts.writeNativeChatDraftCache('tab-1:pane', huge) modules.store.flushNativeChatComposerDrafts() 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 6c983bb4d665..fedb4656e18c 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 @@ -27,11 +27,17 @@ export type NativeChatComposerDraftChange = { /** Present, even as undefined, to replace the document. */ document?: JSONContent images?: readonly NativeChatComposerDraftImage[] + /** Text shown but never saved while the draft still holds exactly it. */ + unsavedText?: string } -const EMPTY_DRAFT: NativeChatComposerDraft = { text: '', images: [] } +// Why unsavedText: an adopted launch seed is also parked in the agent's input line, and only this +// run's seed knows to replace it, so a reload must not bring the copy back. +type DraftRecord = StoredNativeChatComposerDraft & { readonly unsavedText?: string } -const records = new Map() +const EMPTY_DRAFT: DraftRecord = { text: '', images: [], savedAt: 0 } + +const records = new Map() // Scopes whose record storage does not hold yet; a refused write stays here for the next flush. const dirtyScopes = new Set() let lastSavedAt = 0 @@ -63,18 +69,22 @@ function nextSavedAt(): number { return lastSavedAt } -function writeRecord( - storage: Storage, - scopeKey: string, - record: StoredNativeChatComposerDraft -): void { - if (writeStoredNativeChatComposerDraft(storage, scopeKey, record)) { +function writeRecord(storage: Storage, scopeKey: string, record: DraftRecord): void { + const { unsavedText, ...saved } = record + if (saved.text === unsavedText && saved.images.length === 0) { + removeStoredNativeChatComposerDraft(storage, nativeChatComposerDraftStorageKey(scopeKey)) + dirtyScopes.delete(scopeKey) + return + } + const stored = + saved.text === unsavedText ? { text: '', images: saved.images, savedAt: saved.savedAt } : saved + if (writeStoredNativeChatComposerDraft(storage, scopeKey, stored)) { dirtyScopes.delete(scopeKey) } } // A draft leaving memory before its write landed is written on the way out. -function writeEvictedRecord(scopeKey: string, record: StoredNativeChatComposerDraft): void { +function writeEvictedRecord(scopeKey: string, record: DraftRecord): void { const storage = dirtyScopes.has(scopeKey) ? nativeChatComposerDraftStorage() : null if (storage) { writeRecord(storage, scopeKey, record) @@ -129,7 +139,7 @@ function installFlushOnHide(): void { } /** The scope's record, read from storage the first time this run asks for it. */ -function loadRecord(scopeKey: string): StoredNativeChatComposerDraft | undefined { +function loadRecord(scopeKey: string): DraftRecord | undefined { const held = records.get(scopeKey) if (held) { return held @@ -170,9 +180,11 @@ export function updateNativeChatComposerDraft( const text = change.text ?? current.text const document = 'document' in change ? change.document : current.document const images = change.images ?? current.images + const unsavedText = change.unsavedText ?? current.unsavedText if ( text === current.text && document === current.document && + unsavedText === current.unsavedText && sameImages(images, current.images) ) { if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { @@ -189,11 +201,12 @@ export function updateNativeChatComposerDraft( } return } - const record: StoredNativeChatComposerDraft = { + const record: DraftRecord = { text, ...(document ? { document } : {}), images: [...images], - savedAt: nextSavedAt() + savedAt: nextSavedAt(), + ...(unsavedText === undefined ? {} : { unsavedText }) } setBoundedScopeCacheEntry(records, scopeKey, record, writeEvictedRecord) dirtyScopes.add(scopeKey) 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 6cab022307e6..df1899dc0fdf 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 @@ -15,14 +15,19 @@ export function readNativeChatDraftCache(scopeKey: string): string { return readNativeChatComposerDraft(scopeKey).text } -export function writeNativeChatDraftCache(scopeKey: string, draft: string): void { +/** `unsaved` shows text this run without saving it while the draft is still exactly that text. */ +export function writeNativeChatDraftCache( + scopeKey: string, + draft: string, + options?: { unsaved?: boolean } +): void { if (readNativeChatComposerDraft(scopeKey).text === draft) { return } // Cleared at once, so a sent or emptied draft never resurfaces. updateNativeChatComposerDraft( scopeKey, - { text: draft, document: undefined }, + { text: draft, document: undefined, ...(options?.unsaved ? { unsavedText: draft } : {}) }, draft === '' ? 'immediate' : 'deferred' ) } 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 2f7bd83e1357..7b07506b313a 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 @@ -1,5 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' import { translate } from '@/i18n/i18n' import { nativeChatComposerTargetIsRemote, @@ -55,8 +55,17 @@ export function useNativeChatComposerAttachments({ const [imageAttachments, setImageAttachments] = useState( () => readNativeChatAttachmentCache(attachmentScopeKey) ) + // Reused for another pane: show that pane's chips, so a later change never saves these over them. + const [shownScopeKey, setShownScopeKey] = useState(attachmentScopeKey) + if (shownScopeKey !== attachmentScopeKey) { + setShownScopeKey(attachmentScopeKey) + setImageAttachments(readNativeChatAttachmentCache(attachmentScopeKey)) + } // The chips shown, so each change is computed and saved where it happens, not in a state updater. const imageAttachmentsRef = useRef(imageAttachments) + useLayoutEffect(() => { + imageAttachmentsRef.current = imageAttachments + }, [imageAttachments]) const imageAttachmentCounter = useRef(0) useEffect( diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft.ts b/src/renderer/src/components/native-chat/use-native-chat-draft.ts index 3f5dd6bd1a65..3328ea30cd1b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-draft.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-draft.ts @@ -17,7 +17,7 @@ export function useNativeChatDraft( isComposing: () => boolean ): { draft: string - setDraft: (next: string | ((previous: string) => string)) => void + setDraft: (next: string | ((previous: string) => string), options?: { unsaved?: boolean }) => void /** Shows text appended during an IME composition, which owns the field until it settles. */ flushDraftAppends: () => void } { @@ -60,7 +60,7 @@ export function useNativeChatDraft( // Persist every mutation through the cache. Accepts the same value/updater // forms as a useState setter so call sites are drop-in. const setDraft = useCallback( - (next: string | ((previous: string) => string)) => { + (next: string | ((previous: string) => string), options?: { unsaved?: boolean }) => { const resolved = typeof next === 'function' ? next(shownRef.current) : next shownRef.current = resolved setDraftState(resolved) @@ -69,7 +69,8 @@ export function useNativeChatDraft( scopeKey, pending?.scopeKey === scopeKey ? appendNativeChatDraftText(resolved, pending.text) - : resolved + : resolved, + options ) }, [scopeKey] diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx index 71da8b11b601..a341150ce0e5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx @@ -222,7 +222,7 @@ describe('useNativeChatLaunchDraftAdoption', () => { const { setDraft, setCaret } = setup({ launchDraft: entry }) expect(mocks.markNativeChatLaunchDraftAdopted).toHaveBeenCalledWith('tab-1') - expect(setDraft).toHaveBeenCalledWith(entry.text) + expect(setDraft).toHaveBeenCalledWith(entry.text, { unsaved: true }) expect(setCaret).toHaveBeenCalledWith(entry.text.length) expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() }) @@ -353,7 +353,7 @@ describe('launch draft adoption across a split', () => { it('still mirrors the launch draft into the owning pane composer', () => { const { setDraft } = renderPaneComposer({ ownsTabWideLaunchDraft: true }) - expect(setDraft).toHaveBeenCalledWith(SEED_TEXT) + expect(setDraft).toHaveBeenCalledWith(SEED_TEXT, { unsaved: true }) }) it('clears the adopted copy after a split once the transcript resolves the seed', () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts index 2b2e176a30b8..54d6af8319c0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts @@ -81,7 +81,7 @@ export function useNativeChatLaunchDraftAdoption(args: { launchDraft: NativeChatLaunchDraft | null | undefined launchDraftResolved: boolean draft: string - setDraft: (next: string) => void + setDraft: (next: string, options?: { unsaved?: boolean }) => void setCaret: (next: number) => void /** This pane is the tab-wide evidence's owner (`nativeChatLeafOwnsTabWideEvidence`). * Splitting drops it for every pane, so it gates pickup, never cleanup. */ @@ -128,7 +128,8 @@ export function useNativeChatLaunchDraftAdoption(args: { // declines the seed permanently instead of resurrecting it on a later clear. useAppStore.getState().markNativeChatLaunchDraftAdopted(terminalTabId) if (draft === '') { - setDraft(launchDraft.text) + // Not saved untouched: after a reload no seed is left to replace the copy in the input line. + setDraft(launchDraft.text, { unsaved: true }) setCaret(launchDraft.text.length) } }, [ From 9abc756518b7ead93df0c23db3e29372f668f0a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:12:10 -0700 Subject: [PATCH 06/38] test(native-chat): a plain 260k given-back message stays saved --- .../native-chat-composer-draft-store.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 618b3c607585..c4c456cbd017 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -222,6 +222,22 @@ describe('native-chat composer draft store', () => { ) }) + it('keeps a plain given-back message of about 260k characters', async () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'my earlier typing') + modules.store.flushNativeChatComposerDrafts() + const returned = 'y'.repeat(260_000) + expect(JSON.stringify([{ type: 'text', text: returned }]).length).toBeLessThanOrEqual( + MAX_PROMPT_BYTES + ) + + modules.drafts.appendNativeChatDraftCache('tab-1:pane', returned) + expect(storedDraft('tab-1:pane')?.text).toBe(`my earlier typing\n\n${returned}`) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe( + `my earlier typing\n\n${returned}` + ) + }) + it('shows an unsaved text without storing it, and stores it once the user changes it', async () => { modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'https://example.com/issue/1', { From ee415b328eea6e0fbb6123734cef3e0fb604fb9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:36:43 -0700 Subject: [PATCH 07/38] fix(native-chat): saved drafts drop pasted images and die with their worktree A pasted image lives in a temp file Orca wrote, and the permission to show it is held in memory only, so after a restart a restored pasted chip showed a generic icon, and once the temp file was cleaned it looked fine but the send failed after the composer cleared. Pasted images now stay in the draft for the current run only; images the user attached from their own files are still saved. Removing a worktree now deletes its terminal tabs' drafts, as closing the tab does. A paste that finished late in a composer that had already been replaced wrote that composer's old images back over a newer send. Dropping a placeholder chip no longer writes the saved draft. The pane-change reload in the images hook is removed: the composer is keyed by pane, so a pane change always mounts a new one. --- ...ive-chat-composer-draft-lifecycle.test.tsx | 66 +++++++++++++------ .../native-chat-composer-draft-store.test.ts | 25 ++++++- .../native-chat-composer-draft-store.ts | 21 ++++-- .../use-native-chat-composer-attachments.ts | 17 +++-- .../native-chat-launch-draft-teardown.test.ts | 13 ++++ .../removed-worktree-renderer-teardown.ts | 5 ++ 6 files changed, 111 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index da22f0fd0149..16cac8f44932 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -182,31 +182,55 @@ describe('native-chat composer draft lifecycle', () => { expect(storedDraft('tab-1:pane')).toBeNull() }) - it('shows the next pane’s saved chips when reused for it, and adds to them', async () => { - localStorage.setItem( - `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-2:pane')}`, - JSON.stringify({ text: 'b caption', images: [{ id: 'b1', path: '/repo/b.png' }], savedAt: 1 }) - ) + it('does not bring back a sent image when a paste from a replaced composer is dropped late', async () => { const hooks = await loadHooks() - const seen: { api?: ComposerApi } = {} - const Composer = composer(hooks, (next) => { - seen.api = next - }) - await mount(createElement(Composer, { scopeKey: 'tab-1:pane' })) + const first: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => { + first.api = next + }), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => first.api?.attachments.attachResolvedPaths(['/repo/x.png'])) + const pending: { id?: string | null } = {} await act(async () => { - seen.api?.attachments.attachResolvedPaths(['/repo/a.png']) + pending.id = first.api?.attachments.beginPendingImageAttachment('data:image/png;base64,AA') }) - await act(async () => root?.render(createElement(Composer, { scopeKey: 'tab-2:pane' }))) - expect(seen.api).toMatchObject({ attachments: { imageAttachments: [{ id: 'b1' }] } }) + expect(pending.id).toBeTruthy() + const stale = first.api + // The composer is replaced while the paste is still being saved. + await unmount() + const second: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => { + second.api = next + }), + { scopeKey: 'tab-1:pane' } + ) + ) + expect(second.api?.attachments.imageAttachments.map(({ path }) => path)).toEqual([ + '/repo/x.png' + ]) + await act(async () => second.api?.attachments.clearImageAttachments()) + await unmount() - await act(async () => { - seen.api?.attachments.attachResolvedPaths(['/repo/c.png']) - }) - expect(storedDraft('tab-2:pane')).toMatchObject({ - text: 'b caption', - images: [{ id: 'b1', path: '/repo/b.png' }, { path: '/repo/c.png' }] - }) - expect(storedDraft('tab-1:pane')).toMatchObject({ images: [{ path: '/repo/a.png' }] }) + // The paste's save fails late in the replaced composer, which drops its placeholder. + stale?.attachments.dropPendingImageAttachment(pending.id ?? '') + + expect(storedDraft('tab-1:pane')).toBeNull() + const third: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => { + third.api = next + }), + { scopeKey: 'tab-1:pane' } + ) + ) + expect(third.api?.attachments.imageAttachments).toEqual([]) }) it('does not bring back an untouched launch link after a reload, when no seed is left to replace it', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index c4c456cbd017..37697f6a8c63 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -81,7 +81,7 @@ function storedDraft(scopeKey: string): Record | null { const IMAGES = [ { id: 'a-1', path: '/repo/shot.png' }, - { id: 'a-2', path: '/tmp/orca-paste-1.png', connectionId: 'ssh-1' } + { id: 'a-2', path: '/remote/repo/diagram.png', connectionId: 'ssh-1' } ] const SKILL_DOCUMENT = { @@ -145,6 +145,29 @@ describe('native-chat composer draft store', () => { expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) }) + it('keeps a pasted image for this run only, and still saves the files the user attached', async () => { + const pasted = { id: 'p-1', path: '/var/folders/T/orca-paste-1-0f.png' } + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [pasted, IMAGES[0]]) + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'caption') + modules.store.flushNativeChatComposerDrafts() + + expect(modules.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + pasted, + IMAGES[0] + ]) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'caption', images: [IMAGES[0]] }) + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([IMAGES[0]]) + }) + + it('stores nothing for a draft that holds only a pasted image', () => { + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'p-1', path: '/tmp/orca-paste-1-0f.png', connectionId: 'ssh-1' } + ]) + + expect(storedDraftKeys()).toEqual([]) + }) + it('appends a given-back message after a draft restored from a reload', async () => { modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine') modules.store.flushNativeChatComposerDrafts() 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 fedb4656e18c..ac59423c835d 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 @@ -5,6 +5,7 @@ import type { JSONContent } from '@tiptap/react' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { isNativeChatPastedImagePath } from './native-chat-image-paste' import { clearStoredNativeChatComposerDraftsForTests, enforceStoredNativeChatComposerDraftBounds, @@ -69,16 +70,26 @@ function nextSavedAt(): number { return lastSavedAt } -function writeRecord(storage: Storage, scopeKey: string, record: DraftRecord): void { +/** What storage keeps: not an unsaved launch-seed copy, and not a pasted image, whose temp file + * and read grant need not outlive this run. Null when nothing is left. */ +function savedForm(record: DraftRecord): StoredNativeChatComposerDraft | null { const { unsavedText, ...saved } = record - if (saved.text === unsavedText && saved.images.length === 0) { + const images = saved.images.filter((image) => !isNativeChatPastedImagePath(image.path)) + const text = saved.text === unsavedText ? '' : saved.text + if (text === '' && images.length === 0) { + return null + } + return text === saved.text ? { ...saved, images } : { text, images, savedAt: saved.savedAt } +} + +function writeRecord(storage: Storage, scopeKey: string, record: DraftRecord): void { + const saved = savedForm(record) + if (!saved) { removeStoredNativeChatComposerDraft(storage, nativeChatComposerDraftStorageKey(scopeKey)) dirtyScopes.delete(scopeKey) return } - const stored = - saved.text === unsavedText ? { text: '', images: saved.images, savedAt: saved.savedAt } : saved - if (writeStoredNativeChatComposerDraft(storage, scopeKey, stored)) { + if (writeStoredNativeChatComposerDraft(storage, scopeKey, saved)) { dirtyScopes.delete(scopeKey) } } 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 7b07506b313a..8d272ca43410 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 @@ -55,12 +55,6 @@ export function useNativeChatComposerAttachments({ const [imageAttachments, setImageAttachments] = useState( () => readNativeChatAttachmentCache(attachmentScopeKey) ) - // Reused for another pane: show that pane's chips, so a later change never saves these over them. - const [shownScopeKey, setShownScopeKey] = useState(attachmentScopeKey) - if (shownScopeKey !== attachmentScopeKey) { - setShownScopeKey(attachmentScopeKey) - setImageAttachments(readNativeChatAttachmentCache(attachmentScopeKey)) - } // The chips shown, so each change is computed and saved where it happens, not in a state updater. const imageAttachmentsRef = useRef(imageAttachments) useLayoutEffect(() => { @@ -81,12 +75,15 @@ export function useNativeChatComposerAttachments({ ( updater: ( previous: NativeChatComposerImageAttachment[] - ) => NativeChatComposerImageAttachment[] + ) => NativeChatComposerImageAttachment[], + save = true ) => { const next = updater(imageAttachmentsRef.current) imageAttachmentsRef.current = next setImageAttachments(next) - writeNativeChatAttachmentCache(attachmentScopeKey, next) + if (save) { + writeNativeChatAttachmentCache(attachmentScopeKey, next) + } }, [attachmentScopeKey] ) @@ -193,7 +190,9 @@ export function useNativeChatComposerAttachments({ const dropPendingImageAttachment = useCallback( (id: string) => { - updateImageAttachments((prev) => removeAttachmentById(prev, id)) + // Why no save: a pending chip was never saved, and a late drop from an unmounted composer + // would write its stale chips over what a newer composer sent. + updateImageAttachments((prev) => removeAttachmentById(prev, id), false) }, [updateImageAttachments] ) diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index 718c8a89271a..04f41f809063 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -163,6 +163,19 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(patch.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() }) + it('removing a worktree drops the unsent composer drafts of its tabs only', async () => { + const store = createTestStore() + seedDrafts(store) + updateNativeChatComposerDraft(`${TAB1}:leaf-a`, { text: 'unsent' }, 'immediate') + updateNativeChatComposerDraft(`${TAB2}:leaf-a`, { text: 'kept' }, 'immediate') + + const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null }) + + expect(result).toEqual({ ok: true }) + expect(readNativeChatComposerDraft(`${TAB1}:leaf-a`).text).toBe('') + expect(readNativeChatComposerDraft(`${TAB2}:leaf-a`).text).toBe('kept') + }) + it('a user tab close drops the unsent composer drafts of that tab’s panes only', () => { const store = createTestStore() seedDrafts(store) diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index ab8309338c55..c5918749efa0 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -11,6 +11,7 @@ import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../../../compon import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-display-name' import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup' import { purgeOrphanedRuntimeSshProjects } from './orphaned-runtime-ssh-project-purge' +import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' /** * Renderer-side teardown after the backend removal succeeded. @@ -79,6 +80,10 @@ export async function tearDownRemovedWorktreeRendererState(args: { detachedHeadAutoDerivedDisplayNames.delete(worktreeId) forgetForegroundTerminalTabs(tabIds) forgetAgentStartupDeliveriesForTabs(tabIds) + // Structured chats were closed above, which deleted their drafts; terminal tabs skip closeTab. + for (const tabId of tabIds) { + deleteNativeChatComposerDraftsForTab(tabId) + } // Why: snapshot the sidebar top-row anchor in the same tick we remove the row; recording at click time goes stale across the await. requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') From ae0421c4c36c2d35d5c63b418ab437a5946f13df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:59:28 -0700 Subject: [PATCH 08/38] fix(native-chat): the composer renders the draft store instead of keeping a copy A composer that had been replaced, for example by a question prompt, could still finish an attach or a send later and write its own old copy of the draft over the store, so a sent message or image came back. The composer now reads text and images from the draft store and changes the draft as it is now: an added image or file reference joins what is there, and a send that settles after a round trip, or after its composer was replaced, clears the draft only if it is still what was sent. Only chips still being written and an IME composition in progress stay local to the composer. Closing a tab now deletes drafts by the exact tab id: a second chat for one session has an id that extends the first one's, so a prefix match deleted its drafts too. --- .../native-chat/NativeChatComposer.tsx | 3 +- ...ive-chat-composer-draft-lifecycle.test.tsx | 167 +++++++++++- .../native-chat-composer-draft-storage.ts | 9 +- .../native-chat-composer-draft-store.test.ts | 18 ++ .../native-chat-composer-draft-store.ts | 57 ++++- .../native-chat/native-chat-draft-cache.ts | 14 +- ...tructured-session-takeover-report.test.tsx | 1 + .../use-native-chat-composer-attachments.ts | 242 +++++++++--------- .../use-native-chat-composer-submit.test.tsx | 52 ++-- .../use-native-chat-composer-submit.ts | 20 +- .../native-chat/use-native-chat-draft.ts | 95 +++---- ...ive-chat-structured-composer-send.test.tsx | 2 +- ...se-native-chat-structured-composer-send.ts | 39 +-- 13 files changed, 482 insertions(+), 237 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..26612e6ae492 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -250,7 +250,7 @@ const NativeChatComposerPane = forwardRef ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('@/lib/worker-terminal-takeover-report', () => ({ + reportStructuredSessionUserInput: vi.fn() +})) const mocks = vi.hoisted(() => { const launchDrafts: Record = {} return { launchDrafts } @@ -39,6 +45,43 @@ type ComposerApi = { draft: string setDraft: ReturnType['setDraft'] attachments: ReturnType + send: (text: string) => void +} + +type Dispatched = { handled: boolean; accepted: boolean; error: string | null } + +/** A transport whose next send settles only when the test says so. */ +function heldTransport(): { + transport: NativeChatStructuredComposerTransport + settle: () => Promise +} { + let resolve: (value: Dispatched) => void = () => {} + const dispatched = new Promise((settle) => { + resolve = settle + }) + return { + transport: { + send: vi.fn(() => true), + dispatchCommand: vi.fn(() => dispatched), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: 'session-test', + runtimeEnvironmentId: null + }, + settle: async () => { + await act(async () => { + resolve({ handled: false, accepted: false, error: null }) + await dispatched + }) + } + } } let root: Root | null = null @@ -67,17 +110,20 @@ function storedDraft(scopeKey: string): unknown { async function loadHooks(): Promise<{ draftHook: typeof DraftHook attachmentsHook: typeof AttachmentsHook + sendHook: typeof SendHook }> { vi.resetModules() return { draftHook: await import('./use-native-chat-draft'), - attachmentsHook: await import('./use-native-chat-composer-attachments') + attachmentsHook: await import('./use-native-chat-composer-attachments'), + sendHook: await import('./use-native-chat-structured-composer-send') } } function composer( hooks: Awaited>, - onRender: (api: ComposerApi) => void + onRender: (api: ComposerApi) => void, + transport?: NativeChatStructuredComposerTransport ): (props: { scopeKey: string }) => null { return function Composer({ scopeKey }) { const { draft, setDraft } = hooks.draftHook.useNativeChatDraft(scopeKey, () => false) @@ -94,7 +140,18 @@ function composer( setDraft: () => {}, setNotice: () => {} }) - onRender({ draft, setDraft, attachments }) + const send = hooks.sendHook.useNativeChatStructuredComposerSend({ + agent: 'claude', + draftScopeKey: scopeKey, + imageAttachments: attachments.imageAttachments, + structuredTransport: transport, + clearImageAttachments: attachments.clearImageAttachments, + clearSkillOrigin: () => {}, + setHistory: () => {}, + setDraft, + setCaret + }) + onRender({ draft, setDraft, attachments, send }) return null } } @@ -107,6 +164,8 @@ beforeEach(() => { afterEach(async () => { await unmount() vi.useRealTimers() + // Writes still deferred land now, not in the next test's storage. + window.dispatchEvent(new Event('pagehide')) localStorage.clear() }) @@ -151,6 +210,52 @@ describe('native-chat composer draft lifecycle', () => { expect(restored.api).toMatchObject({ draft: '', attachments: { imageAttachments: [] } }) }) + it('clears a sent draft when its send settles after the composer was replaced', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const first: { api?: ComposerApi } = {} + const Composer = composer(hooks, (next) => (first.api = next), held.transport) + await mount(createElement(Composer, { scopeKey: 'tab-1:pane' })) + await act(async () => first.api?.setDraft('/goal ship it')) + await act(async () => first.api?.send('/goal ship it')) + await unmount() + + await held.settle() + + expect(storedDraft('tab-1:pane')).toBeNull() + }) + + it('keeps what was typed in a replacement composer when the old composer’s send settles', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const first: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (first.api = next), held.transport), + { + scopeKey: 'tab-1:pane' + } + ) + ) + await act(async () => first.api?.setDraft('sent text')) + await act(async () => first.api?.send('sent text')) + await unmount() + const second: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (second.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => second.api?.setDraft('new text')) + + await held.settle() + + expect(second.api?.draft).toBe('new text') + window.dispatchEvent(new Event('pagehide')) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'new text' }) + }) + it('leaves nothing saved when a send clears a draft whose typing was still deferred', async () => { vi.useFakeTimers() const hooks = await loadHooks() @@ -233,6 +338,62 @@ describe('native-chat composer draft lifecycle', () => { expect(third.api?.attachments.imageAttachments).toEqual([]) }) + it('adds a late image from a replaced composer to the current draft, not to its old one', async () => { + const hooks = await loadHooks() + const first: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (first.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => first.api?.attachments.attachResolvedPaths(['/repo/x.png'])) + const stale = first.api + await unmount() + const second: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (second.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => second.api?.attachments.clearImageAttachments()) + await unmount() + + // An SSH upload started in the first composer finishes now. + stale?.attachments.attachResolvedPaths(['/repo/late.png']) + + expect(storedDraft('tab-1:pane')).toMatchObject({ images: [{ path: '/repo/late.png' }] }) + }) + + it('inserts a late file reference from a replaced composer into the current text', async () => { + const hooks = await loadHooks() + const first: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (first.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => first.api?.setDraft('already sent text')) + const stale = first.api + await unmount() + const second: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (second.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => second.api?.setDraft('')) + await unmount() + + stale?.setDraft((previous) => `${previous}@late.txt `) + window.dispatchEvent(new Event('pagehide')) + + expect(storedDraft('tab-1:pane')).toMatchObject({ text: '@late.txt ' }) + }) + it('does not bring back an untouched launch link after a reload, when no seed is left to replace it', async () => { const link = 'https://github.com/o/r/issues/12' const seed: NativeChatLaunchDraft = { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index e502ae2a3004..bc9d50c3a658 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -217,15 +217,14 @@ export function enforceStoredNativeChatComposerDraftBounds(storage: Storage): vo } } -/** Removes every stored draft whose scope starts with `scopePrefix`. */ -export function removeStoredNativeChatComposerDraftsByScopePrefix( +/** Removes every stored draft whose scope matches. */ +export function removeStoredNativeChatComposerDraftsWhere( storage: Storage, - scopePrefix: string + matches: (scopeKey: string) => boolean ): void { try { - const keyPrefix = nativeChatComposerDraftStorageKey(scopePrefix) for (const key of storedDraftIndex(storage).keys()) { - if (key.startsWith(keyPrefix)) { + if (matches(decodeURIComponent(key.slice(STORAGE_KEY_PREFIX.length)))) { removeStoredNativeChatComposerDraft(storage, key) } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 37697f6a8c63..c9fedf264e31 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -417,4 +417,22 @@ describe('native-chat composer draft store', () => { expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane-b')).toBe('') expect(reloaded.drafts.readNativeChatDraftCache('tab-10:pane')).toBe('other tab') }) + + it('keeps the drafts of a tab whose id extends the closed one', async () => { + // A second chat for one session gets `:history-1`, so a prefix match would reach it. + const closed = 'structured-agent-session-claude_1' + const kept = `${closed}:history-1` + modules.drafts.writeNativeChatDraftCache(`${closed}:0a1b2c3d-0000-4000-a000-000000000001`, 'a') + modules.drafts.writeNativeChatDraftCache(`${kept}:0a1b2c3d-0000-4000-a000-000000000002`, 'b') + modules.store.flushNativeChatComposerDrafts() + + modules.store.deleteNativeChatComposerDraftsForTab(closed) + const reloaded = await reload() + expect( + reloaded.drafts.readNativeChatDraftCache(`${closed}:0a1b2c3d-0000-4000-a000-000000000001`) + ).toBe('') + expect( + reloaded.drafts.readNativeChatDraftCache(`${kept}:0a1b2c3d-0000-4000-a000-000000000002`) + ).toBe('b') + }) }) 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 ac59423c835d..045f99d8a01a 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 @@ -14,7 +14,7 @@ import { nativeChatComposerDraftStorageKey, parseStoredNativeChatComposerDraft, removeStoredNativeChatComposerDraft, - removeStoredNativeChatComposerDraftsByScopePrefix, + removeStoredNativeChatComposerDraftsWhere, writeStoredNativeChatComposerDraft, type NativeChatComposerDraft, type NativeChatComposerDraftImage, @@ -44,6 +44,27 @@ const dirtyScopes = new Set() let lastSavedAt = 0 let flushTimer: ReturnType | null = null let flushOnHideInstalled = false +const scopeListeners = new Map void>>() + +function notifyScope(scopeKey: string): void { + scopeListeners.get(scopeKey)?.forEach((listener) => listener()) +} + +/** Composers render from the store; this tells one that its pane's draft changed. */ +export function subscribeToNativeChatComposerDraft( + scopeKey: string, + listener: () => void +): () => void { + const listeners = scopeListeners.get(scopeKey) ?? new Set() + scopeListeners.set(scopeKey, listeners) + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0 && scopeListeners.get(scopeKey) === listeners) { + scopeListeners.delete(scopeKey) + } + } +} function isEmptyDraft(draft: NativeChatComposerDraft): boolean { return draft.text === '' && draft.images.length === 0 @@ -210,6 +231,7 @@ export function updateNativeChatComposerDraft( if (storage) { removeStoredNativeChatComposerDraft(storage, nativeChatComposerDraftStorageKey(scopeKey)) } + notifyScope(scopeKey) return } const record: DraftRecord = { @@ -222,6 +244,7 @@ export function updateNativeChatComposerDraft( setBoundedScopeCacheEntry(records, scopeKey, record, writeEvictedRecord) dirtyScopes.add(scopeKey) installFlushOnHide() + notifyScope(scopeKey) if (persist === 'immediate') { flushNativeChatComposerDrafts() return @@ -229,18 +252,44 @@ export function updateNativeChatComposerDraft( flushTimer ??= setTimeout(flushNativeChatComposerDrafts, PERSIST_DEBOUNCE_MS) } +/** + * Clears a sent draft only while it is still what was sent, so a send settling late never wipes + * what was typed or attached since, even from a composer that was replaced meanwhile. + */ +export function clearNativeChatComposerDraftIfUnchanged( + scopeKey: string, + sent: NativeChatComposerDraft +): boolean { + const current = readNativeChatComposerDraft(scopeKey) + if (current.text !== sent.text || !sameImages(current.images, sent.images)) { + return false + } + updateNativeChatComposerDraft(scopeKey, { text: '', images: [] }, 'immediate') + return true +} + +/** A pane key is `:`; the leaf is a UUID, while a tab id may hold ':' itself. */ +function scopeTabId(scopeKey: string): string { + return scopeKey.slice(0, scopeKey.lastIndexOf(':')) +} + /** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. */ export function deleteNativeChatComposerDraftsForTab(tabId: string): void { - const scopePrefix = `${tabId}:` + const inTab = (scopeKey: string): boolean => scopeTabId(scopeKey) === tabId for (const scopeKey of records.keys()) { - if (scopeKey.startsWith(scopePrefix)) { + if (inTab(scopeKey)) { records.delete(scopeKey) dirtyScopes.delete(scopeKey) } } const storage = nativeChatComposerDraftStorage() if (storage) { - removeStoredNativeChatComposerDraftsByScopePrefix(storage, scopePrefix) + removeStoredNativeChatComposerDraftsWhere(storage, inTab) + } + for (const scopeKey of scopeListeners.keys()) { + if (inTab(scopeKey)) { + notifyScope(scopeKey) + } } } 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 df1899dc0fdf..31507d023ced 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 @@ -36,29 +36,27 @@ export function appendNativeChatDraftText(draft: string, text: string): string { return draft === '' ? text : `${draft.trimEnd()}\n\n${text}` } -// Only a write from outside the composer notifies; its own writes already hold the text. -const appendListeners = new Map void>>() +// Why: a composer mid-IME-composition keeps showing what it had, so it is told what was appended. +const appendListeners = new Map void>>() /** Puts text back after whatever is typed, and tells a mounted composer to show it. */ export function appendNativeChatDraftCache(scopeKey: string, text: string): void { if (text === '') { return } + const previous = readNativeChatDraftCache(scopeKey) // Saved now: the copy it came from (an outbox entry, a queued card) goes right after this. updateNativeChatComposerDraft( scopeKey, - { - text: appendNativeChatDraftText(readNativeChatDraftCache(scopeKey), text), - document: undefined - }, + { text: appendNativeChatDraftText(previous, text), document: undefined }, 'immediate' ) - appendListeners.get(scopeKey)?.forEach((listener) => listener(text)) + appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous)) } export function subscribeToNativeChatDraftAppend( scopeKey: string, - listener: (text: string) => void + listener: (text: string, previous: string) => void ): () => void { const listeners = appendListeners.get(scopeKey) ?? new Set() appendListeners.set(scopeKey, listeners) diff --git a/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx index b44df6c8304c..4dc6a68b711e 100644 --- a/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx +++ b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx @@ -50,6 +50,7 @@ function send(structuredTransport: NativeChatStructuredComposerTransport): (text const { result } = renderHook(() => useNativeChatStructuredComposerSend({ agent: 'claude', + draftScopeKey: 'tab-1:pane', imageAttachments: [], structuredTransport, clearImageAttachments: vi.fn(), 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 8d272ca43410..7a1dd6080d4c 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 @@ -1,5 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { useCallback, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from 'react' import { translate } from '@/i18n/i18n' import { nativeChatComposerTargetIsRemote, @@ -9,6 +9,7 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { clearNativeChatComposerDraftsForTests, readNativeChatComposerDraft, + subscribeToNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' @@ -52,41 +53,33 @@ export function useNativeChatComposerAttachments({ resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void dropPendingImageAttachment: (id: string) => void } { - const [imageAttachments, setImageAttachments] = useState( - () => readNativeChatAttachmentCache(attachmentScopeKey) - ) - // The chips shown, so each change is computed and saved where it happens, not in a state updater. - const imageAttachmentsRef = useRef(imageAttachments) - useLayoutEffect(() => { - imageAttachmentsRef.current = imageAttachments - }, [imageAttachments]) - const imageAttachmentCounter = useRef(0) - - useEffect( - () => - subscribeToNativeChatAttachmentAppend(attachmentScopeKey, (appended) => { - imageAttachmentsRef.current = [...imageAttachmentsRef.current, ...appended] - setImageAttachments(imageAttachmentsRef.current) - }), + const subscribe = useCallback( + (listener: () => void) => subscribeToNativeChatComposerDraft(attachmentScopeKey, listener), [attachmentScopeKey] ) - - const updateImageAttachments = useCallback( - ( - updater: ( - previous: NativeChatComposerImageAttachment[] - ) => NativeChatComposerImageAttachment[], - save = true - ) => { - const next = updater(imageAttachmentsRef.current) - imageAttachmentsRef.current = next - setImageAttachments(next) - if (save) { - writeNativeChatAttachmentCache(attachmentScopeKey, next) - } - }, - [attachmentScopeKey] + const settled = useSyncExternalStore( + subscribe, + () => readNativeChatComposerDraft(attachmentScopeKey).images + ) + // Chips still being written, and the clipboard previews this composer minted, are its own. + const [local, setLocal] = useState(NO_LOCAL_ATTACHMENTS) + // Read by callbacks between renders; only they change it, always together with the state. + const localRef = useRef(local) + const updateLocal = useCallback((next: LocalAttachments) => { + localRef.current = next + setLocal(next) + }, []) + const imageAttachments = useMemo( + () => [ + ...settled.map((image) => { + const previewUrl = local.previews.get(image.id) + return previewUrl ? { ...image, previewUrl } : image + }), + ...local.pending + ], + [local, settled] ) + const imageAttachmentCounter = useRef(0) const nextAttachmentId = useCallback((): string => { imageAttachmentCounter.current += 1 @@ -117,19 +110,16 @@ export function useNativeChatComposerAttachments({ const appendImageAttachments = useCallback( (paths: { path: string; connectionId?: string | null }[]) => { - if (paths.length === 0) { - return - } - updateImageAttachments((prev) => [ - ...prev, - ...paths.map(({ path, connectionId }) => ({ + appendNativeChatAttachmentCache( + attachmentScopeKey, + paths.map(({ path, connectionId }) => ({ id: nextAttachmentId(), path, - connectionId: connectionId ?? undefined + ...(connectionId ? { connectionId } : {}) })) - ]) + ) }, - [nextAttachmentId, updateImageAttachments] + [attachmentScopeKey, nextAttachmentId] ) const { attachResolvedPaths, disabledRef, flushPendingAttachments } = @@ -158,7 +148,11 @@ export function useNativeChatComposerAttachments({ return null } const id = nextAttachmentId() - updateImageAttachments((prev) => [...prev, { id, path: '', previewUrl, pending: true }]) + const current = localRef.current + updateLocal({ + ...current, + pending: [...current.pending, { id, path: '', previewUrl, pending: true }] + }) return id }, [ @@ -166,69 +160,95 @@ export function useNativeChatComposerAttachments({ disabledRef, nextAttachmentId, noteAttachmentTargetBlocked, - updateImageAttachments + updateLocal ] ) + /** Drops this composer's own copy of a chip; the store is not touched. */ + const forgetLocalAttachment = useCallback( + (id: string, keepPreview = false): NativeChatComposerImageAttachment | undefined => { + const current = localRef.current + const pending = current.pending.find((attachment) => attachment.id === id) + const previewUrl = pending?.previewUrl ?? current.previews.get(id) + if (!pending && previewUrl === undefined) { + return undefined + } + const previews = new Map(current.previews) + previews.delete(id) + if (keepPreview && previewUrl) { + previews.set(id, previewUrl) + } else { + releasePreviewUrl(previewUrl) + } + updateLocal({ + pending: current.pending.filter((attachment) => attachment.id !== id), + previews + }) + return pending + }, + [updateLocal] + ) + const resolvePendingImageAttachment = useCallback( (id: string, path: string, connectionId?: string | null) => { - updateImageAttachments((prev) => - prev.map((attachment) => - attachment.id === id - ? { - ...attachment, - path, - connectionId: connectionId ?? undefined, - pending: undefined - } - : attachment - ) - ) + if (forgetLocalAttachment(id, true)) { + appendNativeChatAttachmentCache(attachmentScopeKey, [ + { id, path, ...(connectionId ? { connectionId } : {}) } + ]) + } }, - [updateImageAttachments] + [attachmentScopeKey, forgetLocalAttachment] ) + // A pending chip was never saved, so dropping one, even late from a replaced composer, leaves + // the store alone. const dropPendingImageAttachment = useCallback( (id: string) => { - // Why no save: a pending chip was never saved, and a late drop from an unmounted composer - // would write its stale chips over what a newer composer sent. - updateImageAttachments((prev) => removeAttachmentById(prev, id), false) + forgetLocalAttachment(id) }, - [updateImageAttachments] + [forgetLocalAttachment] ) return { imageAttachments, attachResolvedPaths, - clearImageAttachments: () => - updateImageAttachments((prev) => { - prev.forEach(releaseAttachmentPreview) - return [] - }), + clearImageAttachments: () => { + const current = localRef.current + current.pending.forEach((attachment) => releasePreviewUrl(attachment.previewUrl)) + current.previews.forEach(releasePreviewUrl) + updateLocal(NO_LOCAL_ATTACHMENTS) + updateNativeChatComposerDraft(attachmentScopeKey, { images: [] }, 'immediate') + }, flushPendingAttachments, - removeImageAttachment: (id) => updateImageAttachments((prev) => removeAttachmentById(prev, id)), + removeImageAttachment: (id) => { + if (forgetLocalAttachment(id)) { + return + } + const images = readNativeChatComposerDraft(attachmentScopeKey).images + updateNativeChatComposerDraft( + attachmentScopeKey, + { images: images.filter((image) => image.id !== id) }, + 'immediate' + ) + }, beginPendingImageAttachment, resolvePendingImageAttachment, dropPendingImageAttachment } } -/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ -function releaseAttachmentPreview(attachment: NativeChatComposerImageAttachment): void { - if (attachment.previewUrl?.startsWith('blob:')) { - URL.revokeObjectURL(attachment.previewUrl) - } +type LocalAttachments = { + pending: NativeChatComposerImageAttachment[] + previews: ReadonlyMap } -function removeAttachmentById( - attachments: readonly NativeChatComposerImageAttachment[], - id: string -): NativeChatComposerImageAttachment[] { - const removed = attachments.find((attachment) => attachment.id === id) - if (removed) { - releaseAttachmentPreview(removed) +const NO_LOCAL_ATTACHMENTS: LocalAttachments = { pending: [], previews: new Map() } + +/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ +function releasePreviewUrl(previewUrl: string | undefined): void { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl) } - return attachments.filter((attachment) => attachment.id !== id) } export function readNativeChatAttachmentCache( @@ -237,62 +257,32 @@ export function readNativeChatAttachmentCache( return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image })) } -function writeNativeChatAttachmentCache( +/** Adds settled images after the ones the draft holds now. Saved at once: when Stop gives them + * back, the copy they came from goes right after this. */ +export function appendNativeChatAttachmentCache( scopeKey: string, - attachments: readonly NativeChatComposerImageAttachment[] + appended: readonly NativeChatComposerImageAttachment[] ): void { - // A pending chip's save resolves into THIS hook instance; restoring one into a - // remount would strand it pending forever, so only settled chips are kept. - // Preview URLs can retain the full clipboard Blob (or a large data URL), so - // never keep the transient preview: settled chips reload from their path. + if (appended.length === 0) { + return + } + // Preview URLs can retain the full clipboard Blob, so only the path is kept. updateNativeChatComposerDraft( scopeKey, { - images: attachments.flatMap(({ id, path, connectionId, pending }) => - pending ? [] : [{ id, path, ...(connectionId ? { connectionId } : {}) }] - ) + images: [ + ...readNativeChatComposerDraft(scopeKey).images, + ...appended.map(({ id, path, connectionId }) => ({ + id, + path, + ...(connectionId ? { connectionId } : {}) + })) + ] }, 'immediate' ) } -// Only a write from outside the composer notifies; its own writes already hold the chips. -const appendListeners = new Map< - string, - Set<(appended: readonly NativeChatComposerImageAttachment[]) => void> ->() - -/** Puts settled images back after whatever is attached, and shows them in a mounted composer. */ -export function appendNativeChatAttachmentCache( - scopeKey: string, - appended: readonly NativeChatComposerImageAttachment[] -): void { - if (appended.length === 0) { - return - } - // Saved now: the copy it came from goes right after this. - writeNativeChatAttachmentCache(scopeKey, [ - ...readNativeChatAttachmentCache(scopeKey), - ...appended - ]) - appendListeners.get(scopeKey)?.forEach((listener) => listener(appended)) -} - -function subscribeToNativeChatAttachmentAppend( - scopeKey: string, - listener: (appended: readonly NativeChatComposerImageAttachment[]) => void -): () => void { - const listeners = appendListeners.get(scopeKey) ?? new Set() - appendListeners.set(scopeKey, listeners) - listeners.add(listener) - return () => { - listeners.delete(listener) - if (listeners.size === 0 && appendListeners.get(scopeKey) === listeners) { - appendListeners.delete(scopeKey) - } - } -} - export function clearNativeChatAttachmentCacheForTests(): void { clearNativeChatComposerDraftsForTests() } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx index bbbf99d2a5d2..fe21c0b58f4a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -1,10 +1,21 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatPickerItem } from './native-chat-picker-items' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { useNativeChatComposerSubmit } from './use-native-chat-composer-submit' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache, + writeNativeChatDraftCache +} from './native-chat-draft-cache' + +const SCOPE = 'tab-1:pane' + +afterEach(() => { + clearNativeChatDraftCacheForTests() +}) const GOAL_ITEM: NativeChatPickerItem = { kind: 'command', @@ -41,10 +52,12 @@ function harness(options: { setCaret: vi.fn(), setHistory: vi.fn() } + writeNativeChatDraftCache(SCOPE, options.draft) const hook = renderHook( (props: { draft: string; caret: number }) => useNativeChatComposerSubmit({ structuredTransport: options.lane === 'pty' ? undefined : structuredTransport, + draftScopeKey: SCOPE, draft: props.draft, caret: props.caret, imageAttachments: options.imageAttachments ?? [], @@ -53,7 +66,12 @@ function harness(options: { }), { initialProps: { draft: options.draft, caret: options.caret ?? options.draft.length } } ) - return { hook, calls, onError } + /** The user's typing: the draft store and the rendered draft move together. */ + const type = (draft: string, caret: number): void => { + writeNativeChatDraftCache(SCOPE, draft) + hook.rerender({ draft, caret }) + } + return { hook, calls, onError, type } } describe('composer goal mode', () => { @@ -86,24 +104,24 @@ describe('composer goal mode', () => { it('sets the draft as the goal instead of sending it, then leaves goal mode', async () => { const setObjective = vi.fn(async () => true) - const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } }) + const { hook, calls, type } = harness({ draft: '/go', threadGoal: { setObjective } }) act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) - hook.rerender({ draft: ' Ship the parser ', caret: 0 }) + type(' Ship the parser ', 0) await act(async () => hook.result.current.send()) expect(setObjective).toHaveBeenCalledWith('Ship the parser') expect(calls.sendStructured).not.toHaveBeenCalled() - expect(calls.setDraft).toHaveBeenLastCalledWith('') + expect(readNativeChatDraftCache(SCOPE)).toBe('') expect(hook.result.current.goalMode.active).toBe(false) }) it('keeps the draft and goal mode when the goal is refused', async () => { const setObjective = vi.fn(async () => false) - const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } }) + const { hook, calls, type } = harness({ draft: '/go', threadGoal: { setObjective } }) act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) calls.setDraft.mockClear() - hook.rerender({ draft: 'Ship the parser', caret: 0 }) + type('Ship the parser', 0) await act(async () => hook.result.current.send()) @@ -114,13 +132,13 @@ describe('composer goal mode', () => { it('refuses attachments in goal mode rather than dropping them', () => { const setObjective = vi.fn(async () => true) - const { hook, onError } = harness({ + const { hook, onError, type } = harness({ draft: '/go', threadGoal: { setObjective }, imageAttachments: [{ id: 'a1', path: '/tmp/shot.png' }] }) act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) - hook.rerender({ draft: 'Ship the parser', caret: 0 }) + type('Ship the parser', 0) act(() => hook.result.current.send()) @@ -147,10 +165,10 @@ describe('composer goal mode', () => { it('keeps a bare /goal typed inside goal mode as the entrance, not the objective', () => { const setObjective = vi.fn(async () => true) - const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } }) + const { hook, calls, type } = harness({ draft: '/go', threadGoal: { setObjective } }) act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) calls.setDraft.mockClear() - hook.rerender({ draft: '/goal', caret: 5 }) + type('/goal', 5) act(() => hook.result.current.send()) @@ -161,9 +179,9 @@ describe('composer goal mode', () => { it('sets the objective a /goal typed inside goal mode names, not the literal command', async () => { const setObjective = vi.fn(async () => true) - const { hook } = harness({ draft: '/go', threadGoal: { setObjective } }) + const { hook, type } = harness({ draft: '/go', threadGoal: { setObjective } }) act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) - hook.rerender({ draft: '/goal fix the parser ', caret: 0 }) + type('/goal fix the parser ', 0) await act(async () => hook.result.current.send()) @@ -174,18 +192,18 @@ describe('composer goal mode', () => { it('keeps a draft edited while the goal was in flight, and stays in goal mode', async () => { let settle: (accepted: boolean) => void = () => undefined const setObjective = vi.fn(() => new Promise((resolve) => (settle = resolve))) - const { hook, calls } = harness({ draft: '/go', threadGoal: { setObjective } }) + const { hook, calls, type } = harness({ draft: '/go', threadGoal: { setObjective } }) act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) - hook.rerender({ draft: 'Ship the parser', caret: 0 }) + type('Ship the parser', 0) calls.setDraft.mockClear() act(() => hook.result.current.send()) - hook.rerender({ draft: 'Ship the parser and its tests', caret: 0 }) + type('Ship the parser and its tests', 0) await act(async () => settle(true)) expect(setObjective).toHaveBeenCalledWith('Ship the parser') expect(calls.setHistory).toHaveBeenCalledOnce() - expect(calls.setDraft).not.toHaveBeenCalled() + expect(readNativeChatDraftCache(SCOPE)).toBe('Ship the parser and its tests') expect(hook.result.current.goalMode.active).toBe(true) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts index 7bf8ea7f8705..159fc3cfae2d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -1,9 +1,13 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { translate } from '@/i18n/i18n' import { applyPickerSuggestion, type NativeChatPickerItem } from './native-chat-picker-items' import { pushHistory, type HistoryState } from './native-chat-composer-state' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { + clearNativeChatComposerDraftIfUnchanged, + readNativeChatComposerDraft +} from './native-chat-composer-draft-store' import { isBareStructuredAgentSessionGoalCommand, structuredAgentSessionGoalObjective @@ -28,6 +32,7 @@ export type NativeChatComposerGoalMode = { */ export function useNativeChatComposerSubmit(args: { structuredTransport?: NativeChatStructuredComposerTransport + draftScopeKey: string draft: string caret: number imageAttachments: readonly NativeChatComposerImageAttachment[] @@ -38,7 +43,7 @@ export function useNativeChatComposerSubmit(args: { setCaret: (caret: number) => void setHistory: (updater: (previous: HistoryState) => HistoryState) => void }): { send: () => void; goalMode: NativeChatComposerGoalMode } { - const { caret, disabled, draft, imageAttachments, sendPty, sendStructured } = args + const { caret, disabled, draft, draftScopeKey, imageAttachments, sendPty, sendStructured } = args const { setCaret, setDraft, setHistory, structuredTransport } = args const threadGoal = structuredTransport?.threadGoal const [entered, setEntered] = useState(false) @@ -63,11 +68,6 @@ export function useNativeChatComposerSubmit(args: { // Setting a goal is a host round trip; the draft is cleared only if it is still // the one that was submitted, as with any other host command. - const composition = useRef(draft) - useLayoutEffect(() => { - composition.current = draft - }, [draft]) - // In-flight changes are serialized by the session's goal controller, which // answers false to a second submit while the first is unsettled. const setGoal = useCallback(() => { @@ -84,24 +84,24 @@ export function useNativeChatComposerSubmit(args: { ) return } + const submitted = readNativeChatComposerDraft(draftScopeKey) void threadGoal.setObjective(objective).then((accepted) => { if (!accepted) { return } structuredTransport.onError(null) setHistory((previous) => pushHistory(previous, draft)) - if (composition.current !== draft) { + if (!clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) { return } - setDraft('') setCaret(0) setEntered(false) }) }, [ draft, + draftScopeKey, imageAttachments.length, setCaret, - setDraft, setHistory, structuredTransport, threadGoal diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft.ts b/src/renderer/src/components/native-chat/use-native-chat-draft.ts index 3328ea30cd1b..6a5518720b38 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-draft.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-draft.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { subscribeToNativeChatComposerDraft } from './native-chat-composer-draft-store' import { appendNativeChatDraftText, readNativeChatDraftCache, @@ -6,11 +7,14 @@ import { writeNativeChatDraftCache } from './native-chat-draft-cache' +/** Text appended mid-IME-composition: saved at once, shown once the composition settles. */ +type CompositionHold = { scopeKey: string; shown: string; appended: string } + /** - * Composer draft state backed by the scope cache so a typed-but-unsent message - * survives the composer unmounting on a TUI/GUI toggle. `scopeKey` is the stable - * pane key also used for image attachments; when it changes (the composer is - * reused for a different pane) the cached draft is reloaded. + * The composer's draft text, read from the draft store so a typed-but-unsent message survives + * the composer unmounting on a TUI/GUI toggle, a reload or a quit. Every change is made to the + * store's current draft, never to a copy this composer holds. `scopeKey` is the stable pane key + * also used for image attachments. */ export function useNativeChatDraft( scopeKey: string, @@ -21,68 +25,65 @@ export function useNativeChatDraft( /** Shows text appended during an IME composition, which owns the field until it settles. */ flushDraftAppends: () => void } { - const [draft, setDraftState] = useState(() => readNativeChatDraftCache(scopeKey)) - // The text shown, so a change is computed and saved where it happens, not in a state updater. - const shownRef = useRef(draft) - useLayoutEffect(() => { - shownRef.current = draft - }, [draft]) - // Appended while composing: the editor's own writes would erase it, so the cache keeps it after them. - const pendingAppendRef = useRef<{ scopeKey: string; text: string } | null>(null) - - // Reload the cached draft when reused for a different pane (scope change), - // adjusting state during render rather than in an effect so the restored draft - // is visible on the first paint after the switch. - const lastScopeKey = useRef(scopeKey) - if (lastScopeKey.current !== scopeKey) { - lastScopeKey.current = scopeKey - setDraftState(readNativeChatDraftCache(scopeKey)) - } + const subscribe = useCallback( + (listener: () => void) => subscribeToNativeChatComposerDraft(scopeKey, listener), + [scopeKey] + ) + const stored = useSyncExternalStore(subscribe, () => readNativeChatDraftCache(scopeKey)) + const [hold, setHold] = useState(null) + // Read by callbacks between renders; only they change it, always together with the state. + const holdRef = useRef(null) + const updateHold = useCallback((next: CompositionHold | null) => { + holdRef.current = next + setHold(next) + }, []) + useEffect( + () => () => { + holdRef.current = null + }, + [] + ) useEffect( () => - subscribeToNativeChatDraftAppend(scopeKey, (text) => { + subscribeToNativeChatDraftAppend(scopeKey, (text, previous) => { if (!isComposing()) { - shownRef.current = readNativeChatDraftCache(scopeKey) - setDraftState(shownRef.current) return } - const pending = pendingAppendRef.current - pendingAppendRef.current = { + const held = holdRef.current?.scopeKey === scopeKey ? holdRef.current : null + updateHold({ scopeKey, - text: - pending?.scopeKey === scopeKey ? appendNativeChatDraftText(pending.text, text) : text - } + shown: held ? held.shown : previous, + appended: held ? appendNativeChatDraftText(held.appended, text) : text + }) }), - [isComposing, scopeKey] + [isComposing, scopeKey, updateHold] ) - // Persist every mutation through the cache. Accepts the same value/updater - // forms as a useState setter so call sites are drop-in. + // Accepts the same value/updater forms as a useState setter so call sites are drop-in. const setDraft = useCallback( (next: string | ((previous: string) => string), options?: { unsaved?: boolean }) => { - const resolved = typeof next === 'function' ? next(shownRef.current) : next - shownRef.current = resolved - setDraftState(resolved) - const pending = pendingAppendRef.current + const held = holdRef.current?.scopeKey === scopeKey ? holdRef.current : null + const previous = held ? held.shown : readNativeChatDraftCache(scopeKey) + const resolved = typeof next === 'function' ? next(previous) : next + if (held) { + updateHold({ ...held, shown: resolved }) + } writeNativeChatDraftCache( scopeKey, - pending?.scopeKey === scopeKey - ? appendNativeChatDraftText(resolved, pending.text) - : resolved, + held ? appendNativeChatDraftText(resolved, held.appended) : resolved, options ) }, - [scopeKey] + [scopeKey, updateHold] ) const flushDraftAppends = useCallback(() => { - const pending = pendingAppendRef.current - pendingAppendRef.current = null - if (pending?.scopeKey === scopeKey) { - setDraft((previous) => appendNativeChatDraftText(previous, pending.text)) + if (holdRef.current?.scopeKey === scopeKey) { + updateHold(null) } - }, [scopeKey, setDraft]) + }, [scopeKey, updateHold]) - return { draft, setDraft, flushDraftAppends } + const shownHold = hold?.scopeKey === scopeKey ? hold : null + return { draft: shownHold ? shownHold.shown : stored, setDraft, flushDraftAppends } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 39892a49219f..5ea9503db12c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -41,7 +41,7 @@ function harness( const { result } = renderHook(() => useNativeChatStructuredComposerSend({ agent, - draft: '', + draftScopeKey: 'tab-1:pane', imageAttachments: [ATTACHMENT], structuredTransport, clearImageAttachments: vi.fn(), 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 f208e6cb4a93..d3204ad999ab 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 @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { reportStructuredSessionUserInput } from '@/lib/worker-terminal-takeover-report' import { @@ -10,10 +10,14 @@ import { dispatchNativeChatStructuredComposerText } from './native-chat-structur import { pushHistory, type HistoryState } from './native-chat-composer-state' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { + clearNativeChatComposerDraftIfUnchanged, + readNativeChatComposerDraft +} from './native-chat-composer-draft-store' export type UseNativeChatStructuredComposerSendArgs = { agent: AgentType - draft?: string + draftScopeKey: string imageAttachments: readonly NativeChatComposerImageAttachment[] structuredTransport?: NativeChatStructuredComposerTransport clearImageAttachments: () => void @@ -27,7 +31,7 @@ export type UseNativeChatStructuredComposerSendArgs = { * once the transport accepts (the PTY path has its own sibling hook). */ export function useNativeChatStructuredComposerSend({ agent, - draft, + draftScopeKey, imageAttachments, structuredTransport, clearImageAttachments, @@ -39,10 +43,13 @@ export function useNativeChatStructuredComposerSend({ text: string, attachments?: readonly NativeChatComposerImageAttachment[] ) => void { - const composition = useRef({ draft, imageAttachments }) - useLayoutEffect(() => { - composition.current = { draft, imageAttachments } - }, [draft, imageAttachments]) + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) return useCallback( (text: string, attachments = imageAttachments): void => { if (!structuredTransport) { @@ -55,7 +62,7 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.onError('Remove attachments before using a chat-session command.') return } - const submitted = composition.current + const submitted = readNativeChatComposerDraft(draftScopeKey) void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error }) => { structuredTransport.onError(error) @@ -71,14 +78,15 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.runtimeEnvironmentId ) setHistory((previous) => pushHistory(previous, text)) - if ( - hostCommand && - (composition.current.draft !== submitted.draft || - composition.current.imageAttachments !== submitted.imageAttachments) - ) { - return + // Why: a host command settles after a round trip, and a replaced composer's send after + // the user may have typed in the new one; either clears only a draft still as sent. + if (hostCommand || !mounted.current) { + if (!clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) { + return + } + } else { + setDraft('') } - setDraft('') setCaret(0) clearSkillOrigin() clearImageAttachments() @@ -91,6 +99,7 @@ export function useNativeChatStructuredComposerSend({ agent, clearImageAttachments, clearSkillOrigin, + draftScopeKey, imageAttachments, setCaret, setDraft, From 951e7ba9bad2bc26d10422ab61dabe23b8ab7c07 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:20:24 -0700 Subject: [PATCH 09/38] fix(native-chat): keep images pasted during a command and on shown composers An image pasted while /compact or /clear was still on its way to the host vanished when the command was accepted: the clear that followed also wiped the chip still being saved. A command that settles now clears only the draft it sent, and an image pasted meanwhile stays. A composer on screen could lose its pasted images, and its unsaved launch text, once 128 other drafts pushed its record out of memory, because those are never stored. A draft that a composer is showing is no longer evicted. --- ...ive-chat-composer-draft-lifecycle.test.tsx | 80 +++++++++++++++---- .../native-chat-composer-draft-store.ts | 9 ++- .../native-chat-composer-scope-cache.ts | 18 +++-- .../use-native-chat-composer-attachments.ts | 26 +++++- ...se-native-chat-structured-composer-send.ts | 12 +-- 5 files changed, 114 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 0ddb627d758f..0eb75c912ac9 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -51,7 +51,7 @@ type ComposerApi = { type Dispatched = { handled: boolean; accepted: boolean; error: string | null } /** A transport whose next send settles only when the test says so. */ -function heldTransport(): { +function heldTransport(hostCommand = false): { transport: NativeChatStructuredComposerTransport settle: () => Promise } { @@ -77,7 +77,11 @@ function heldTransport(): { }, settle: async () => { await act(async () => { - resolve({ handled: false, accepted: false, error: null }) + resolve( + hostCommand + ? { handled: true, accepted: true, error: null } + : { handled: false, accepted: false, error: null } + ) await dispatched }) } @@ -210,21 +214,6 @@ describe('native-chat composer draft lifecycle', () => { expect(restored.api).toMatchObject({ draft: '', attachments: { imageAttachments: [] } }) }) - it('clears a sent draft when its send settles after the composer was replaced', async () => { - const hooks = await loadHooks() - const held = heldTransport() - const first: { api?: ComposerApi } = {} - const Composer = composer(hooks, (next) => (first.api = next), held.transport) - await mount(createElement(Composer, { scopeKey: 'tab-1:pane' })) - await act(async () => first.api?.setDraft('/goal ship it')) - await act(async () => first.api?.send('/goal ship it')) - await unmount() - - await held.settle() - - expect(storedDraft('tab-1:pane')).toBeNull() - }) - it('keeps what was typed in a replacement composer when the old composer’s send settles', async () => { const hooks = await loadHooks() const held = heldTransport() @@ -256,6 +245,63 @@ describe('native-chat composer draft lifecycle', () => { expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'new text' }) }) + it('keeps an image pasted while a host command was on its way', async () => { + const hooks = await loadHooks() + const held = heldTransport(true) + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next), held.transport), + { + scopeKey: 'tab-1:pane' + } + ) + ) + await act(async () => seen.api?.setDraft('/compact')) + await act(async () => seen.api?.send('/compact')) + const pending: { id?: string | null } = {} + await act(async () => { + pending.id = seen.api?.attachments.beginPendingImageAttachment('data:image/png;base64,AA') + }) + expect(pending.id).toBeTruthy() + + await held.settle() + await act(async () => + seen.api?.attachments.resolvePendingImageAttachment(pending.id ?? '', '/repo/shot.png') + ) + + expect(seen.api?.draft).toBe('') + expect(seen.api?.attachments.imageAttachments.map(({ path }) => path)).toEqual([ + '/repo/shot.png' + ]) + }) + + it('keeps a shown composer’s pasted image when many other drafts are written', async () => { + const hooks = await loadHooks() + const drafts = await import('./native-chat-draft-cache') + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => + hooks.attachmentsHook.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'p1', path: '/tmp/orca-paste-1-abc.png' } + ]) + ) + for (let index = 0; index < 128; index += 1) { + drafts.writeNativeChatDraftCache(`tab-${index + 2}:pane`, `draft ${index}`) + } + + await act(async () => seen.api?.setDraft('typing')) + + expect(seen.api?.attachments.imageAttachments.map(({ path }) => path)).toEqual([ + '/tmp/orca-paste-1-abc.png' + ]) + }) + it('leaves nothing saved when a send clears a draft whose typing was still deferred', async () => { vi.useFakeTimers() const hooks = await loadHooks() 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 045f99d8a01a..89b89a9b19b4 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 @@ -46,6 +46,11 @@ let flushTimer: ReturnType | null = null let flushOnHideInstalled = false const scopeListeners = new Map void>>() +// Why: a shown draft can hold what storage never does (pasted images, unsaved launch text). +function isShown(scopeKey: string): boolean { + return scopeListeners.has(scopeKey) +} + function notifyScope(scopeKey: string): void { scopeListeners.get(scopeKey)?.forEach((listener) => listener()) } @@ -187,7 +192,7 @@ function loadRecord(scopeKey: string): DraftRecord | undefined { if (!stored) { return undefined } - setBoundedScopeCacheEntry(records, scopeKey, stored, writeEvictedRecord) + setBoundedScopeCacheEntry(records, scopeKey, stored, writeEvictedRecord, isShown) return stored } catch { return undefined @@ -241,7 +246,7 @@ export function updateNativeChatComposerDraft( savedAt: nextSavedAt(), ...(unsavedText === undefined ? {} : { unsavedText }) } - setBoundedScopeCacheEntry(records, scopeKey, record, writeEvictedRecord) + setBoundedScopeCacheEntry(records, scopeKey, record, writeEvictedRecord, isShown) dirtyScopes.add(scopeKey) installFlushOnHide() notifyScope(scopeKey) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts index 40ab2a612420..728d41f27080 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -11,16 +11,22 @@ export function setBoundedScopeCacheEntry( cache: Map, scopeKey: string, value: T, - onEvict?: (evictedKey: string, evicted: T) => void + onEvict?: (evictedKey: string, evicted: T) => void, + /** Entries still in use are skipped; the cache may then briefly exceed its bound. */ + inUse?: (key: string) => boolean ): void { cache.delete(scopeKey) cache.set(scopeKey, value) - while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { - const oldest = cache.entries().next().value - if (oldest === undefined) { + let excess = cache.size - NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + for (const [key, evicted] of cache) { + if (excess <= 0) { break } - cache.delete(oldest[0]) - onEvict?.(oldest[0], oldest[1]) + if (inUse?.(key)) { + continue + } + cache.delete(key) + onEvict?.(key, evicted) + excess -= 1 } } 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 7a1dd6080d4c..cdc92277dee2 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 @@ -1,5 +1,13 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type RefObject +} from 'react' import { translate } from '@/i18n/i18n' import { nativeChatComposerTargetIsRemote, @@ -79,6 +87,22 @@ export function useNativeChatComposerAttachments({ ], [local, settled] ) + // A preview whose image left the draft (sent, or removed elsewhere) is released. + useEffect(() => { + const current = localRef.current + const gone = [...current.previews.keys()].filter( + (id) => !settled.some((image) => image.id === id) + ) + if (gone.length === 0) { + return + } + const previews = new Map(current.previews) + for (const id of gone) { + releasePreviewUrl(previews.get(id)) + previews.delete(id) + } + updateLocal({ ...current, previews }) + }, [settled, updateLocal]) const imageAttachmentCounter = useRef(0) const nextAttachmentId = useCallback((): string => { 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 d3204ad999ab..c6454beb0ade 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 @@ -79,14 +79,16 @@ export function useNativeChatStructuredComposerSend({ ) setHistory((previous) => pushHistory(previous, text)) // Why: a host command settles after a round trip, and a replaced composer's send after - // the user may have typed in the new one; either clears only a draft still as sent. + // the user may have typed in the new one; either clears only a draft still as sent, and + // leaves an image pasted meanwhile, which was never part of it. if (hostCommand || !mounted.current) { - if (!clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) { - return + if (clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) { + setCaret(0) + clearSkillOrigin() } - } else { - setDraft('') + return } + setDraft('') setCaret(0) clearSkillOrigin() clearImageAttachments() From 9a7f9c997f86cbfb6eb3a6982854842cad7153dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:29:08 -0700 Subject: [PATCH 10/38] fix(native-chat): a draft being written is never evicted from memory A write to a pane while every other cached draft was on screen could evict the record it had just written, losing it. Also adds the test that a send settling after its composer unmounted clears the saved draft, through the real send hook. --- ...ive-chat-composer-draft-lifecycle.test.tsx | 23 +++++++++++++++++++ .../native-chat-composer-scope-cache.test.ts | 10 ++++++++ .../native-chat-composer-scope-cache.ts | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 0eb75c912ac9..0135ffa17dff 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -214,6 +214,29 @@ describe('native-chat composer draft lifecycle', () => { expect(restored.api).toMatchObject({ draft: '', attachments: { imageAttachments: [] } }) }) + it('clears a saved sent draft when its send settles after the composer unmounted', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const first: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (first.api = next), held.transport), + { + scopeKey: 'tab-1:pane' + } + ) + ) + await act(async () => first.api?.setDraft('sent text')) + window.dispatchEvent(new Event('pagehide')) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'sent text' }) + await act(async () => first.api?.send('sent text')) + await unmount() + + await held.settle() + + expect(storedDraft('tab-1:pane')).toBeNull() + }) + it('keeps what was typed in a replacement composer when the old composer’s send settles', async () => { const hooks = await loadHooks() const held = heldTransport() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts index 07867e8a5c44..b9250c2f775e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts @@ -32,4 +32,14 @@ describe('setBoundedScopeCacheEntry', () => { expect([...cache.keys()]).toEqual(['b', 'a']) expect(cache.get('a')).toBe(3) }) + + it('never evicts the entry being set, even when every other entry is in use', () => { + const cache = new Map() + for (let i = 0; i < NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX; i += 1) { + setBoundedScopeCacheEntry(cache, `shown-${i}`, i) + } + setBoundedScopeCacheEntry(cache, 'new', 1, undefined, (key) => key.startsWith('shown-')) + + expect(cache.get('new')).toBe(1) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts index 728d41f27080..65c82c9af2c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -22,7 +22,7 @@ export function setBoundedScopeCacheEntry( if (excess <= 0) { break } - if (inUse?.(key)) { + if (key === scopeKey || inUse?.(key)) { continue } cache.delete(key) From 7336174da364136ef7b40f786865fc7350504d5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:34:29 -0700 Subject: [PATCH 11/38] fix(native-chat): restored images Orca couldn't keep come back to attach again A pasted screenshot in an unsent draft vanished after a restart with no notice, and a restored image whose file had been deleted looked fine until the send failed. Both now come back as a placeholder chip that names the image and says it wasn't saved with the draft. Send stays disabled, and the send button says to attach the image again or remove it. Attaching a file with the same name takes the placeholder's place. Two browser tabs of the web client on one chat now follow each other: when one sends or edits the draft, the other drops its copy and shows the new one, unless it has an edit of its own not yet saved. --- .../native-chat/NativeChatComposer.tsx | 8 +- .../NativeChatComposerActions.test.tsx | 24 ++++++ .../native-chat/NativeChatComposerActions.tsx | 56 ++++++++----- .../native-chat/NativeChatComposerField.tsx | 6 ++ .../NativeChatImageAttachmentPreview.test.tsx | 23 ++++- .../NativeChatImageAttachmentPreview.tsx | 83 +++++++++++++++---- .../native-chat-composer-draft-image-check.ts | 64 ++++++++++++++ ...ive-chat-composer-draft-lifecycle.test.tsx | 80 ++++++++++++++++++ .../native-chat-composer-draft-storage.ts | 35 +++++++- .../native-chat-composer-draft-store.test.ts | 35 ++++++-- .../native-chat-composer-draft-store.ts | 50 ++++++++++- .../native-chat/native-chat-image-reattach.ts | 41 +++++++++ .../use-native-chat-composer-attachments.ts | 37 +++++---- .../use-native-chat-composer-submit.ts | 3 +- ...se-native-chat-structured-composer-send.ts | 7 ++ src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + 21 files changed, 501 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-image-reattach.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 26612e6ae492..bd9e6862788f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -14,6 +14,7 @@ import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft import { NativeChatComposerField } from './NativeChatComposerField' import type { NativeChatResolvedTarget } from './native-chat-composer-target' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { nativeChatImageSendBlock } from './native-chat-image-reattach' import { useNativeChatComposerHandle } from './use-native-chat-composer-handle' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' @@ -182,12 +183,10 @@ const NativeChatComposerPane = forwardRef attachment.pending) + const imageBlock = nativeChatImageSendBlock(imageAttachments) const sendButtonDisabled = isWorking ? !hasPty || !onStop - : disabled || hasPendingAttachment || (draft.trim() === '' && imageAttachments.length === 0) + : disabled || imageBlock.holdsSend || (draft.trim() === '' && imageAttachments.length === 0) const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({ terminalTabId, @@ -372,6 +371,7 @@ const NativeChatComposerPane = forwardRef { expect(onSend).not.toHaveBeenCalled() expect(onStop).not.toHaveBeenCalled() }) + + it('says on the disabled send button what to do to send', () => { + render( + + ) + + const send = screen.getByRole('button', { name: 'Attach the image again or remove it' }) + expect(send.hasAttribute('disabled')).toBe(true) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 3012aa28cc44..f3dfd2e62307 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -16,6 +16,8 @@ export type NativeChatComposerActionsProps = { attachDisabled: boolean dictationDisabled: boolean sendDisabled: boolean + /** Shown on the disabled send button: what the user can do to send. */ + sendBlockedReason?: string | null isWorking: boolean isDictating: boolean isDictationHoldMode: boolean @@ -38,6 +40,7 @@ export function NativeChatComposerActions({ attachDisabled, dictationDisabled, sendDisabled, + sendBlockedReason, isWorking, isDictating, isDictationHoldMode, @@ -68,6 +71,26 @@ export function NativeChatComposerActions({ const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') + const sendReason = isWorking ? null : (sendBlockedReason ?? null) + const sendButton = ( + + ) + return (
@@ -145,26 +168,19 @@ export function NativeChatComposerActions({ {dictationLabel} - + {sendReason ? ( + + + {/* A disabled button gets no pointer events, so the wrapper carries the hover. */} + {sendButton} + + + {sendReason} + + + ) : ( + sendButton + )}
) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index ccbd38afe84f..e9cf50ad7137 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -34,6 +34,8 @@ export type NativeChatComposerFieldProps = { notice: string | null imageAttachments: readonly NativeChatComposerImageAttachment[] sendButtonDisabled: boolean + /** Why the send button is disabled, when the user can do something about it. */ + sendBlockedReason?: string | null isWorking: boolean attachDisabled: boolean dictationDisabled: boolean @@ -73,6 +75,8 @@ export type NativeChatComposerImageAttachment = { previewUrl?: string /** True while the pasted image is still being written to disk or uploaded. */ pending?: boolean + /** Set on an image the draft names but can't send: the file to attach again. */ + unavailableName?: string } /** @@ -108,6 +112,7 @@ export function NativeChatComposerField({ notice, imageAttachments, sendButtonDisabled, + sendBlockedReason, isWorking, attachDisabled, dictationDisabled, @@ -285,6 +290,7 @@ export function NativeChatComposerField({ attachDisabled={attachDisabled} dictationDisabled={dictationDisabled} sendDisabled={sendButtonDisabled} + sendBlockedReason={sendBlockedReason} isWorking={isWorking} isDictating={isDictating} isDictationHoldMode={isDictationHoldMode} diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 2b5924e98260..e2ac63dda296 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -10,7 +10,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => options?.[name] ?? '') })) vi.mock('@/components/editor/useLocalImageSrc', () => ({ @@ -52,4 +53,24 @@ describe('NativeChatImageAttachmentPreview', () => { expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined) }) + + it('shows an image the draft could not keep as one to attach again, and lets it be removed', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + const onRemove = vi.fn() + vi.stubGlobal('IntersectionObserver', undefined) + render( + + ) + + expect( + screen.getByRole('img', { + name: "Pasted image wasn't saved with this draft. Attach it again to send it." + }) + ).toBeTruthy() + screen.getByRole('button', { name: 'Remove attachment' }).click() + expect(onRemove).toHaveBeenCalledWith('m1') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 52e9a5f7c3a1..f27687af0c30 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Image as ImageIcon, Loader2, X } from 'lucide-react' +import { Image as ImageIcon, ImageOff, Loader2, X } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' @@ -17,6 +17,71 @@ export function NativeChatImageAttachmentPreview({ attachment, onRemove }: Props): React.JSX.Element { + if (attachment.unavailableName !== undefined) { + return ( + + ) + } + return +} + +function attachmentLabel(path: string): string { + return isNativeChatPastedImagePath(path) + ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') + : basename(path) +} + +function RemoveAttachmentButton({ onRemove }: { onRemove: () => void }): React.JSX.Element { + return ( + + ) +} + +/** An image the draft names but couldn't keep: it waits to be attached again, or removed. */ +function NativeChatUnavailableImageChip({ + id, + name, + onRemove +}: { + id: string + name: string + onRemove: (id: string) => void +}): React.JSX.Element { + const explanation = translate( + 'components.native-chat.composer.imageNotSavedWithDraft', + "{{name}} wasn't saved with this draft. Attach it again to send it.", + { name: attachmentLabel(name) } + ) + return ( +
+
+ +
+ onRemove(id)} /> +
+ ) +} + +function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.Element { const [isOpen, setIsOpen] = useState(false) const [isNearViewport, setIsNearViewport] = useState(false) const thumbnailRef = useRef(null) @@ -51,9 +116,7 @@ export function NativeChatImageAttachmentPreview({ // round-trip; the on-disk file only wins for the full-size dialog. const thumbnailSrc = attachment.previewUrl ?? localSrc const fullSizeSrc = localSrc ?? attachment.previewUrl - const filename = isNativeChatPastedImagePath(attachment.path) - ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') - : basename(attachment.path) + const filename = attachmentLabel(attachment.path) const pendingLabel = translate( 'components.native-chat.composer.imageSaving', 'Saving pasted image…' @@ -90,17 +153,7 @@ export function NativeChatImageAttachmentPreview({ ) : null} - + onRemove(attachment.id)} /> diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts new file mode 100644 index 000000000000..25adff1098e9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts @@ -0,0 +1,64 @@ +// Which restored draft images are known to be gone, through the existing existence check: the +// workspace's own read rules locally, the host over SSH. + +import type { NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' +import { + readNativeChatComposerDraft, + takeUnverifiedNativeChatComposerDraft, + unavailableNativeChatComposerDraftImage, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' + +/** Ids of images whose file is gone. One that cannot be checked (no read permission for that + * path, host not connected) counts as present: the send's own check still guards it. */ +export async function findMissingNativeChatComposerDraftImages( + images: readonly NativeChatComposerDraftImage[] +): Promise> { + const pathExists = typeof window === 'undefined' ? undefined : window.api?.fs?.pathExists + const missing = new Set() + if (!pathExists) { + return missing + } + await Promise.all( + images + .filter((image) => image.unavailableName === undefined && image.path !== '') + .map(async (image) => { + try { + const exists = await pathExists({ + filePath: image.path, + ...(image.connectionId ? { connectionId: image.connectionId } : {}) + }) + if (!exists) { + missing.add(image.id) + } + } catch { + // Unknown, not gone. + } + }) + ) + return missing +} + +/** Once per restored draft: an image whose file is gone comes back as one to attach again. */ +export async function verifyRestoredNativeChatComposerDraftImages(scopeKey: string): Promise { + if (!takeUnverifiedNativeChatComposerDraft(scopeKey)) { + return + } + const checked = readNativeChatComposerDraft(scopeKey).images + const missing = await findMissingNativeChatComposerDraftImages(checked) + if (missing.size === 0) { + return + } + const gone = new Set(checked.filter((image) => missing.has(image.id)).map(({ path }) => path)) + updateNativeChatComposerDraft( + scopeKey, + { + images: readNativeChatComposerDraft(scopeKey).images.map((image) => + missing.has(image.id) && gone.has(image.path) + ? unavailableNativeChatComposerDraftImage(image) + : image + ) + }, + 'immediate' + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 0135ffa17dff..199549f4ef6a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -463,6 +463,86 @@ describe('native-chat composer draft lifecycle', () => { expect(storedDraft('tab-1:pane')).toMatchObject({ text: '@late.txt ' }) }) + it('turns a restored image whose file is gone into one to attach again', async () => { + const pathExists = vi.fn(async ({ filePath }: { filePath: string }) => { + if (filePath === '/Users/me/Desktop/elsewhere.png') { + throw new Error('Access denied: path resolves outside allowed directories.') + } + return filePath !== '/repo/gone.png' + }) + vi.stubGlobal('api', { fs: { pathExists } }) + localStorage.setItem( + `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}`, + JSON.stringify({ + text: 'see these', + images: [ + { id: 'a', path: '/repo/gone.png' }, + { id: 'b', path: '/repo/here.png' }, + { id: 'c', path: '/Users/me/Desktop/elsewhere.png' } + ], + savedAt: 1 + }) + ) + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + try { + await mount( + createElement( + composer(hooks, (next) => (seen.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + await act(async () => {}) + } finally { + vi.unstubAllGlobals() + } + + expect(seen.api?.attachments.imageAttachments).toEqual([ + { id: 'a', path: '', unavailableName: 'gone.png' }, + { id: 'b', path: '/repo/here.png' }, + { id: 'c', path: '/Users/me/Desktop/elsewhere.png' } + ]) + }) + + it('takes another window’s send of the same draft, unless an edit here is still unsaved', async () => { + const key = `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}` + localStorage.setItem( + key, + JSON.stringify({ text: 'sent in the other tab', images: [], savedAt: 1 }) + ) + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + expect(seen.api?.draft).toBe('sent in the other tab') + + // The other tab sends: it removes the draft, and this window hears of it. + localStorage.removeItem(key) + await act(async () => { + window.dispatchEvent(new StorageEvent('storage', { key, newValue: null })) + }) + expect(seen.api?.draft).toBe('') + await act(async () => seen.api?.setDraft((previous) => `${previous}next`)) + window.dispatchEvent(new Event('pagehide')) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'next' }) + + // An edit here not yet saved wins over the other window's write. + await act(async () => seen.api?.setDraft('mine, unsaved')) + await act(async () => { + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: JSON.stringify({ text: 'theirs', images: [], savedAt: 2 }) + }) + ) + }) + expect(seen.api?.draft).toBe('mine, unsaved') + }) + it('does not bring back an untouched launch link after a reload, when no seed is left to replace it', async () => { const link = 'https://github.com/o/r/issues/12' const seed: NativeChatLaunchDraft = { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index bc9d50c3a658..d52e81fa0a6d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -15,8 +15,11 @@ const MAX_STORED_DRAFT_CHARS = MAX_STORED_TOTAL_CHARS - 1_000 export type NativeChatComposerDraftImage = { id: string + /** Empty for an image Orca could not keep. */ path: string connectionId?: string + /** Set on an image Orca could not keep: the file name the user must attach again. */ + unavailableName?: string } export type NativeChatComposerDraft = { @@ -59,8 +62,14 @@ function parseImage(value: unknown): NativeChatComposerDraftImage | null { if (!isRecord(value)) { return null } - const { id, path, connectionId } = value - if (typeof id !== 'string' || typeof path !== 'string' || path === '') { + const { id, path, connectionId, unavailableName } = value + if (typeof id !== 'string') { + return null + } + if (typeof unavailableName === 'string') { + return { id, path: '', unavailableName } + } + if (typeof path !== 'string' || path === '') { return null } return { id, path, ...(typeof connectionId === 'string' ? { connectionId } : {}) } @@ -217,6 +226,28 @@ export function enforceStoredNativeChatComposerDraftBounds(storage: Storage): vo } } +/** For a `storage` event from another window: the scope it changed, after re-indexing it; null + * when the key is not a draft. */ +export function storedNativeChatComposerDraftChanged( + key: string | null, + newValue: string | null +): string | null { + if (!key?.startsWith(STORAGE_KEY_PREFIX)) { + return null + } + const stored = parseStoredNativeChatComposerDraft(newValue) + if (newValue === null || !stored) { + unindexStoredDraft(key) + } else { + indexStoredDraft(key, { chars: key.length + newValue.length, savedAt: stored.savedAt }) + } + try { + return decodeURIComponent(key.slice(STORAGE_KEY_PREFIX.length)) + } catch { + return null + } +} + /** Removes every stored draft whose scope matches. */ export function removeStoredNativeChatComposerDraftsWhere( storage: Storage, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index c9fedf264e31..d664d71df16f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -145,7 +145,7 @@ describe('native-chat composer draft store', () => { expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) }) - it('keeps a pasted image for this run only, and still saves the files the user attached', async () => { + it('brings a pasted image back after a reload as one to attach again, by name', async () => { const pasted = { id: 'p-1', path: '/var/folders/T/orca-paste-1-0f.png' } modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [pasted, IMAGES[0]]) modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'caption') @@ -155,17 +155,42 @@ describe('native-chat composer draft store', () => { pasted, IMAGES[0] ]) - expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'caption', images: [IMAGES[0]] }) const reloaded = await reload() - expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([IMAGES[0]]) + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'p-1', path: '', unavailableName: 'orca-paste-1-0f.png' }, + IMAGES[0] + ]) }) - it('stores nothing for a draft that holds only a pasted image', () => { + it('keeps a draft that holds only a pasted image, as one to attach again', async () => { modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ { id: 'p-1', path: '/tmp/orca-paste-1-0f.png', connectionId: 'ssh-1' } ]) - expect(storedDraftKeys()).toEqual([]) + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'p-1', path: '', unavailableName: 'orca-paste-1-0f.png' } + ]) + }) + + it('puts a re-attached image in the place of the one to attach again', async () => { + storage.setItem( + `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}`, + JSON.stringify({ + text: 'see', + images: [{ id: 'm', path: '', unavailableName: 'shot.png' }, IMAGES[0]], + savedAt: 1 + }) + ) + const reloaded = await reload() + + reloaded.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'again', path: '/Users/me/Desktop/shot.png' } + ]) + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'again', path: '/Users/me/Desktop/shot.png' }, + IMAGES[0] + ]) }) it('appends a given-back message after a draft restored from a reload', async () => { 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 89b89a9b19b4..8dc93cf1d1cc 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 @@ -5,6 +5,7 @@ import type { JSONContent } from '@tiptap/react' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { basename } from '@/lib/path' import { isNativeChatPastedImagePath } from './native-chat-image-paste' import { clearStoredNativeChatComposerDraftsForTests, @@ -15,6 +16,7 @@ import { parseStoredNativeChatComposerDraft, removeStoredNativeChatComposerDraft, removeStoredNativeChatComposerDraftsWhere, + storedNativeChatComposerDraftChanged, writeStoredNativeChatComposerDraft, type NativeChatComposerDraft, type NativeChatComposerDraftImage, @@ -45,6 +47,9 @@ let lastSavedAt = 0 let flushTimer: ReturnType | null = null let flushOnHideInstalled = false const scopeListeners = new Map void>>() +// Records read back from storage this run whose image files have not been checked yet. +const unverifiedScopes = new Set() +let storageSyncInstalled = false // Why: a shown draft can hold what storage never does (pasted images, unsaved launch text). function isShown(scopeKey: string): boolean { @@ -60,6 +65,7 @@ export function subscribeToNativeChatComposerDraft( scopeKey: string, listener: () => void ): () => void { + installStorageSync() const listeners = scopeListeners.get(scopeKey) ?? new Set() scopeListeners.set(scopeKey, listeners) listeners.add(listener) @@ -71,6 +77,28 @@ export function subscribeToNativeChatComposerDraft( } } +// Why: another window of the same app (two web-client tabs) can send or edit this draft; its write +// replaces what this window holds unless this window has an edit of its own still unsaved. +function installStorageSync(): void { + if (storageSyncInstalled || typeof window === 'undefined' || !window.addEventListener) { + return + } + storageSyncInstalled = true + window.addEventListener('storage', (event) => { + const scopeKey = storedNativeChatComposerDraftChanged(event.key, event.newValue) + if (scopeKey === null || dirtyScopes.has(scopeKey)) { + return + } + records.delete(scopeKey) + notifyScope(scopeKey) + }) +} + +/** True once for a draft read back from storage this run, so its image files get checked once. */ +export function takeUnverifiedNativeChatComposerDraft(scopeKey: string): boolean { + return unverifiedScopes.delete(scopeKey) +} + function isEmptyDraft(draft: NativeChatComposerDraft): boolean { return draft.text === '' && draft.images.length === 0 } @@ -85,7 +113,8 @@ function sameImages( (image, index) => image.id === right[index].id && image.path === right[index].path && - image.connectionId === right[index].connectionId + image.connectionId === right[index].connectionId && + image.unavailableName === right[index].unavailableName ) ) } @@ -96,11 +125,22 @@ function nextSavedAt(): number { return lastSavedAt } -/** What storage keeps: not an unsaved launch-seed copy, and not a pasted image, whose temp file - * and read grant need not outlive this run. Null when nothing is left. */ +/** An image the draft names but can no longer send, so the user can attach it again. */ +export function unavailableNativeChatComposerDraftImage( + image: NativeChatComposerDraftImage +): NativeChatComposerDraftImage { + return image.unavailableName === undefined + ? { id: image.id, path: '', unavailableName: basename(image.path) } + : image +} + +/** What storage keeps: not an unsaved launch-seed copy, and a pasted image only by name, since its + * temp file and read grant need not outlive this run. Null when nothing is left. */ function savedForm(record: DraftRecord): StoredNativeChatComposerDraft | null { const { unsavedText, ...saved } = record - const images = saved.images.filter((image) => !isNativeChatPastedImagePath(image.path)) + const images = saved.images.map((image) => + isNativeChatPastedImagePath(image.path) ? unavailableNativeChatComposerDraftImage(image) : image + ) const text = saved.text === unsavedText ? '' : saved.text if (text === '' && images.length === 0) { return null @@ -193,6 +233,7 @@ function loadRecord(scopeKey: string): DraftRecord | undefined { return undefined } setBoundedScopeCacheEntry(records, scopeKey, stored, writeEvictedRecord, isShown) + unverifiedScopes.add(scopeKey) return stored } catch { return undefined @@ -305,5 +346,6 @@ export function clearNativeChatComposerDraftsForTests(): void { } records.clear() dirtyScopes.clear() + unverifiedScopes.clear() clearStoredNativeChatComposerDraftsForTests() } diff --git a/src/renderer/src/components/native-chat/native-chat-image-reattach.ts b/src/renderer/src/components/native-chat/native-chat-image-reattach.ts new file mode 100644 index 000000000000..3591eb3487e0 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-image-reattach.ts @@ -0,0 +1,41 @@ +import { translate } from '@/i18n/i18n' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +/** Why the composer can't send while it holds images Orca couldn't keep; null when it holds none. */ +export function nativeChatAttachImagesAgainReason( + attachments: readonly NativeChatComposerImageAttachment[] +): string | null { + const count = attachments.filter((attachment) => attachment.unavailableName !== undefined).length + if (count === 0) { + return null + } + return count === 1 + ? translate( + 'components.native-chat.composer.attachImageAgain', + 'Attach the image again or remove it' + ) + : translate( + 'components.native-chat.composer.attachImagesAgain', + 'Attach the images again or remove them' + ) +} + +/** A chip still saving, or one to attach again, has no path the agent can read yet. */ +export function nativeChatImagesHoldSend( + attachments: readonly NativeChatComposerImageAttachment[] +): boolean { + return attachments.some( + (attachment) => attachment.pending || attachment.unavailableName !== undefined + ) +} + +/** Whether the composer's images keep it from sending, and the reason to show when the user can + * fix it. */ +export function nativeChatImageSendBlock( + attachments: readonly NativeChatComposerImageAttachment[] +): { holdsSend: boolean; reason: string | null } { + return { + holdsSend: nativeChatImagesHoldSend(attachments), + reason: nativeChatAttachImagesAgainReason(attachments) + } +} 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 cdc92277dee2..9ef2dad558e9 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,6 +9,7 @@ import { type RefObject } from 'react' import { translate } from '@/i18n/i18n' +import { basename } from '@/lib/path' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget @@ -20,6 +21,7 @@ import { subscribeToNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' +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' @@ -87,6 +89,9 @@ export function useNativeChatComposerAttachments({ ], [local, settled] ) + useEffect(() => { + void verifyRestoredNativeChatComposerDraftImages(attachmentScopeKey) + }, [attachmentScopeKey]) // A preview whose image left the draft (sent, or removed elsewhere) is released. useEffect(() => { const current = localRef.current @@ -281,8 +286,9 @@ export function readNativeChatAttachmentCache( return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image })) } -/** Adds settled images after the ones the draft holds now. Saved at once: when Stop gives them - * back, the copy they came from goes right after this. */ +/** Adds settled images after the ones the draft holds now; one with the name of an image to + * attach again takes its place. Saved at once: when Stop gives images back, the copy they came + * from goes right after this. */ export function appendNativeChatAttachmentCache( scopeKey: string, appended: readonly NativeChatComposerImageAttachment[] @@ -290,21 +296,18 @@ export function appendNativeChatAttachmentCache( if (appended.length === 0) { return } - // Preview URLs can retain the full clipboard Blob, so only the path is kept. - updateNativeChatComposerDraft( - scopeKey, - { - images: [ - ...readNativeChatComposerDraft(scopeKey).images, - ...appended.map(({ id, path, connectionId }) => ({ - id, - path, - ...(connectionId ? { connectionId } : {}) - })) - ] - }, - 'immediate' - ) + 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 marker = images.findIndex((held) => held.unavailableName === basename(path)) + if (marker === -1) { + images.push(image) + } else { + images[marker] = image + } + } + updateNativeChatComposerDraft(scopeKey, { images }, 'immediate') } export function clearNativeChatAttachmentCacheForTests(): void { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts index 159fc3cfae2d..65d7c515a4b9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -4,6 +4,7 @@ import { applyPickerSuggestion, type NativeChatPickerItem } from './native-chat- import { pushHistory, type HistoryState } from './native-chat-composer-state' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { nativeChatImagesHoldSend } from './native-chat-image-reattach' import { clearNativeChatComposerDraftIfUnchanged, readNativeChatComposerDraft @@ -108,7 +109,7 @@ export function useNativeChatComposerSubmit(args: { ]) const send = useCallback(() => { - if (imageAttachments.some((attachment) => attachment.pending)) { + if (nativeChatImagesHoldSend(imageAttachments)) { return } if (threadGoal && structuredTransport && isBareStructuredAgentSessionGoalCommand(draft)) { 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 c6454beb0ade..2a6b5ea5e041 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 @@ -10,6 +10,7 @@ import { dispatchNativeChatStructuredComposerText } from './native-chat-structur import { pushHistory, type HistoryState } from './native-chat-composer-state' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { nativeChatAttachImagesAgainReason } from './native-chat-image-reattach' import { clearNativeChatComposerDraftIfUnchanged, readNativeChatComposerDraft @@ -58,6 +59,12 @@ export function useNativeChatStructuredComposerSend({ const hostCommand = isStructuredAgentSessionComposerCommand(text, agent) || (structuredTransport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) + // A command picked while images await re-attaching would send them without a file. + const attachAgain = nativeChatAttachImagesAgainReason(attachments) + if (attachAgain) { + structuredTransport.onError(attachAgain) + return + } if (attachments.length > 0 && hostCommand) { structuredTransport.onError('Remove attachments before using a chat-session command.') return diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6c160e408dc3..b803ccda70d3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17765,6 +17765,9 @@ "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", + "imageNotSavedWithDraft": "{{name}} wasn't saved with this draft. Attach it again to send it.", + "attachImageAgain": "Attach the image again or remove it", + "attachImagesAgain": "Attach the images again or remove them", "imagePasteFailed": "Image paste failed.", "imageSaving": "Saving pasted image…", "worktreeNotReady": "Worktree not ready — try again in a moment.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3f8a1e2f7034..d89dff132b9e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15084,6 +15084,9 @@ "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", + "imageNotSavedWithDraft": "{{name}} no se guardó con este borrador. Adjúntala de nuevo para enviarla.", + "attachImageAgain": "Adjunta la imagen de nuevo o quítala", + "attachImagesAgain": "Adjunta las imágenes de nuevo o quítalas", "imagePasteFailed": "Error al pegar imagen.", "worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.", "pasteUnavailable": "No se puede pegar — este chat no acepta entradas en este momento.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 40f29aafcd75..f5b6686a7604 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17568,6 +17568,9 @@ "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", + "imageNotSavedWithDraft": "{{name}} n'a pas été enregistrée avec ce brouillon. Joignez-la à nouveau pour l'envoyer.", + "attachImageAgain": "Joignez à nouveau l'image ou retirez-la", + "attachImagesAgain": "Joignez à nouveau les images ou retirez-les", "imagePasteFailed": "Échec du collage de l'image.", "worktreeNotReady": "Worktree pas encore prêt — réessayez dans un instant.", "pasteUnavailable": "Impossible de coller — ce chat n'accepte pas de saisie pour le moment.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4870f2cbb29c..2c204b788401 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17490,6 +17490,9 @@ "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", + "imageNotSavedWithDraft": "{{name}} はこの下書きに保存されませんでした。送信するにはもう一度添付してください。", + "attachImageAgain": "画像をもう一度添付するか、削除してください", + "attachImagesAgain": "画像をもう一度添付するか、削除してください", "imagePasteFailed": "画像の貼り付けに失敗しました。", "worktreeNotReady": "ワークツリーが準備できていません — しばらくしてから再試行してください。", "pasteUnavailable": "貼り付けできません — このチャットは現在入力を受け付けていません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e04189ae70f1..3a8e5a3b8225 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17490,6 +17490,9 @@ "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", + "imageNotSavedWithDraft": "{{name}}은(는) 이 초안에 저장되지 않았습니다. 보내려면 다시 첨부하세요.", + "attachImageAgain": "이미지를 다시 첨부하거나 제거하세요", + "attachImagesAgain": "이미지를 다시 첨부하거나 제거하세요", "imagePasteFailed": "이미지 붙여넣기 실패.", "worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.", "pasteUnavailable": "붙여넣을 수 없습니다 — 이 채팅은 지금 입력을 받을 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2a9a3ea73b66..b7af46e09fd4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17507,6 +17507,9 @@ "localAttachmentUnsupported": "远程会话不支持本地附件。", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", + "imageNotSavedWithDraft": "{{name}} 未随此草稿保存。请重新附加后再发送。", + "attachImageAgain": "请重新附加该图片或将其移除", + "attachImagesAgain": "请重新附加这些图片或将其移除", "imagePasteFailed": "图片粘贴失败。", "worktreeNotReady": "工作树未就绪 — 请稍后重试。", "pasteUnavailable": "无法粘贴 — 此聊天当前不接受输入。", From f7177f2f5019e9ebad91fc8118c420691bff6737 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:35:20 -0700 Subject: [PATCH 12/38] test(native-chat): no send while an image waits to be attached again --- .../native-chat/use-native-chat-composer-submit.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx index fe21c0b58f4a..7ec967b6f0e9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -207,6 +207,15 @@ describe('composer goal mode', () => { expect(hook.result.current.goalMode.active).toBe(true) }) + it('does not send while an image waits to be attached again', () => { + const { hook, calls } = harness({ + draft: 'see the screenshot', + imageAttachments: [{ id: 'm1', path: '', unavailableName: 'orca-paste-1-ab.png' }] + }) + act(() => hook.result.current.send()) + expect(calls.sendStructured).not.toHaveBeenCalled() + }) + it('sends an ordinary message outside goal mode', () => { const { hook, calls } = harness({ draft: 'hello', threadGoal: { setObjective: vi.fn() } }) act(() => hook.result.current.send()) From 13672f82676b67f723412ff5fddeba7e0a04625a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:01:46 -0700 Subject: [PATCH 13/38] fix(native-chat): pasted images in a draft come back after a restart A pasted screenshot lived in the OS temp folder with a read permission held in memory, so a draft could only bring it back as a placeholder. Local pastes are now written to Orca's own native-chat-pastes folder under the app's user data, and a draft keeps them as real images. On restore the composer asks the main process to re-grant each one; main grants a read only when the file's real path, symlinks and junctions resolved, is inside that folder, and reports anything else as not kept, which becomes the placeholder. Pastes older than 30 days are deleted at startup, far past the host's 24 hour window for a resent message; the sweep never follows a link and never blocks startup. SSH pastes and older temp-folder pastes still come back as placeholders. --- .../clipboard-dashboard-popout-access.test.ts | 3 + .../window/clipboard-image-temp-file.test.ts | 30 ++- src/main/window/clipboard-image-temp-file.ts | 10 +- .../window/clipboard-ipc-handlers.test.ts | 4 +- src/main/window/clipboard-ipc-handlers.ts | 7 + .../clipboard-runtime-owned-ssh-paste.test.ts | 6 +- .../window/native-chat-paste-files.test.ts | 179 ++++++++++++++++++ src/main/window/native-chat-paste-files.ts | 117 ++++++++++++ ...ui-bridge-clipboard-and-window-controls.ts | 4 + src/preload/api/ui-window-api.ts | 4 + .../native-chat-composer-draft-image-check.ts | 24 ++- ...ive-chat-composer-draft-lifecycle.test.tsx | 58 ++++++ .../native-chat-composer-draft-store.test.ts | 19 ++ .../native-chat-composer-draft-store.ts | 19 +- .../native-chat/native-chat-image-paste.ts | 8 + .../use-native-chat-composer-attachments.ts | 20 +- .../src/web/preload-api/web-ui-api.ts | 2 + src/shared/native-chat-paste-folder.ts | 3 + 18 files changed, 487 insertions(+), 30 deletions(-) create mode 100644 src/main/window/native-chat-paste-files.test.ts create mode 100644 src/main/window/native-chat-paste-files.ts create mode 100644 src/shared/native-chat-paste-folder.ts diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 9a9c50bd3afb..99144519d582 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -109,6 +109,9 @@ describe('dashboard popout clipboard access', () => { expect(() => handlers.get('clipboard:readFilePaths')?.(popoutEvent)).toThrow( 'Unauthorized clipboard IPC sender' ) + expect(() => + handlers.get('clipboard:restoreNativeChatPastes')?.(popoutEvent, ['/etc/passwd']) + ).toThrow('Unauthorized clipboard IPC sender') expect(() => handlers.get('clipboard:writeFile')?.(popoutEvent, { filePath: '/tmp/copied-file.txt', diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 71ca0804c0ef..8058426a6d1d 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,15 +1,16 @@ +import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { authorizeExternalPathMock, writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted( - () => ({ +const { authorizeExternalPathMock, writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } = + vi.hoisted(() => ({ authorizeExternalPathMock: vi.fn(), writeFileMock: vi.fn(), - getPathMock: vi.fn(() => '/var/folders/ab/T'), + mkdirMock: vi.fn(), + getPathMock: vi.fn(() => '/Users/me/Library/Application Support/orca'), writeFileBase64Mock: vi.fn() - }) -) + })) -vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock } })) +vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock, mkdir: mkdirMock } })) vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getPath: getPathMock }) @@ -29,11 +30,24 @@ beforeEach(() => { }) describe('saveClipboardImageBufferAsTempFile', () => { - it('authorizes the local temp file so the composer can preview what it just wrote', async () => { + it('writes a local paste into the paste folder, where a restored draft can still find it', async () => { + const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) + + expect(getPathMock).toHaveBeenCalledWith('userData') + expect(mkdirMock).toHaveBeenCalledWith( + join('/Users/me/Library/Application Support/orca', 'native-chat-pastes'), + { recursive: true } + ) + expect(dirname(savedPath)).toBe( + join('/Users/me/Library/Application Support/orca', 'native-chat-pastes') + ) + }) + + it('authorizes the local paste so the composer can preview what it just wrote', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) - // The OS temp dir is outside every allowed root, so an unauthorized path + // The paste folder is outside every allowed root, so an unauthorized path // makes fs:readFile deny the preview read of Orca's own file. expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 0024c4b6d1f8..715ecaeb7077 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -2,11 +2,11 @@ import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { getAppEnvironment } from '../../shared/app-environment' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' import { authorizeExternalPath } from '../ipc/filesystem-auth' +import { nativeChatPasteFolder } from './native-chat-paste-files' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null @@ -40,9 +40,13 @@ export async function saveClipboardImageBufferAsTempFile( return remotePath } - const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) + // Why the paste folder, not the OS temp dir: a draft that names the paste can still show and + // send it after a restart (native-chat-paste-files). + const folder = nativeChatPasteFolder() + await fs.mkdir(folder, { recursive: true }) + const tempPath = path.join(folder, fileName) await fs.writeFile(tempPath, buffer) - // Why: the OS temp dir is outside every allowed root, so without this the + // Why: the paste folder is outside every allowed root, so without this the // composer's own thumbnail/preview read of the file it just wrote is denied. authorizeExternalPath(tempPath) return tempPath diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 1bcf1749c30c..dadd1a60023e 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -86,9 +86,7 @@ vi.mock('node:fs/promises', () => ({ stat: fsStatMock, realpath: vi.fn(), // unused here; only satisfies filesystem-path-containment's named import writeFile: fsWriteFileMock, - default: { - writeFile: fsWriteFileMock - } + default: { writeFile: fsWriteFileMock, mkdir: fsMkdirMock } })) vi.mock('../ipc/filesystem-auth', () => ({ diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index e81140d734a6..6a5d207c29cb 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -43,6 +43,7 @@ import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' import { writeClipboardTextAndVerify } from './clipboard-text-write-verify' import { isDashboardPopoutRenderer } from './dashboard-popout-window' +import { restoreNativeChatPastes, sweepExpiredNativeChatPastes } from './native-chat-paste-files' let trustedClipboardRendererWebContentsId: number | null = null @@ -100,8 +101,10 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:readImageThumbnail') ipcMain.removeHandler('clipboard:hasImage') ipcMain.removeHandler('clipboard:readFilePaths') + ipcMain.removeHandler('clipboard:restoreNativeChatPastes') void cleanupExpiredRemoteClipboardFiles() + void sweepExpiredNativeChatPastes() scheduleLegacyRemoteClipboardFileCleanup() ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { @@ -115,6 +118,10 @@ export function registerClipboardHandlers(store: Store): void { return assertClipboardTextWithinLimitWithYield(clipboard.readText('selection'), options) } ) + ipcMain.handle('clipboard:restoreNativeChatPastes', (event, paths: unknown) => { + assertTrustedClipboardSender(event) + return restoreNativeChatPastes(paths) + }) // Why: an unanswered paste reads as a dropped paste, so the composer probes // the clipboard in memory before the (slower) save lands. ipcMain.handle('clipboard:readImageThumbnail', (event): ClipboardImageThumbnail | null => { diff --git a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts index 6e09eb5fd3e6..8ee4b04ec2f1 100644 --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts @@ -39,7 +39,7 @@ vi.mock('node:fs/promises', () => ({ stat: vi.fn(), realpath: vi.fn(), writeFile: fsWriteFileMock, - default: { writeFile: fsWriteFileMock } + default: { writeFile: fsWriteFileMock, mkdir: vi.fn() } })) vi.mock('../ipc/filesystem-auth', () => ({ PATH_ACCESS_DENIED_MESSAGE: 'denied', @@ -210,11 +210,11 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => { expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() }) - it('keeps a plain local paste on the local temp dir', async () => { + it('keeps a plain local paste on this machine, in Orca’s paste folder', async () => { fsWriteFileMock.mockResolvedValue(undefined) await expect(saveImageHandler()(rendererEvent, undefined)).resolves.toMatch( - /orca-paste-.*\.png$/ + /native-chat-pastes[\\/]orca-paste-.*\.png$/ ) expect(fsWriteFileMock).toHaveBeenCalledTimes(1) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts new file mode 100644 index 000000000000..cb4fb2b6b0f1 --- /dev/null +++ b/src/main/window/native-chat-paste-files.test.ts @@ -0,0 +1,179 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' + +const { authorizeExternalPathMock } = vi.hoisted(() => ({ authorizeExternalPathMock: vi.fn() })) +vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) + +import { + NATIVE_CHAT_PASTE_TTL_MS, + isInsideNativeChatPasteFolder, + restoreNativeChatPastes, + sweepExpiredNativeChatPastes +} from './native-chat-paste-files' + +describe('isInsideNativeChatPasteFolder', () => { + const posixFolder = '/data/native-chat-pastes' + const winFolder = 'C:\\Users\\Me\\AppData\\Roaming\\Orca\\native-chat-pastes' + + it.each([ + ['a file inside', `${posixFolder}/orca-paste-1.png`, true], + ['a name that only starts with dots', `${posixFolder}/..orca-paste-1.png`, true], + ['the folder itself', posixFolder, false], + ['the parent', '/data', false], + ['a sibling reached through ..', `${posixFolder}/../secret.png`, false], + ['a sibling folder sharing the prefix', '/data/native-chat-pastes-evil/x.png', false], + ['an unrelated absolute path', '/etc/passwd', false] + ])('posix: %s', (_label, target, inside) => { + expect(isInsideNativeChatPasteFolder(posixFolder, target, path.posix, 'darwin')).toBe(inside) + }) + + it.each([ + ['a file inside', `${winFolder}\\orca-paste-1.png`, true], + ['a file inside in other letter case', `${winFolder.toLowerCase()}\\ORCA-PASTE-1.PNG`, true], + ['a \\\\?\\ prefixed file inside', `\\\\?\\${winFolder}\\orca-paste-1.png`, true], + ['another drive', 'D:\\native-chat-pastes\\orca-paste-1.png', false], + ['a \\\\?\\UNC share', '\\\\?\\UNC\\server\\share\\orca-paste-1.png', false], + ['a sibling reached through ..', `${winFolder}\\..\\secret.png`, false], + ['the folder itself', winFolder, false] + ])('win32: %s', (_label, target, inside) => { + expect(isInsideNativeChatPasteFolder(winFolder, target, path.win32, 'win32')).toBe(inside) + }) + + it('compares a \\\\?\\ prefixed folder like its plain form', () => { + expect( + isInsideNativeChatPasteFolder( + `\\\\?\\${winFolder}`, + `${winFolder}\\orca-paste-1.png`, + path.win32, + 'win32' + ) + ).toBe(true) + }) +}) + +describe('native-chat paste folder on disk', () => { + let root: string + let folder: string + + beforeEach(() => { + authorizeExternalPathMock.mockReset() + root = mkdtempSync(path.join(tmpdir(), 'orca-native-chat-pastes-')) + folder = path.join(root, 'native-chat-pastes') + mkdirSync(folder) + installFakeAppEnvironment({ getPath: () => root }) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('re-grants only files really inside the folder, and never throws on a bad path', async () => { + const kept = path.join(folder, 'orca-paste-1.png') + writeFileSync(kept, 'png') + const outside = path.join(root, 'outside.png') + writeFileSync(outside, 'png') + const linkOut = path.join(folder, 'orca-paste-2.png') + symlinkSync(outside, linkOut) + mkdirSync(path.join(folder, 'orca-paste-dir.png')) + + const results = await restoreNativeChatPastes([ + kept, + linkOut, + path.join(folder, '..', 'outside.png'), + path.join(folder, 'orca-paste-dir.png'), + path.join(folder, 'orca-paste-missing.png'), + 'relative/orca-paste-3.png', + '', + 42 + ]) + + expect(results).toEqual([ + { path: kept, kept: true, exists: true }, + { path: linkOut, kept: false, exists: false }, + { path: path.join(folder, '..', 'outside.png'), kept: false, exists: false }, + { path: path.join(folder, 'orca-paste-dir.png'), kept: false, exists: false }, + { path: path.join(folder, 'orca-paste-missing.png'), kept: false, exists: false }, + { path: 'relative/orca-paste-3.png', kept: false, exists: false }, + { path: '', kept: false, exists: false } + ]) + const granted = authorizeExternalPathMock.mock.calls.map(([granted]) => granted) + expect(granted).toContain(realpathSync(kept)) + expect(granted.some((value: string) => value.includes('outside'))).toBe(false) + await expect(restoreNativeChatPastes('not a list')).resolves.toEqual([]) + }) + + it('keeps a paste reached through a symlinked alias of the folder, as /var is of /private/var', async () => { + const kept = path.join(folder, 'orca-paste-1.png') + writeFileSync(kept, 'png') + const alias = path.join(tmpdir(), `orca-native-chat-pastes-alias-${process.pid}`) + rmSync(alias, { force: true }) + symlinkSync(root, alias) + try { + installFakeAppEnvironment({ getPath: () => alias }) + const viaAlias = path.join(alias, 'native-chat-pastes', 'orca-paste-1.png') + + await expect(restoreNativeChatPastes([viaAlias, kept])).resolves.toEqual([ + { path: viaAlias, kept: true, exists: true }, + { path: kept, kept: true, exists: true } + ]) + } finally { + rmSync(alias, { force: true }) + } + }) + + it('reports nothing kept when the folder does not exist yet', async () => { + rmSync(folder, { recursive: true }) + await expect(restoreNativeChatPastes([path.join(folder, 'orca-paste-1.png')])).resolves.toEqual( + [{ path: path.join(folder, 'orca-paste-1.png'), kept: false, exists: false }] + ) + }) + + it('expires old pastes only, and never follows a symlink or enters a folder', async () => { + const now = Date.now() + const old = (now - NATIVE_CHAT_PASTE_TTL_MS - 60_000) / 1000 + const oldPaste = path.join(folder, 'orca-paste-old.png') + const newPaste = path.join(folder, 'orca-paste-new.png') + writeFileSync(oldPaste, 'png') + writeFileSync(newPaste, 'png') + utimesSync(oldPaste, old, old) + const outsideOld = path.join(root, 'outside-old.png') + writeFileSync(outsideOld, 'png') + utimesSync(outsideOld, old, old) + symlinkSync(outsideOld, path.join(folder, 'orca-paste-link.png')) + const nested = path.join(folder, 'nested') + mkdirSync(nested) + const nestedOld = path.join(nested, 'orca-paste-nested.png') + writeFileSync(nestedOld, 'png') + utimesSync(nestedOld, old, old) + + await sweepExpiredNativeChatPastes(now) + + expect(existsSync(oldPaste)).toBe(false) + expect(existsSync(newPaste)).toBe(true) + expect(existsSync(outsideOld)).toBe(true) + expect(existsSync(path.join(folder, 'orca-paste-link.png'))).toBe(true) + expect(existsSync(nestedOld)).toBe(true) + }) + + it('does nothing, and does not throw, when the folder is missing', async () => { + rmSync(folder, { recursive: true }) + await expect(sweepExpiredNativeChatPastes()).resolves.toBeUndefined() + }) + + it('keeps pastes longer than the host keeps a resend admissible', () => { + expect(NATIVE_CHAT_PASTE_TTL_MS).toBeGreaterThan(AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) + }) +}) diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts new file mode 100644 index 000000000000..ed237569c19e --- /dev/null +++ b/src/main/window/native-chat-paste-files.ts @@ -0,0 +1,117 @@ +// Local native-chat pastes live in an Orca-owned folder, so a restored draft can show and send them: +// a restore re-grants preview reads only for files that really are inside it, and old files expire. + +import { lstat, readdir, realpath, stat, unlink } from 'node:fs/promises' +import path from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { NATIVE_CHAT_PASTE_FOLDER } from '../../shared/native-chat-paste-folder' +import { authorizeExternalPath } from '../ipc/filesystem-auth' + +// Why 30 days: far past the host's 24 h window for admitting a resent send that names a paste +// (AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS). +export const NATIVE_CHAT_PASTE_TTL_MS = 30 * 24 * 60 * 60 * 1000 +const MAX_RESTORED_PASTES = 256 + +export type RestoredNativeChatPaste = { path: string; kept: boolean; exists: boolean } + +export function nativeChatPasteFolder(): string { + return path.join(getAppEnvironment().getPath('userData'), NATIVE_CHAT_PASTE_FOLDER) +} + +/** A path as compared for containment: no `\\?\` prefix, and case-folded where the platform is. */ +function comparablePath(value: string, pathApi: path.PlatformPath, platform: string): string { + const unprefixed = value.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/, '') + const normalized = pathApi.normalize(unprefixed) + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +/** True when `target` names something strictly inside `folder`; both must already be real paths. */ +export function isInsideNativeChatPasteFolder( + folder: string, + target: string, + pathApi: path.PlatformPath = path, + platform: string = process.platform +): boolean { + const relative = pathApi.relative( + comparablePath(folder, pathApi, platform), + comparablePath(target, pathApi, platform) + ) + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${pathApi.sep}`) && + !pathApi.isAbsolute(relative) + ) +} + +/** + * For each restored local paste: re-grants its preview read only when its real path is a file + * inside the real paste folder (symlinks and junctions resolved). Never throws. + */ +export async function restoreNativeChatPastes(paths: unknown): Promise { + if (!Array.isArray(paths)) { + return [] + } + let folder: string | null = null + try { + folder = await realpath(nativeChatPasteFolder()) + } catch { + // No folder yet: nothing in it to keep. + } + return Promise.all( + paths + .slice(0, MAX_RESTORED_PASTES) + .flatMap((value) => + typeof value === 'string' ? [restoreNativeChatPaste(folder, value)] : [] + ) + ) +} + +async function restoreNativeChatPaste( + folder: string | null, + restored: string +): Promise { + const refused = { path: restored, kept: false, exists: false } + if (folder === null || restored === '' || !path.isAbsolute(restored)) { + return refused + } + try { + const real = await realpath(restored) + if (!isInsideNativeChatPasteFolder(folder, real) || !(await stat(real)).isFile()) { + return refused + } + authorizeExternalPath(real) + authorizeExternalPath(restored) + return { path: restored, kept: true, exists: true } + } catch { + // Missing or unreadable: not kept, and nothing about an outside path is reported. + return refused + } +} + +/** Deletes pastes older than the TTL. Symlinks and folders are skipped, never followed; failures + * are logged and never block startup. */ +export async function sweepExpiredNativeChatPastes(now = Date.now()): Promise { + let folder: string + let entries + try { + folder = nativeChatPasteFolder() + entries = await readdir(folder, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isFile()) { + continue + } + const file = path.join(folder, entry.name) + try { + const info = await lstat(file) + if (info.isFile() && now - info.mtimeMs > NATIVE_CHAT_PASTE_TTL_MS) { + await unlink(file) + } + } catch (error) { + console.warn('[native-chat-pastes] could not expire a paste:', error) + } + } +} diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 51957a5eec5a..8f79e5860453 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -96,6 +96,10 @@ export const uiClipboardAndWindowControlsApi = { }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), clipboardHasImage: (): Promise => ipcRenderer.invoke('clipboard:hasImage'), readClipboardFilePaths: (): Promise => ipcRenderer.invoke('clipboard:readFilePaths'), + restoreNativeChatPastes: ( + paths: string[] + ): Promise<{ path: string; kept: boolean; exists: boolean }[]> => + ipcRenderer.invoke('clipboard:restoreNativeChatPastes', paths), readClipboardImageThumbnail: (): Promise => ipcRenderer.invoke('clipboard:readImageThumbnail'), writeClipboardText: (text: string): Promise => diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index c36896a2481d..83f1d9122666 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -16,6 +16,10 @@ export type UiWindowApi = { clipboardHasImage: () => Promise /** Paths of files a file manager copied; empty when there are none or the host cannot list them. */ readClipboardFilePaths: () => Promise + /** Re-grants preview reads for restored draft pastes that really are in Orca's paste folder. */ + restoreNativeChatPastes: ( + paths: string[] + ) => Promise<{ path: string; kept: boolean; exists: boolean }[]> readClipboardImageThumbnail: () => Promise writeClipboardText: (text: string) => Promise writeTerminalClipboardText: (text: string) => Promise diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts index 25adff1098e9..7ef4990e174c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts @@ -1,8 +1,10 @@ -// Which restored draft images are known to be gone, through the existing existence check: the -// workspace's own read rules locally, the host over SSH. +// Which restored draft images are known to be gone. A paste in Orca's paste folder asks main to +// re-grant its preview, which main does only for files really inside that folder; any other image +// goes through the existing existence check (the workspace's read rules locally, the host over SSH). import type { NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' import { + isKeptLocalPaste, readNativeChatComposerDraft, takeUnverifiedNativeChatComposerDraft, unavailableNativeChatComposerDraftImage, @@ -14,14 +16,26 @@ import { export async function findMissingNativeChatComposerDraftImages( images: readonly NativeChatComposerDraftImage[] ): Promise> { - const pathExists = typeof window === 'undefined' ? undefined : window.api?.fs?.pathExists + const api = typeof window === 'undefined' ? undefined : window.api const missing = new Set() + const checkable = images.filter((image) => image.unavailableName === undefined && image.path) + const pastes = checkable.filter(isKeptLocalPaste) + if (pastes.length > 0 && api?.ui?.restoreNativeChatPastes) { + try { + const restored = await api.ui.restoreNativeChatPastes(pastes.map(({ path }) => path)) + const kept = new Set(restored.filter((r) => r.kept && r.exists).map(({ path }) => path)) + pastes.filter(({ path }) => !kept.has(path)).forEach(({ id }) => missing.add(id)) + } catch { + // Unknown, not gone. + } + } + const pathExists = api?.fs?.pathExists if (!pathExists) { return missing } await Promise.all( - images - .filter((image) => image.unavailableName === undefined && image.path !== '') + checkable + .filter((image) => !isKeptLocalPaste(image)) .map(async (image) => { try { const exists = await pathExists({ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 199549f4ef6a..8f50952821c7 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -504,6 +504,64 @@ describe('native-chat composer draft lifecycle', () => { ]) }) + it('shows a restored paste from Orca’s paste folder once main re-grants it, and marks the rest', async () => { + const folder = '/Users/me/Library/Application Support/orca/native-chat-pastes' + let regrant: () => void = () => {} + const regranted = new Promise((resolve) => { + regrant = resolve + }) + const restoreNativeChatPastes = vi.fn(async (paths: string[]) => { + await regranted + return paths.map((path) => ({ + path, + kept: path.endsWith('orca-paste-1-ab.png'), + exists: path.endsWith('orca-paste-1-ab.png') + })) + }) + vi.stubGlobal('api', { + fs: { pathExists: vi.fn(async () => true) }, + ui: { restoreNativeChatPastes } + }) + localStorage.setItem( + `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}`, + JSON.stringify({ + text: 'see', + images: [ + { id: 'kept', path: `${folder}/orca-paste-1-ab.png` }, + { id: 'swept', path: `${folder}/orca-paste-2-ab.png` } + ], + savedAt: 1 + }) + ) + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + try { + await mount( + createElement( + composer(hooks, (next) => (seen.api = next)), + { scopeKey: 'tab-1:pane' } + ) + ) + // Until main answers, the restored paste waits, so its preview is not read before the grant. + expect(seen.api?.attachments.imageAttachments[0]?.pending).toBe(true) + await act(async () => { + regrant() + await regranted + }) + } finally { + vi.unstubAllGlobals() + } + + expect(restoreNativeChatPastes).toHaveBeenCalledWith([ + `${folder}/orca-paste-1-ab.png`, + `${folder}/orca-paste-2-ab.png` + ]) + expect(seen.api?.attachments.imageAttachments).toEqual([ + { id: 'kept', path: `${folder}/orca-paste-1-ab.png` }, + { id: 'swept', path: '', unavailableName: 'orca-paste-2-ab.png' } + ]) + }) + it('takes another window’s send of the same draft, unless an edit here is still unsaved', async () => { const key = `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}` localStorage.setItem( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index d664d71df16f..6bf65ee8ab20 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -173,6 +173,25 @@ describe('native-chat composer draft store', () => { ]) }) + it('saves a paste kept in Orca’s paste folder as the real image, but not one over SSH', async () => { + const kept = { + id: 'p-1', + path: '/Users/me/Library/Application Support/orca/native-chat-pastes/orca-paste-1-0f.png' + } + const remote = { + id: 'p-2', + path: '/remote/native-chat-pastes/orca-paste-2-0f.png', + connectionId: 'ssh-1' + } + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', [kept, remote]) + + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + kept, + { id: 'p-2', path: '', unavailableName: 'orca-paste-2-0f.png' } + ]) + }) + it('puts a re-attached image in the place of the one to attach again', async () => { storage.setItem( `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}`, 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 8dc93cf1d1cc..e5b79ba9b09e 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 @@ -6,7 +6,7 @@ import type { JSONContent } from '@tiptap/react' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import { basename } from '@/lib/path' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { clearStoredNativeChatComposerDraftsForTests, enforceStoredNativeChatComposerDraftBounds, @@ -99,6 +99,10 @@ export function takeUnverifiedNativeChatComposerDraft(scopeKey: string): boolean return unverifiedScopes.delete(scopeKey) } +export function isNativeChatComposerDraftUnverified(scopeKey: string): boolean { + return unverifiedScopes.has(scopeKey) +} + function isEmptyDraft(draft: NativeChatComposerDraft): boolean { return draft.text === '' && draft.images.length === 0 } @@ -134,12 +138,19 @@ export function unavailableNativeChatComposerDraftImage( : image } -/** What storage keeps: not an unsaved launch-seed copy, and a pasted image only by name, since its - * temp file and read grant need not outlive this run. Null when nothing is left. */ +/** A local paste in Orca's paste folder: it outlives the run, so a restore can show and send it. */ +export function isKeptLocalPaste(image: NativeChatComposerDraftImage): boolean { + return !image.connectionId && isNativeChatKeptPastePath(image.path) +} + +/** What storage keeps: not an unsaved launch-seed copy, and a paste outside Orca's paste folder + * (over SSH, or from before it) only by name. Null when nothing is left. */ function savedForm(record: DraftRecord): StoredNativeChatComposerDraft | null { const { unsavedText, ...saved } = record const images = saved.images.map((image) => - isNativeChatPastedImagePath(image.path) ? unavailableNativeChatComposerDraftImage(image) : image + isNativeChatPastedImagePath(image.path) && !isKeptLocalPaste(image) + ? unavailableNativeChatComposerDraftImage(image) + : image ) const text = saved.text === unsavedText ? '' : saved.text if (text === '' && images.length === 0) { diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.ts index 15f68900c840..b0d9c47f5976 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.ts @@ -1,4 +1,5 @@ import { isImageDropPath } from '../terminal-pane/terminal-drop-image-path' +import { NATIVE_CHAT_PASTE_FOLDER } from '../../../../shared/native-chat-paste-folder' export { getAgentImageHandling, type AgentImageHandling @@ -15,3 +16,10 @@ export function isNativeChatPastedImagePath(path: string): boolean { const base = path.split(/[\\/]/).findLast(Boolean) ?? path return /^orca-paste-.+\.png$/i.test(base) } + +/** True for a local paste Orca keeps in its paste folder, judged from the path alone; main checks + * the real file before granting it. */ +export function isNativeChatKeptPastePath(path: string): boolean { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts.at(-2) === NATIVE_CHAT_PASTE_FOLDER && isNativeChatPastedImagePath(path) +} 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 9ef2dad558e9..21b37ddbb7cd 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 @@ -17,6 +17,8 @@ import { import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { clearNativeChatComposerDraftsForTests, + isKeptLocalPaste, + isNativeChatComposerDraftUnverified, readNativeChatComposerDraft, subscribeToNativeChatComposerDraft, updateNativeChatComposerDraft @@ -71,6 +73,16 @@ export function useNativeChatComposerAttachments({ subscribe, () => readNativeChatComposerDraft(attachmentScopeKey).images ) + // Why: a restored paste can be previewed only once main re-grants it, so until the restore check + // is done it waits like a chip still saving. + const [restoring, setRestoring] = useState(() => + isNativeChatComposerDraftUnverified(attachmentScopeKey) + ) + useEffect(() => { + void verifyRestoredNativeChatComposerDraftImages(attachmentScopeKey).finally(() => + setRestoring(false) + ) + }, [attachmentScopeKey]) // Chips still being written, and the clipboard previews this composer minted, are its own. const [local, setLocal] = useState(NO_LOCAL_ATTACHMENTS) // Read by callbacks between renders; only they change it, always together with the state. @@ -83,15 +95,15 @@ export function useNativeChatComposerAttachments({ () => [ ...settled.map((image) => { const previewUrl = local.previews.get(image.id) + if (restoring && isKeptLocalPaste(image)) { + return { ...image, pending: true } + } return previewUrl ? { ...image, previewUrl } : image }), ...local.pending ], - [local, settled] + [local, restoring, settled] ) - useEffect(() => { - void verifyRestoredNativeChatComposerDraftImages(attachmentScopeKey) - }, [attachmentScopeKey]) // A preview whose image left the draft (sent, or removed elsewhere) is released. useEffect(() => { const current = localRef.current diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index c92f741ba386..6672649c9a64 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -162,6 +162,8 @@ export function createWebUiApi(): NonNullable['ui']> { clipboardHasImage, // Browsers expose copied files only inside a paste event. readClipboardFilePaths: async () => [], + // Why empty: a browser has no local paste folder, so restored pastes stay to attach again. + restoreNativeChatPastes: async () => [], readClipboardImageThumbnail: () => readClipboardImageThumbnail().catch(() => null), writeClipboardText: writeWebClipboardText, writeTerminalClipboardText: writeWebClipboardText, diff --git a/src/shared/native-chat-paste-folder.ts b/src/shared/native-chat-paste-folder.ts new file mode 100644 index 000000000000..4fac1ba86a80 --- /dev/null +++ b/src/shared/native-chat-paste-folder.ts @@ -0,0 +1,3 @@ +/** The folder under the app's user data where local native-chat pastes are kept, so a draft that + * names one can show and send it after a restart. */ +export const NATIVE_CHAT_PASTE_FOLDER = 'native-chat-pastes' From 657496ca541e30f06bba1a0ba64e28a5117e7a6f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:06:48 -0700 Subject: [PATCH 14/38] test(native-chat): the paste sweep test ages the link itself --- src/main/window/native-chat-paste-files.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index cb4fb2b6b0f1..dff721a10f04 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -1,5 +1,6 @@ import { existsSync, + lutimesSync, mkdirSync, mkdtempSync, realpathSync, @@ -153,6 +154,7 @@ describe('native-chat paste folder on disk', () => { writeFileSync(outsideOld, 'png') utimesSync(outsideOld, old, old) symlinkSync(outsideOld, path.join(folder, 'orca-paste-link.png')) + lutimesSync(path.join(folder, 'orca-paste-link.png'), old, old) const nested = path.join(folder, 'nested') mkdirSync(nested) const nestedOld = path.join(nested, 'orca-paste-nested.png') From a938777ea99090d3a24ad112675199b4aa837ba0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:10:09 -0700 Subject: [PATCH 15/38] fix(native-chat): type the paste-folder path helper with PlatformPath --- src/main/window/native-chat-paste-files.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts index ed237569c19e..6fad4e9e1714 100644 --- a/src/main/window/native-chat-paste-files.ts +++ b/src/main/window/native-chat-paste-files.ts @@ -2,7 +2,7 @@ // a restore re-grants preview reads only for files that really are inside it, and old files expire. import { lstat, readdir, realpath, stat, unlink } from 'node:fs/promises' -import path from 'node:path' +import path, { type PlatformPath } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { NATIVE_CHAT_PASTE_FOLDER } from '../../shared/native-chat-paste-folder' import { authorizeExternalPath } from '../ipc/filesystem-auth' @@ -19,7 +19,7 @@ export function nativeChatPasteFolder(): string { } /** A path as compared for containment: no `\\?\` prefix, and case-folded where the platform is. */ -function comparablePath(value: string, pathApi: path.PlatformPath, platform: string): string { +function comparablePath(value: string, pathApi: PlatformPath, platform: string): string { const unprefixed = value.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/, '') const normalized = pathApi.normalize(unprefixed) return platform === 'win32' ? normalized.toLowerCase() : normalized @@ -29,7 +29,7 @@ function comparablePath(value: string, pathApi: path.PlatformPath, platform: str export function isInsideNativeChatPasteFolder( folder: string, target: string, - pathApi: path.PlatformPath = path, + pathApi: PlatformPath = path, platform: string = process.platform ): boolean { const relative = pathApi.relative( From ad7b1a92760bdf092123e234d37ac9597797ccc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:12:29 -0700 Subject: [PATCH 16/38] fix(native-chat): type the paste-folder path helper from path.posix --- src/main/window/native-chat-paste-files.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts index 6fad4e9e1714..6412d2ec0471 100644 --- a/src/main/window/native-chat-paste-files.ts +++ b/src/main/window/native-chat-paste-files.ts @@ -2,7 +2,7 @@ // a restore re-grants preview reads only for files that really are inside it, and old files expire. import { lstat, readdir, realpath, stat, unlink } from 'node:fs/promises' -import path, { type PlatformPath } from 'node:path' +import path from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { NATIVE_CHAT_PASTE_FOLDER } from '../../shared/native-chat-paste-folder' import { authorizeExternalPath } from '../ipc/filesystem-auth' @@ -12,6 +12,8 @@ import { authorizeExternalPath } from '../ipc/filesystem-auth' export const NATIVE_CHAT_PASTE_TTL_MS = 30 * 24 * 60 * 60 * 1000 const MAX_RESTORED_PASTES = 256 +type PathApi = typeof path.posix + export type RestoredNativeChatPaste = { path: string; kept: boolean; exists: boolean } export function nativeChatPasteFolder(): string { @@ -19,7 +21,7 @@ export function nativeChatPasteFolder(): string { } /** A path as compared for containment: no `\\?\` prefix, and case-folded where the platform is. */ -function comparablePath(value: string, pathApi: PlatformPath, platform: string): string { +function comparablePath(value: string, pathApi: PathApi, platform: string): string { const unprefixed = value.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/, '') const normalized = pathApi.normalize(unprefixed) return platform === 'win32' ? normalized.toLowerCase() : normalized @@ -29,7 +31,7 @@ function comparablePath(value: string, pathApi: PlatformPath, platform: string): export function isInsideNativeChatPasteFolder( folder: string, target: string, - pathApi: PlatformPath = path, + pathApi: PathApi = path, platform: string = process.platform ): boolean { const relative = pathApi.relative( From 5ff07e67306fa239af1fb1083320bbd5b2e377b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:23:49 -0700 Subject: [PATCH 17/38] fix(native-chat): placeholder chips say what happened and only a user attach replaces one A placeholder now shows the image's name and a short hint on the chip itself, "Attach again" for a file and "Not kept" for a pasted image, with the longer explanation in its tooltip. The copy no longer says the image wasn't saved: it says it couldn't be brought back, and for a pasted image it asks only for removal, since a new paste can't match it. The send button now says to remove the image to send. Only an image the user attaches (picking, dropping or pasting) takes the place of a placeholder with its file name. An image Stop gives back is added beside it, so a different file with the same name no longer hides the reminder. --- .../NativeChatComposerActions.test.tsx | 6 ++-- .../NativeChatImageAttachmentPreview.test.tsx | 25 ++++++++++++-- .../NativeChatImageAttachmentPreview.tsx | 33 ++++++++++++++----- .../native-chat-composer-draft-store.test.ts | 28 ++++++++++++++-- .../native-chat/native-chat-image-reattach.ts | 11 ++++--- .../use-native-chat-composer-attachments.ts | 28 +++++++++------- src/renderer/src/i18n/locales/en.json | 9 +++-- src/renderer/src/i18n/locales/es.json | 9 +++-- src/renderer/src/i18n/locales/fr.json | 9 +++-- src/renderer/src/i18n/locales/ja.json | 9 +++-- src/renderer/src/i18n/locales/ko.json | 9 +++-- src/renderer/src/i18n/locales/zh.json | 9 +++-- 12 files changed, 135 insertions(+), 50 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx index ae315b4530a8..ecffdbe9f945 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -121,7 +121,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled - sendBlockedReason="Attach the image again or remove it" + sendBlockedReason="An image couldn't be brought back. Remove it to send." isWorking={false} isDictating={false} isDictationHoldMode={false} @@ -135,7 +135,9 @@ describe('NativeChatComposerActions', () => { /> ) - const send = screen.getByRole('button', { name: 'Attach the image again or remove it' }) + const send = screen.getByRole('button', { + name: "An image couldn't be brought back. Remove it to send." + }) expect(send.hasAttribute('disabled')).toBe(true) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index e2ac63dda296..879c71bc991a 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -54,7 +54,7 @@ describe('NativeChatImageAttachmentPreview', () => { expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined) }) - it('shows an image the draft could not keep as one to attach again, and lets it be removed', () => { + it('shows a pasted image that could not come back by name, says it was not kept, and lets it be removed', () => { mocks.useLocalImageSrc.mockReturnValue(undefined) const onRemove = vi.fn() vi.stubGlobal('IntersectionObserver', undefined) @@ -67,10 +67,31 @@ describe('NativeChatImageAttachmentPreview', () => { expect( screen.getByRole('img', { - name: "Pasted image wasn't saved with this draft. Attach it again to send it." + name: "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it." }) ).toBeTruthy() + expect(screen.getByText('Pasted image')).toBeTruthy() + expect(screen.getByText('Not kept')).toBeTruthy() screen.getByRole('button', { name: 'Remove attachment' }).click() expect(onRemove).toHaveBeenCalledWith('m1') }) + + it('shows a file that could not come back by name, with a visible attach-again hint', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + vi.stubGlobal('IntersectionObserver', undefined) + render( + + ) + + expect( + screen.getByRole('img', { + name: "diagram.png couldn't be brought back with this draft. Attach it again or remove it." + }) + ).toBeTruthy() + expect(screen.getByText('diagram.png')).toBeTruthy() + expect(screen.getByText('Attach again')).toBeTruthy() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index f27687af0c30..f1ce3d2c7304 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -51,7 +51,8 @@ function RemoveAttachmentButton({ onRemove }: { onRemove: () => void }): React.J ) } -/** An image the draft names but couldn't keep: it waits to be attached again, or removed. */ +/** An image the draft names but couldn't bring back. A file can be attached again in its place; a + * pasted image can't be matched by a new paste, so its copy asks only for removal. */ function NativeChatUnavailableImageChip({ id, name, @@ -61,20 +62,34 @@ function NativeChatUnavailableImageChip({ name: string onRemove: (id: string) => void }): React.JSX.Element { - const explanation = translate( - 'components.native-chat.composer.imageNotSavedWithDraft', - "{{name}} wasn't saved with this draft. Attach it again to send it.", - { name: attachmentLabel(name) } - ) + const pasted = isNativeChatPastedImagePath(name) + const label = attachmentLabel(name) + const explanation = pasted + ? translate( + 'components.native-chat.composer.pastedImageNotBroughtBack', + "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it." + ) + : translate( + 'components.native-chat.composer.imageNotBroughtBack', + "{{name}} couldn't be brought back with this draft. Attach it again or remove it.", + { name: label } + ) + const hint = pasted + ? translate('components.native-chat.composer.pastedImageNotKeptLabel', 'Not kept') + : translate('components.native-chat.composer.imageAttachAgainLabel', 'Attach again') return ( -
+
- + +
+ {label} + {hint} +
onRemove(id)} />
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 6bf65ee8ab20..f8a7ec1f8304 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -203,15 +203,37 @@ describe('native-chat composer draft store', () => { ) const reloaded = await reload() - reloaded.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ - { id: 'again', path: '/Users/me/Desktop/shot.png' } - ]) + reloaded.attachments.appendNativeChatAttachmentCache( + 'tab-1:pane', + [{ id: 'again', path: '/Users/me/Desktop/shot.png' }], + { fromUser: true } + ) expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ { id: 'again', path: '/Users/me/Desktop/shot.png' }, IMAGES[0] ]) }) + it('adds an image Stop gives back next to a placeholder with its name, never in its place', async () => { + storage.setItem( + `${DRAFT_KEY_PREFIX}${encodeURIComponent('tab-1:pane')}`, + JSON.stringify({ + text: 'compare with this', + images: [{ id: 'm', path: '', unavailableName: 'image.png' }], + savedAt: 1 + }) + ) + const reloaded = await reload() + + reloaded.attachments.appendNativeChatAttachmentCache('tab-1:pane', [ + { id: 'withdrawn-cm-1-1', path: '/Users/me/Downloads/image.png' } + ]) + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'm', path: '', unavailableName: 'image.png' }, + { id: 'withdrawn-cm-1-1', path: '/Users/me/Downloads/image.png' } + ]) + }) + it('appends a given-back message after a draft restored from a reload', async () => { modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine') modules.store.flushNativeChatComposerDrafts() diff --git a/src/renderer/src/components/native-chat/native-chat-image-reattach.ts b/src/renderer/src/components/native-chat/native-chat-image-reattach.ts index 3591eb3487e0..29d35cf90e9f 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-reattach.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-reattach.ts @@ -1,7 +1,8 @@ import { translate } from '@/i18n/i18n' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -/** Why the composer can't send while it holds images Orca couldn't keep; null when it holds none. */ +/** Why the composer can't send while it holds images that couldn't be brought back; null when it + * holds none. Removing always works; re-attaching only for a file, so the reason names removing. */ export function nativeChatAttachImagesAgainReason( attachments: readonly NativeChatComposerImageAttachment[] ): string | null { @@ -11,12 +12,12 @@ export function nativeChatAttachImagesAgainReason( } return count === 1 ? translate( - 'components.native-chat.composer.attachImageAgain', - 'Attach the image again or remove it' + 'components.native-chat.composer.removeImageToSend', + "An image couldn't be brought back. Remove it to send." ) : translate( - 'components.native-chat.composer.attachImagesAgain', - 'Attach the images again or remove them' + 'components.native-chat.composer.removeImagesToSend', + "Some images couldn't be brought back. Remove them to send." ) } 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 21b37ddbb7cd..f2049ba88a2a 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 @@ -157,7 +157,8 @@ export function useNativeChatComposerAttachments({ id: nextAttachmentId(), path, ...(connectionId ? { connectionId } : {}) - })) + })), + { fromUser: true } ) }, [attachmentScopeKey, nextAttachmentId] @@ -233,9 +234,11 @@ export function useNativeChatComposerAttachments({ const resolvePendingImageAttachment = useCallback( (id: string, path: string, connectionId?: string | null) => { if (forgetLocalAttachment(id, true)) { - appendNativeChatAttachmentCache(attachmentScopeKey, [ - { id, path, ...(connectionId ? { connectionId } : {}) } - ]) + appendNativeChatAttachmentCache( + attachmentScopeKey, + [{ id, path, ...(connectionId ? { connectionId } : {}) }], + { fromUser: true } + ) } }, [attachmentScopeKey, forgetLocalAttachment] @@ -298,12 +301,13 @@ export function readNativeChatAttachmentCache( return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image })) } -/** Adds settled images after the ones the draft holds now; one with the name of an image to - * attach again takes its place. Saved at once: when Stop gives images back, the copy they came - * from goes right after this. */ +/** Adds settled images after the ones the draft holds now. 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[] + appended: readonly NativeChatComposerImageAttachment[], + options?: { fromUser?: boolean } ): void { if (appended.length === 0) { return @@ -312,11 +316,13 @@ export function appendNativeChatAttachmentCache( 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 marker = images.findIndex((held) => held.unavailableName === basename(path)) - if (marker === -1) { + const placeholder = options?.fromUser + ? images.findIndex((held) => held.unavailableName === basename(path)) + : -1 + if (placeholder === -1) { images.push(image) } else { - images[marker] = image + images[placeholder] = image } } updateNativeChatComposerDraft(scopeKey, { images }, 'immediate') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b803ccda70d3..7fa1cbcf2dbd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17765,9 +17765,12 @@ "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", - "imageNotSavedWithDraft": "{{name}} wasn't saved with this draft. Attach it again to send it.", - "attachImageAgain": "Attach the image again or remove it", - "attachImagesAgain": "Attach the images again or remove them", + "imageNotBroughtBack": "{{name}} couldn't be brought back with this draft. Attach it again or remove it.", + "pastedImageNotBroughtBack": "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it.", + "removeImageToSend": "An image couldn't be brought back. Remove it to send.", + "removeImagesToSend": "Some images couldn't be brought back. Remove them to send.", + "imageAttachAgainLabel": "Attach again", + "pastedImageNotKeptLabel": "Not kept", "imagePasteFailed": "Image paste failed.", "imageSaving": "Saving pasted image…", "worktreeNotReady": "Worktree not ready — try again in a moment.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d89dff132b9e..ab4630b73a4d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15084,9 +15084,12 @@ "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", - "imageNotSavedWithDraft": "{{name}} no se guardó con este borrador. Adjúntala de nuevo para enviarla.", - "attachImageAgain": "Adjunta la imagen de nuevo o quítala", - "attachImagesAgain": "Adjunta las imágenes de nuevo o quítalas", + "imageNotBroughtBack": "No se pudo recuperar la imagen {{name}} con este borrador. Vuelve a adjuntarla o quítala.", + "pastedImageNotBroughtBack": "No se pudo recuperar esta imagen pegada con este borrador. Quítala y vuelve a pegarla si aún la necesitas.", + "removeImageToSend": "No se pudo recuperar una imagen. Quítala para enviar.", + "removeImagesToSend": "No se pudieron recuperar algunas imágenes. Quítalas para enviar.", + "imageAttachAgainLabel": "Adjuntar de nuevo", + "pastedImageNotKeptLabel": "No se conservó", "imagePasteFailed": "Error al pegar imagen.", "worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.", "pasteUnavailable": "No se puede pegar — este chat no acepta entradas en este momento.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f5b6686a7604..f0ddc4f86d7b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17568,9 +17568,12 @@ "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", - "imageNotSavedWithDraft": "{{name}} n'a pas été enregistrée avec ce brouillon. Joignez-la à nouveau pour l'envoyer.", - "attachImageAgain": "Joignez à nouveau l'image ou retirez-la", - "attachImagesAgain": "Joignez à nouveau les images ou retirez-les", + "imageNotBroughtBack": "L'image {{name}} n'a pas pu être récupérée avec ce brouillon. Joignez-la à nouveau ou retirez-la.", + "pastedImageNotBroughtBack": "Cette image collée n'a pas pu être récupérée avec ce brouillon. Retirez-la, puis collez-la à nouveau si vous en avez encore besoin.", + "removeImageToSend": "Une image n'a pas pu être récupérée. Retirez-la pour envoyer.", + "removeImagesToSend": "Certaines images n'ont pas pu être récupérées. Retirez-les pour envoyer.", + "imageAttachAgainLabel": "Joindre à nouveau", + "pastedImageNotKeptLabel": "Non conservée", "imagePasteFailed": "Échec du collage de l'image.", "worktreeNotReady": "Worktree pas encore prêt — réessayez dans un instant.", "pasteUnavailable": "Impossible de coller — ce chat n'accepte pas de saisie pour le moment.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2c204b788401..67443dcb9acd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17490,9 +17490,12 @@ "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", - "imageNotSavedWithDraft": "{{name}} はこの下書きに保存されませんでした。送信するにはもう一度添付してください。", - "attachImageAgain": "画像をもう一度添付するか、削除してください", - "attachImagesAgain": "画像をもう一度添付するか、削除してください", + "imageNotBroughtBack": "{{name}} をこの下書きと一緒に復元できませんでした。もう一度添付するか、削除してください。", + "pastedImageNotBroughtBack": "この貼り付けた画像をこの下書きと一緒に復元できませんでした。削除して、必要であればもう一度貼り付けてください。", + "removeImageToSend": "復元できなかった画像があります。送信するには削除してください。", + "removeImagesToSend": "復元できなかった画像があります。送信するには削除してください。", + "imageAttachAgainLabel": "もう一度添付", + "pastedImageNotKeptLabel": "保持されていません", "imagePasteFailed": "画像の貼り付けに失敗しました。", "worktreeNotReady": "ワークツリーが準備できていません — しばらくしてから再試行してください。", "pasteUnavailable": "貼り付けできません — このチャットは現在入力を受け付けていません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3a8e5a3b8225..4828e4e3d4ab 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17490,9 +17490,12 @@ "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", - "imageNotSavedWithDraft": "{{name}}은(는) 이 초안에 저장되지 않았습니다. 보내려면 다시 첨부하세요.", - "attachImageAgain": "이미지를 다시 첨부하거나 제거하세요", - "attachImagesAgain": "이미지를 다시 첨부하거나 제거하세요", + "imageNotBroughtBack": "{{name}}을(를) 이 초안과 함께 복원하지 못했습니다. 다시 첨부하거나 제거하세요.", + "pastedImageNotBroughtBack": "이 붙여넣은 이미지를 이 초안과 함께 복원하지 못했습니다. 제거한 후 필요하면 다시 붙여넣으세요.", + "removeImageToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.", + "removeImagesToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.", + "imageAttachAgainLabel": "다시 첨부", + "pastedImageNotKeptLabel": "보존되지 않음", "imagePasteFailed": "이미지 붙여넣기 실패.", "worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.", "pasteUnavailable": "붙여넣을 수 없습니다 — 이 채팅은 지금 입력을 받을 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b7af46e09fd4..299df53d2358 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17507,9 +17507,12 @@ "localAttachmentUnsupported": "远程会话不支持本地附件。", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", - "imageNotSavedWithDraft": "{{name}} 未随此草稿保存。请重新附加后再发送。", - "attachImageAgain": "请重新附加该图片或将其移除", - "attachImagesAgain": "请重新附加这些图片或将其移除", + "imageNotBroughtBack": "无法随此草稿恢复 {{name}}。请重新附加或将其移除。", + "pastedImageNotBroughtBack": "无法随此草稿恢复这张粘贴的图片。请将其移除,如仍需要请重新粘贴。", + "removeImageToSend": "有一张图片无法恢复。请将其移除后再发送。", + "removeImagesToSend": "有些图片无法恢复。请将其移除后再发送。", + "imageAttachAgainLabel": "重新附加", + "pastedImageNotKeptLabel": "未保留", "imagePasteFailed": "图片粘贴失败。", "worktreeNotReady": "工作树未就绪 — 请稍后重试。", "pasteUnavailable": "无法粘贴 — 此聊天当前不接受输入。", From 0971ba94459f0abf15f8d8e2d0e4d3bdb54ce502 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:32:49 -0700 Subject: [PATCH 18/38] fix(native-chat): only composer pastes use the paste folder, and restore grants only real paths Every local clipboard image save had moved into the paste folder, whose macOS path has a space, so a screenshot pasted into a terminal or sent to a terminal-backed agent no longer attached. Only a native-chat composer paste goes there now, through an optional field on the existing save call; terminal, editor and phone pastes stay in the system temp folder as before. An image path sent to an agent's input is escaped the way a dropped image is. The restore re-grant now grants only the file's real path, and only when both the real path and the path as written are inside the paste folder, which must not itself be a link. The sweep deletes only Orca's paste files and skips a linked folder. --- .../window/clipboard-image-temp-file.test.ts | 21 ++++-- src/main/window/clipboard-image-temp-file.ts | 16 +++-- .../clipboard-runtime-owned-ssh-paste.test.ts | 4 +- .../window/native-chat-paste-files.test.ts | 65 +++++++++++++++++-- src/main/window/native-chat-paste-files.ts | 57 +++++++++++----- ...ui-bridge-clipboard-and-window-controls.ts | 2 + src/preload/api/ui-window-api.ts | 2 + .../native-chat-runtime-image-send.ts | 17 ++++- .../native-chat-runtime-send.test.ts | 10 +++ .../use-native-chat-composer-paste.ts | 2 +- 10 files changed, 159 insertions(+), 37 deletions(-) diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 8058426a6d1d..9f70970a9422 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -6,7 +6,9 @@ const { authorizeExternalPathMock, writeFileMock, mkdirMock, getPathMock, writeF authorizeExternalPathMock: vi.fn(), writeFileMock: vi.fn(), mkdirMock: vi.fn(), - getPathMock: vi.fn(() => '/Users/me/Library/Application Support/orca'), + getPathMock: vi.fn((name: string) => + name === 'temp' ? '/os/temp' : '/Users/me/Library/Application Support/orca' + ), writeFileBase64Mock: vi.fn() })) @@ -30,10 +32,20 @@ beforeEach(() => { }) describe('saveClipboardImageBufferAsTempFile', () => { - it('writes a local paste into the paste folder, where a restored draft can still find it', async () => { + it('keeps a terminal, editor or phone paste in OS temp, as before', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) - expect(getPathMock).toHaveBeenCalledWith('userData') + expect(getPathMock).toHaveBeenCalledWith('temp') + expect(getPathMock).not.toHaveBeenCalledWith('userData') + expect(mkdirMock).not.toHaveBeenCalled() + expect(dirname(savedPath)).toBe('/os/temp') + }) + + it('writes a native-chat composer paste into the paste folder, where its draft can find it', async () => { + const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3]), { + forNativeChatDraft: true + }) + expect(mkdirMock).toHaveBeenCalledWith( join('/Users/me/Library/Application Support/orca', 'native-chat-pastes'), { recursive: true } @@ -41,13 +53,14 @@ describe('saveClipboardImageBufferAsTempFile', () => { expect(dirname(savedPath)).toBe( join('/Users/me/Library/Application Support/orca', 'native-chat-pastes') ) + expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath) }) it('authorizes the local paste so the composer can preview what it just wrote', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) - // The paste folder is outside every allowed root, so an unauthorized path + // OS temp is outside every allowed root, so an unauthorized path // makes fs:readFile deny the preview read of Orca's own file. expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 715ecaeb7077..63526f515f05 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { getAppEnvironment } from '../../shared/app-environment' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' import { authorizeExternalPath } from '../ipc/filesystem-auth' @@ -11,6 +12,8 @@ import { nativeChatPasteFolder } from './native-chat-paste-files' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null runtimeEnvironmentId?: string | null + /** A native-chat composer paste: kept in Orca's paste folder so its draft can bring it back. */ + forNativeChatDraft?: boolean } const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' @@ -40,13 +43,16 @@ export async function saveClipboardImageBufferAsTempFile( return remotePath } - // Why the paste folder, not the OS temp dir: a draft that names the paste can still show and - // send it after a restart (native-chat-paste-files). - const folder = nativeChatPasteFolder() - await fs.mkdir(folder, { recursive: true }) + // Why only a composer paste goes to the paste folder: its draft can bring it back after a + // restart, while terminal, editor and phone pastes stay in OS temp, as they always have. + let folder = getAppEnvironment().getPath('temp') + if (args?.forNativeChatDraft === true) { + folder = nativeChatPasteFolder() + await fs.mkdir(folder, { recursive: true }) + } const tempPath = path.join(folder, fileName) await fs.writeFile(tempPath, buffer) - // Why: the paste folder is outside every allowed root, so without this the + // Why: both folders are outside every allowed root, so without this the // composer's own thumbnail/preview read of the file it just wrote is denied. authorizeExternalPath(tempPath) return tempPath diff --git a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts index 8ee4b04ec2f1..9020834ede89 100644 --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts @@ -210,11 +210,11 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => { expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() }) - it('keeps a plain local paste on this machine, in Orca’s paste folder', async () => { + it('keeps a plain local paste on the local temp dir', async () => { fsWriteFileMock.mockResolvedValue(undefined) await expect(saveImageHandler()(rendererEvent, undefined)).resolves.toMatch( - /native-chat-pastes[\\/]orca-paste-.*\.png$/ + /orca-paste-.*\.png$/ ) expect(fsWriteFileMock).toHaveBeenCalledTimes(1) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index dff721a10f04..5ab3f26abe45 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -111,8 +111,7 @@ describe('native-chat paste folder on disk', () => { { path: '', kept: false, exists: false } ]) const granted = authorizeExternalPathMock.mock.calls.map(([granted]) => granted) - expect(granted).toContain(realpathSync(kept)) - expect(granted.some((value: string) => value.includes('outside'))).toBe(false) + expect(granted).toEqual([realpathSync(kept)]) await expect(restoreNativeChatPastes('not a list')).resolves.toEqual([]) }) @@ -126,15 +125,71 @@ describe('native-chat paste folder on disk', () => { installFakeAppEnvironment({ getPath: () => alias }) const viaAlias = path.join(alias, 'native-chat-pastes', 'orca-paste-1.png') - await expect(restoreNativeChatPastes([viaAlias, kept])).resolves.toEqual([ + await expect(restoreNativeChatPastes([viaAlias, realpathSync(kept)])).resolves.toEqual([ { path: viaAlias, kept: true, exists: true }, - { path: kept, kept: true, exists: true } + { path: realpathSync(kept), kept: true, exists: true } ]) } finally { rmSync(alias, { force: true }) } }) + it('refuses a path that names an outside file as text while its real path is inside', async () => { + const secret = path.join(root, 'outside', 'id_rsa') + mkdirSync(path.dirname(secret), { recursive: true }) + writeFileSync(secret, 'PRIVATE KEY') + const paste = path.join(folder, 'orca-paste-1.png') + writeFileSync(paste, 'png') + // `s/..` resolves through a link for real, but by text it climbs to the secret. + const workspace = path.join(root, 'ws') + const depth = workspace.split(path.sep).filter(Boolean).length + 1 + const deep = path.join(workspace, ...Array.from({ length: depth }, (_, i) => `d${i}`)) + mkdirSync(deep, { recursive: true }) + symlinkSync(deep, path.join(workspace, 's')) + const tail = secret.slice(1) + mkdirSync(path.dirname(path.join(workspace, tail)), { recursive: true }) + symlinkSync(paste, path.join(workspace, tail)) + const crafted = `${workspace}/s/${'../'.repeat(depth)}${tail}` + expect(path.resolve(crafted)).toBe(secret) + + await expect(restoreNativeChatPastes([crafted])).resolves.toEqual([ + { path: crafted, kept: false, exists: false } + ]) + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + }) + + it('neither restores from nor sweeps a paste folder that is itself a link', async () => { + const outside = path.join(root, 'Documents') + mkdirSync(outside) + const old = (Date.now() - NATIVE_CHAT_PASTE_TTL_MS - 60_000) / 1000 + for (const name of ['orca-paste-1.png', 'tax-return.pdf']) { + writeFileSync(path.join(outside, name), 'x') + utimesSync(path.join(outside, name), old, old) + } + rmSync(folder, { recursive: true }) + symlinkSync(outside, folder) + + await expect(restoreNativeChatPastes([path.join(folder, 'orca-paste-1.png')])).resolves.toEqual( + [{ path: path.join(folder, 'orca-paste-1.png'), kept: false, exists: false }] + ) + await sweepExpiredNativeChatPastes() + expect(existsSync(path.join(outside, 'orca-paste-1.png'))).toBe(true) + expect(existsSync(path.join(outside, 'tax-return.pdf'))).toBe(true) + }) + + it('expires only Orca paste files, whatever else is in the folder', async () => { + const old = (Date.now() - NATIVE_CHAT_PASTE_TTL_MS - 60_000) / 1000 + for (const name of ['orca-paste-old.png', 'notes.txt']) { + writeFileSync(path.join(folder, name), 'x') + utimesSync(path.join(folder, name), old, old) + } + + await sweepExpiredNativeChatPastes() + + expect(existsSync(path.join(folder, 'orca-paste-old.png'))).toBe(false) + expect(existsSync(path.join(folder, 'notes.txt'))).toBe(true) + }) + it('reports nothing kept when the folder does not exist yet', async () => { rmSync(folder, { recursive: true }) await expect(restoreNativeChatPastes([path.join(folder, 'orca-paste-1.png')])).resolves.toEqual( @@ -175,7 +230,7 @@ describe('native-chat paste folder on disk', () => { await expect(sweepExpiredNativeChatPastes()).resolves.toBeUndefined() }) - it('keeps pastes longer than the host keeps a resend admissible', () => { + it('keeps a paste longer than one send id stays valid on the host', () => { expect(NATIVE_CHAT_PASTE_TTL_MS).toBeGreaterThan(AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) }) diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts index 6412d2ec0471..1789dc16b728 100644 --- a/src/main/window/native-chat-paste-files.ts +++ b/src/main/window/native-chat-paste-files.ts @@ -7,9 +7,11 @@ import { getAppEnvironment } from '../../shared/app-environment' import { NATIVE_CHAT_PASTE_FOLDER } from '../../shared/native-chat-paste-folder' import { authorizeExternalPath } from '../ipc/filesystem-auth' -// Why 30 days: far past the host's 24 h window for admitting a resent send that names a paste -// (AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS). +// Why 30 days: no age bounds what can still name a paste (a queued send is retried with a new id +// after the host's 24 h id window), so this is a judgment. A draft or outbox entry kept longer meets +// its image as a placeholder or a failed send, and a sent paste's file lingers until then. export const NATIVE_CHAT_PASTE_TTL_MS = 30 * 24 * 60 * 60 * 1000 +const PASTE_FILE_NAME = /^orca-paste-.+\.png$/i const MAX_RESTORED_PASTES = 256 type PathApi = typeof path.posix @@ -54,36 +56,52 @@ export async function restoreNativeChatPastes(paths: unknown): Promise - typeof value === 'string' ? [restoreNativeChatPaste(folder, value)] : [] + typeof value === 'string' ? [restoreNativeChatPaste(folders, value)] : [] ) ) } +/** The paste folder as configured and as real path; null when missing or itself a link. */ +async function realPasteFolder(): Promise<{ named: string; real: string } | null> { + try { + const named = path.resolve(nativeChatPasteFolder()) + const info = await lstat(named) + return info.isDirectory() && !info.isSymbolicLink() + ? { named, real: await realpath(named) } + : null + } catch { + return null + } +} + async function restoreNativeChatPaste( - folder: string | null, + folders: { named: string; real: string } | null, restored: string ): Promise { const refused = { path: restored, kept: false, exists: false } - if (folder === null || restored === '' || !path.isAbsolute(restored)) { + if (folders === null || restored === '' || !path.isAbsolute(restored)) { + return refused + } + // Why both: the text a grant would cover and the file it really names must each be inside. + const named = path.resolve(restored) + if ( + !isInsideNativeChatPasteFolder(folders.named, named) && + !isInsideNativeChatPasteFolder(folders.real, named) + ) { return refused } try { const real = await realpath(restored) - if (!isInsideNativeChatPasteFolder(folder, real) || !(await stat(real)).isFile()) { + if (!isInsideNativeChatPasteFolder(folders.real, real) || !(await stat(real)).isFile()) { return refused } + // Only the real file: granting the given text would cover whatever it names lexically. authorizeExternalPath(real) - authorizeExternalPath(restored) return { path: restored, kept: true, exists: true } } catch { // Missing or unreadable: not kept, and nothing about an outside path is reported. @@ -91,19 +109,22 @@ async function restoreNativeChatPaste( } } -/** Deletes pastes older than the TTL. Symlinks and folders are skipped, never followed; failures - * are logged and never block startup. */ +/** Deletes pastes older than the TTL: only Orca's paste files, never a link, a folder, or anything + * in a paste folder that is itself a link. Failures are logged and never block startup. */ export async function sweepExpiredNativeChatPastes(now = Date.now()): Promise { - let folder: string + const folders = await realPasteFolder() + if (!folders) { + return + } + const folder = folders.named let entries try { - folder = nativeChatPasteFolder() entries = await readdir(folder, { withFileTypes: true }) } catch { return } for (const entry of entries) { - if (!entry.isFile()) { + if (!entry.isFile() || !PASTE_FILE_NAME.test(entry.name)) { continue } const file = path.join(folder, entry.name) diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 8f79e5860453..b7d9345daf70 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -93,6 +93,8 @@ export const uiClipboardAndWindowControlsApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + /** A native-chat composer paste, kept where its draft can bring it back. */ + forNativeChatDraft?: boolean }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), clipboardHasImage: (): Promise => ipcRenderer.invoke('clipboard:hasImage'), readClipboardFilePaths: (): Promise => ipcRenderer.invoke('clipboard:readFilePaths'), diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index 83f1d9122666..a4ff4a23b9b8 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -12,6 +12,8 @@ export type UiWindowApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + /** A native-chat composer paste, kept where its draft can bring it back. */ + forNativeChatDraft?: boolean }) => Promise clipboardHasImage: () => Promise /** Paths of files a file manager copied; empty when there are none or the host cannot list them. */ diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index 283a437e449a..a4e793233173 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -10,6 +10,8 @@ import { NATIVE_CHAT_SUBMIT } from './native-chat-send' import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' +import { formatImageDropPasteText } from '../terminal-pane/terminal-drop-image-path' +import { isTerminalDropWindowsPathLike } from '../terminal-pane/terminal-drop-shell' import { clearConfirmDurationMs, clearThenWrite, @@ -22,6 +24,17 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 type RuntimeSettings = ReturnType +/** The path text an agent detects as an image. One with spaces or shell characters is escaped as a + * terminal image drop is, which agent TUIs undo before checking the file exists. */ +export function agentImagePastePath(agent: AgentType, path: string): string { + const formatted = formatAgentImagePath(agent, path) + if (formatted !== path) { + return formatted + } + const targetShell = isTerminalDropWindowsPathLike(path) ? 'windows' : 'posix' + return formatImageDropPasteText(path, targetShell) ?? path +} + export function sendNativeChatMessageWithImageAttachments( agent: AgentType, settings: RuntimeSettings, @@ -37,7 +50,7 @@ export function sendNativeChatMessageWithImageAttachments( if (options?.onWriteRejected) { const writes = agentImagePasteWrites( agent, - imagePaths.map((path) => buildNativeChatImagePasteBytes(formatAgentImagePath(agent, path))), + imagePaths.map((path) => buildNativeChatImagePasteBytes(agentImagePastePath(agent, path))), trimmedText.length > 0 ).map((data) => ({ data, delayBeforeMs: 0 })) if (trimmedText) { @@ -67,7 +80,7 @@ export function sendNativeChatMessageWithImageAttachments( for (const payload of agentImagePasteWrites( agent, imagePaths.map((path) => - buildNativeChatImagePasteBytes(formatAgentImagePath(agent, path)) + buildNativeChatImagePasteBytes(agentImagePastePath(agent, path)) ), trimmedText.length > 0 )) { diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 9e6a8ff2b0fc..a2b7aa92b570 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -338,6 +338,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => { resetNativeChatPtySendQueuesForTests() }) + it('escapes a pasted image path with spaces, so an attachment agent sees one path', () => { + sendNativeChatMessageWithImageAttachments('codex', SETTINGS, PTY, '', [ + '/Users/me/Library/Application Support/orca/native-chat-pastes/orca-paste-1-ab.png' + ]) + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '\x1b[200~/Users/me/Library/Application\\ Support/orca/native-chat-pastes/orca-paste-1-ab.png\x1b[201~' + ]) + }) + it.each(['', 'describe'])('separates every OMP image reference before %j', (text) => { sendNativeChatMessageWithImageAttachments('omp', SETTINGS, PTY, text, [ '/tmp/a.png', 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 6899ee76905e..a5cd254590d4 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 @@ -104,7 +104,7 @@ export function useNativeChatComposerPaste({ // SSH panes save the image on the remote host (SFTP) so the attached // path is readable by the remote agent, matching terminal image paste. const tempPath = await window.api.ui.saveClipboardImageAsTempFile( - owner.kind === 'ssh' ? { connectionId: owner.connectionId } : undefined + owner.kind === 'ssh' ? { connectionId: owner.connectionId } : { forNativeChatDraft: true } ) return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } } catch (error) { From c640f75300958a810180945785aee7ee1455a3cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:44:40 -0700 Subject: [PATCH 19/38] fix(native-chat): a restored paste is readable by the path its draft stored Restore granted only a paste's real path, but the composer reads the preview by the path its draft stored, so with user data reached through a link the restored chip showed a generic icon. The stored spelling is granted too; it is already proven to sit inside the paste folder. A test also pins that a composer paste asks for the paste folder. --- .../window/native-chat-paste-files.test.ts | 26 ++++++++++++++++--- src/main/window/native-chat-paste-files.ts | 3 ++- .../use-native-chat-composer-paste.test.tsx | 11 ++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index 5ab3f26abe45..bdc9e502bcda 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -14,9 +14,17 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' +import type * as FilesystemAuth from '../ipc/filesystem-auth' +import { isPathAllowed } from '../ipc/filesystem-auth' +import type { Store } from '../persistence' const { authorizeExternalPathMock } = vi.hoisted(() => ({ authorizeExternalPathMock: vi.fn() })) -vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) +// Why the real grant behind the spy: the tests check what a read is then allowed to reach. +vi.mock('../ipc/filesystem-auth', async (importOriginal) => { + const actual = await importOriginal() + authorizeExternalPathMock.mockImplementation(actual.authorizeExternalPath) + return { ...actual, authorizeExternalPath: authorizeExternalPathMock } +}) import { NATIVE_CHAT_PASTE_TTL_MS, @@ -25,6 +33,14 @@ import { sweepExpiredNativeChatPastes } from './native-chat-paste-files' +// A store with no workspaces, so only the grants decide what a read may reach. +const NO_ROOTS_STORE: Store = Object.assign(Object.create(null), { + getRepos: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) +}) + describe('isInsideNativeChatPasteFolder', () => { const posixFolder = '/data/native-chat-pastes' const winFolder = 'C:\\Users\\Me\\AppData\\Roaming\\Orca\\native-chat-pastes' @@ -70,7 +86,7 @@ describe('native-chat paste folder on disk', () => { let folder: string beforeEach(() => { - authorizeExternalPathMock.mockReset() + authorizeExternalPathMock.mockClear() root = mkdtempSync(path.join(tmpdir(), 'orca-native-chat-pastes-')) folder = path.join(root, 'native-chat-pastes') mkdirSync(folder) @@ -111,7 +127,7 @@ describe('native-chat paste folder on disk', () => { { path: '', kept: false, exists: false } ]) const granted = authorizeExternalPathMock.mock.calls.map(([granted]) => granted) - expect(granted).toEqual([realpathSync(kept)]) + expect(new Set(granted)).toEqual(new Set([realpathSync(kept), kept])) await expect(restoreNativeChatPastes('not a list')).resolves.toEqual([]) }) @@ -129,6 +145,9 @@ describe('native-chat paste folder on disk', () => { { path: viaAlias, kept: true, exists: true }, { path: realpathSync(kept), kept: true, exists: true } ]) + // The preview reads by the stored spelling, so a read by it is allowed too. + expect(isPathAllowed(viaAlias, NO_ROOTS_STORE)).toBe(true) + expect(isPathAllowed(realpathSync(kept), NO_ROOTS_STORE)).toBe(true) } finally { rmSync(alias, { force: true }) } @@ -156,6 +175,7 @@ describe('native-chat paste folder on disk', () => { { path: crafted, kept: false, exists: false } ]) expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(isPathAllowed(secret, NO_ROOTS_STORE)).toBe(false) }) it('neither restores from nor sweeps a paste folder that is itself a link', async () => { diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts index 1789dc16b728..75c7faee3e5d 100644 --- a/src/main/window/native-chat-paste-files.ts +++ b/src/main/window/native-chat-paste-files.ts @@ -100,8 +100,9 @@ async function restoreNativeChatPaste( if (!isInsideNativeChatPasteFolder(folders.real, real) || !(await stat(real)).isFile()) { return refused } - // Only the real file: granting the given text would cover whatever it names lexically. + // The real file, and the stored spelling the preview reads by: both were proven inside above. authorizeExternalPath(real) + authorizeExternalPath(named) return { path: restored, kept: true, exists: true } } catch { // Missing or unreadable: not kept, and nothing about an outside path is reported. diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index 44c1f59c3c39..cca232c91401 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -269,6 +269,17 @@ describe('useNativeChatComposerPaste', () => { } ) + it('saves a local paste where its draft can bring it back after a restart', async () => { + mocks.saveClipboardImageAsTempFile.mockResolvedValue( + '/Users/me/Library/Application Support/orca/native-chat-pastes/orca-paste-1.png' + ) + const probe = await renderProbe({ resolveAttachmentOwner: () => ({ kind: 'local' }) }) + await act(async () => { + probe.latest().handlePaste(imagePasteEvent()) + }) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledWith({ forNativeChatDraft: true }) + }) + it('saves on the SSH host and settles the chip on the returned remote path', async () => { mocks.saveClipboardImageAsTempFile.mockResolvedValue('/remote/tmp/orca-paste-1.png') const store = createChipStore() From 16c61f6a8df533b830acd62aab47e1c747b418d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:46:15 -0700 Subject: [PATCH 20/38] fix(native-chat): grant a paste's stored spelling only when it names the same file A grant also covers its path's own real path, so a stored spelling that is itself a link inside the paste folder could have granted an outside file while the path as restored reached a real paste. The stored spelling is now granted only when it resolves to the same file; otherwise only the real path is, and the preview falls back to the generic icon. --- .../window/native-chat-paste-files.test.ts | 20 +++++++++++++++++++ src/main/window/native-chat-paste-files.ts | 11 ++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index bdc9e502bcda..e8d9a626fccd 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -178,6 +178,26 @@ describe('native-chat paste folder on disk', () => { expect(isPathAllowed(secret, NO_ROOTS_STORE)).toBe(false) }) + it('grants the stored spelling only when it names the same file as the real path', async () => { + const secret = path.join(root, 'outside', 'id_rsa') + mkdirSync(path.dirname(secret), { recursive: true }) + writeFileSync(secret, 'PRIVATE KEY') + // `folder/link/../y` reaches a real paste through `link`, while `folder/y` by text is a link out. + const sub = path.join(folder, 'sub') + mkdirSync(sub) + mkdirSync(path.join(sub, 'deeper')) + writeFileSync(path.join(sub, 'orca-paste-y.png'), 'png') + symlinkSync(path.join(sub, 'deeper'), path.join(folder, 'link')) + symlinkSync(secret, path.join(folder, 'orca-paste-y.png')) + const restored = `${folder}/link/../orca-paste-y.png` + expect(realpathSync.native(restored)).toBe(realpathSync(path.join(sub, 'orca-paste-y.png'))) + + await expect(restoreNativeChatPastes([restored])).resolves.toEqual([ + { path: restored, kept: true, exists: true } + ]) + expect(isPathAllowed(secret, NO_ROOTS_STORE)).toBe(false) + }) + it('neither restores from nor sweeps a paste folder that is itself a link', async () => { const outside = path.join(root, 'Documents') mkdirSync(outside) diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts index 75c7faee3e5d..d337f2f3e3d5 100644 --- a/src/main/window/native-chat-paste-files.ts +++ b/src/main/window/native-chat-paste-files.ts @@ -100,9 +100,16 @@ async function restoreNativeChatPaste( if (!isInsideNativeChatPasteFolder(folders.real, real) || !(await stat(real)).isFile()) { return refused } - // The real file, and the stored spelling the preview reads by: both were proven inside above. authorizeExternalPath(real) - authorizeExternalPath(named) + // The stored spelling the preview reads by, only when it names that same file: a grant also + // covers the spelling's own real path, which a link inside the folder could point elsewhere. + const sameFile = await realpath(named).then( + (namedReal) => namedReal === real, + () => false + ) + if (sameFile) { + authorizeExternalPath(named) + } return { path: restored, kept: true, exists: true } } catch { // Missing or unreadable: not kept, and nothing about an outside path is reported. From 4b9f91a5a06876d2c62eada42bd4a0d8bc28306d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:46:30 -0700 Subject: [PATCH 21/38] test(native-chat): check the outside file by its real path too --- src/main/window/native-chat-paste-files.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index e8d9a626fccd..8f645c3fd892 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -196,6 +196,7 @@ describe('native-chat paste folder on disk', () => { { path: restored, kept: true, exists: true } ]) expect(isPathAllowed(secret, NO_ROOTS_STORE)).toBe(false) + expect(isPathAllowed(realpathSync(secret), NO_ROOTS_STORE)).toBe(false) }) it('neither restores from nor sweeps a paste folder that is itself a link', async () => { From edd24361150aee54c89e642c049c64d7087205e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:13:16 -0700 Subject: [PATCH 22/38] fix(native-chat): a structured chat's draft belongs to its conversation Drafts of a structured chat were saved under the pane (tab id plus a hash of the session), so the follow-up that keys them by conversation would have left every draft saved by this build invisible after an upgrade, never shown and never deleted. They are now keyed by the conversation (`agent-session:`) from the start: every composer showing the conversation shares one draft, Stop and a queued card's Edit give text back to it, closing the tab keeps it, and removing the worktree deletes it. Terminal-backed chats keep the pane key and lose their draft when the tab is closed. A send now leaves whatever was added since it was sent, text typed or composed and images attached meanwhile, and clears only what was sent; a draft replaced in the meantime is left alone. Lifted from #25207 (62ef8e78042): the conversation key, the composer's draftScopeKey, keep-on-close and delete-on-worktree-removal, and the leave-what-was-added send settle. --- .../native-chat/NativeChatComposer.tsx | 16 ++-- .../native-chat/NativeChatComposerField.tsx | 13 +-- .../NativeChatStructuredSession.test.tsx | 13 ++- .../NativeChatStructuredSession.tsx | 5 +- .../native-chat-composer-autogrow.test.tsx | 3 +- .../native-chat-composer-composition.test.tsx | 11 ++- ...ive-chat-composer-draft-comparison.test.ts | 39 +++++++++ .../native-chat-composer-draft-comparison.ts | 46 +++++++++++ ...ive-chat-composer-draft-lifecycle.test.tsx | 80 ++++++++++++++++++- .../native-chat-composer-draft-store.test.ts | 22 +++++ .../native-chat-composer-draft-store.ts | 70 ++++++++-------- .../native-chat-composer-drop-scope.test.tsx | 45 ++++++++++- .../native-chat/native-chat-composer-types.ts | 3 + ...chat-composer-workspace-file-drop.test.tsx | 3 +- ...tructured-session-takeover-report.test.tsx | 2 +- ...ive-chat-structured-composer-send.test.tsx | 2 +- ...se-native-chat-structured-composer-send.ts | 43 +++++----- .../native-chat-launch-draft-teardown.test.ts | 38 +++++++-- .../store/slices/tabs/tabs-close-actions.ts | 3 +- .../removed-worktree-renderer-teardown.ts | 14 +++- 20 files changed, 377 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index bd9e6862788f..b016dc98351a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -55,6 +55,7 @@ const NativeChatComposerPane = forwardRef draft: string disabled: boolean @@ -101,7 +103,8 @@ function imeComposedSegment(base: string, settled: string): string { } export function NativeChatComposerField({ - composerScopeKey, + dropScopeKey, + draftScopeKey, textareaRef, draft, disabled, @@ -198,7 +201,7 @@ export function NativeChatComposerField({ ) : null}
) : null} moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' describe('NativeChatStructuredSession', () => { afterEach(() => { @@ -35,7 +36,7 @@ describe('NativeChatStructuredSession', () => { resetStructuredSessionMocks() }) - it('gives what a Stop withdrew back to the composer this pane shows', () => { + it("gives the composer this pane shows the conversation's own draft, and Stop returns text there", () => { render( { /> ) const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1') - expect(mocks.composerProps).toMatchObject({ paneKey }) - expect(mocks.controllerProps).toMatchObject({ composerScopeKey: paneKey }) + // The pane routes drops and pickers; the draft belongs to the conversation, whatever pane shows it. + expect(mocks.composerProps).toMatchObject({ + paneKey, + draftScopeKey: structuredAgentSessionDraftScopeKey('session-1') + }) + expect(mocks.controllerProps).toMatchObject({ + composerScopeKey: structuredAgentSessionDraftScopeKey('session-1') + }) }) it('routes the launch draft and app-menu paste to the structured composer', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index eca921ed086c..84b3dab2559a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -5,6 +5,7 @@ import { structuredAgentSessionPaneKey } from '../../../../shared/structured-age import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatApprovalCard } from './NativeChatApprovalCard' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' +import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatLoadingCue } from './NativeChatLoadingCue' import { NativeChatMessageList } from './NativeChatMessageList' @@ -54,7 +55,8 @@ export function NativeChatStructuredSession( const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false) const controller = useStructuredAgentSession({ ...props, - composerScopeKey: paneKey, + // Why: Stop and a queued card's Edit give text back to the conversation's draft, as the composer keeps it. + composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, providerStarting: startupPhase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, @@ -390,6 +392,7 @@ export function NativeChatStructuredSession( ref={composerRef} terminalTabId={props.tabId} paneKey={paneKey} + draftScopeKey={structuredAgentSessionDraftScopeKey(props.sessionId)} targetPtyId={null} agent={props.agent} canSend={!prompt} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx index 826121db9752..52b1e09346f3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx @@ -46,7 +46,8 @@ function TestField({ const imeEnterGesture = useImeEnterGestureOwnership() return ( ()} draft={draft} disabled={false} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index 8749d20ad39f..c1f6dd24cb5f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -35,7 +35,8 @@ function TestField(props: TestFieldProps): React.JSX.Element { function fieldProps(overrides: Partial = {}): TestFieldProps { return { - composerScopeKey: 'pane-test', + dropScopeKey: 'pane-test', + draftScopeKey: 'pane-test', textareaRef: createRef(), draft: '', disabled: false, @@ -79,8 +80,12 @@ function textarea(): HTMLTextAreaElement { describe('native chat composer drop-scope marker', () => { // The drop pipeline stops walking at the drop-target marker, so a scope key on // any other element would never reach the payload. - it('publishes the scope key on the same element as the drop-target marker', () => { - const view = render() + it('publishes the pane key, not the draft key, on the same element as the drop-target marker', () => { + const view = render( + + ) const marker = view.container.querySelector('[data-native-file-drop-target="composer"]') expect(marker).not.toBeNull() expect(marker?.getAttribute('data-composer-scope-key')).toBe('tab-7:pane-9') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts new file mode 100644 index 000000000000..55ed85162276 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' + +const SHOT = { id: 'a-1', path: '/repo/shot.png' } +const DIAGRAM = { id: 'a-2', path: '/repo/diagram.png' } +const SENT = { text: 'abcd', images: [SHOT] } + +describe('nativeChatComposerDraftLeftAfterSend', () => { + it('leaves nothing of a draft still as sent', () => { + expect(nativeChatComposerDraftLeftAfterSend(SENT, SENT)).toEqual({ text: '', images: [] }) + }) + + it('keeps text typed after, and images attached, since the send', () => { + expect( + nativeChatComposerDraftLeftAfterSend({ text: 'abcd more', images: [SHOT, DIAGRAM] }, SENT) + ).toEqual({ text: ' more', images: [DIAGRAM] }) + }) + + it('keeps text composed inside the sent text, away from its end', () => { + expect(nativeChatComposerDraftLeftAfterSend({ text: 'ab가cd', images: [] }, SENT)).toEqual({ + text: '가', + images: [] + }) + }) + + it('leaves a draft replaced or edited inside the sent text alone', () => { + expect(nativeChatComposerDraftLeftAfterSend({ text: 'new', images: [SHOT] }, SENT)).toBeNull() + expect(nativeChatComposerDraftLeftAfterSend({ text: 'abXd', images: [] }, SENT)).toBeNull() + }) + + it('keeps the whole text when only images were sent', () => { + expect( + nativeChatComposerDraftLeftAfterSend( + { text: 'typed', images: [SHOT] }, + { text: '', images: [SHOT] } + ) + ).toEqual({ text: 'typed', images: [] }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts new file mode 100644 index 000000000000..6898edc78302 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts @@ -0,0 +1,46 @@ +// How two copies of a composer draft compare, such as the draft now against the one that was sent. + +import type { + NativeChatComposerDraft, + NativeChatComposerDraftImage +} from './native-chat-composer-draft-storage' + +export function sameNativeChatComposerDraftImages( + left: readonly NativeChatComposerDraftImage[], + right: readonly NativeChatComposerDraftImage[] +): boolean { + return ( + left.length === right.length && + left.every( + (image, index) => + image.id === right[index].id && + image.path === right[index].path && + image.connectionId === right[index].connectionId && + image.unavailableName === right[index].unavailableName + ) + ) +} + +/** + * What a draft keeps once its send is accepted: anything added since, meaning text inserted into + * the sent text (typing or a composition begun before the send settled) and images attached + * meanwhile. Null when the draft was otherwise changed since (replaced, or edited inside the sent + * text), which is then left as it is. + */ +export function nativeChatComposerDraftLeftAfterSend( + current: NativeChatComposerDraft, + sent: NativeChatComposerDraft +): NativeChatComposerDraft | null { + let prefix = 0 + while (prefix < sent.text.length && sent.text[prefix] === current.text[prefix]) { + prefix += 1 + } + const rest = sent.text.slice(prefix) + if (current.text.length < sent.text.length || !current.text.endsWith(rest)) { + return null + } + return { + text: current.text.slice(prefix, current.text.length - rest.length), + images: current.images.filter((image) => !sent.images.some((held) => held.id === image.id)) + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 8f50952821c7..7d742eecfba1 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, createElement, useState } from 'react' +import { act, createElement, Fragment, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type * as DraftHook from './use-native-chat-draft' @@ -149,7 +149,7 @@ function composer( draftScopeKey: scopeKey, imageAttachments: attachments.imageAttachments, structuredTransport: transport, - clearImageAttachments: attachments.clearImageAttachments, + isComposing: () => false, clearSkillOrigin: () => {}, setHistory: () => {}, setDraft, @@ -299,6 +299,82 @@ describe('native-chat composer draft lifecycle', () => { ]) }) + it('clears an accepted send’s text and images from the shown composer', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next), held.transport), + { scopeKey: 'agent-session:s1' } + ) + ) + await act(async () => { + seen.api?.setDraft('sent text') + seen.api?.attachments.attachResolvedPaths(['/repo/a.png']) + }) + await act(async () => seen.api?.send('sent text')) + + await held.settle() + + expect(seen.api?.draft).toBe('') + expect(seen.api?.attachments.imageAttachments).toEqual([]) + expect(storedDraft('agent-session:s1')).toBeNull() + }) + + it('leaves a draft replaced before the send was accepted as it is', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next), held.transport), + { scopeKey: 'agent-session:s1' } + ) + ) + await act(async () => seen.api?.setDraft('sent text')) + await act(async () => seen.api?.send('sent text')) + await act(async () => seen.api?.setDraft('typed while it was on its way')) + + await held.settle() + + expect(seen.api?.draft).toBe('typed while it was on its way') + }) + + it('shares one conversation’s draft between its composers, and keeps what the other added', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const sender: { api?: ComposerApi } = {} + const other: { api?: ComposerApi } = {} + await mount( + createElement( + Fragment, + null, + createElement( + composer(hooks, (next) => (sender.api = next), held.transport), + { + scopeKey: 'agent-session:s1' + } + ), + createElement( + composer(hooks, (next) => (other.api = next)), + { + scopeKey: 'agent-session:s1' + } + ) + ) + ) + await act(async () => sender.api?.setDraft('sent text')) + expect(other.api?.draft).toBe('sent text') + await act(async () => sender.api?.send('sent text')) + await act(async () => other.api?.setDraft((previous) => `${previous} and more`)) + + await held.settle() + + expect(sender.api?.draft).toBe(' and more') + expect(other.api?.draft).toBe(' and more') + }) + it('keeps a shown composer’s pasted image when many other drafts are written', async () => { const hooks = await loadHooks() const drafts = await import('./native-chat-draft-cache') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index f8a7ec1f8304..f5bb76023bad 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -484,6 +484,28 @@ describe('native-chat composer draft store', () => { expect(reloaded.drafts.readNativeChatDraftCache('tab-10:pane')).toBe('other tab') }) + it('keeps a conversation’s draft when a tab is closed, and drops it when deleted by its key', async () => { + const conversation = modules.store.structuredAgentSessionDraftScopeKey('session-1') + expect(conversation).toBe('agent-session:session-1') + modules.drafts.writeNativeChatDraftCache(conversation, 'unsent') + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'pane draft') + modules.store.flushNativeChatComposerDrafts() + // The prefix before the conversation key's ':' must never read as a tab id. + modules.store.deleteNativeChatComposerDraftsForTab('agent-session') + modules.store.deleteNativeChatComposerDraftsForTab('tab-1') + expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('unsent') + + const listener = vi.fn() + modules.store.subscribeToNativeChatComposerDraft(conversation, listener) + modules.drafts.writeNativeChatDraftCache(conversation, 'unsent, still deferred') + modules.store.deleteNativeChatComposerDraft(conversation) + expect(listener).toHaveBeenCalled() + expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('') + modules.store.flushNativeChatComposerDrafts() + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache(conversation)).toBe('') + }) + it('keeps the drafts of a tab whose id extends the closed one', async () => { // A second chat for one session gets `:history-1`, so a prefix match would reach it. const closed = 'structured-agent-session-claude_1' 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 e5b79ba9b09e..9c9e9720fad4 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 @@ -5,6 +5,7 @@ import type { JSONContent } from '@tiptap/react' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { sameNativeChatComposerDraftImages } from './native-chat-composer-draft-comparison' import { basename } from '@/lib/path' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { @@ -107,22 +108,6 @@ function isEmptyDraft(draft: NativeChatComposerDraft): boolean { return draft.text === '' && draft.images.length === 0 } -function sameImages( - left: readonly NativeChatComposerDraftImage[], - right: readonly NativeChatComposerDraftImage[] -): boolean { - return ( - left.length === right.length && - left.every( - (image, index) => - image.id === right[index].id && - image.path === right[index].path && - image.connectionId === right[index].connectionId && - image.unavailableName === right[index].unavailableName - ) - ) -} - /** Monotonic within a run, so drafts changed in the same millisecond still age in order. */ function nextSavedAt(): number { lastSavedAt = Math.max(Date.now(), lastSavedAt + 1) @@ -274,7 +259,7 @@ export function updateNativeChatComposerDraft( text === current.text && document === current.document && unsavedText === current.unsavedText && - sameImages(images, current.images) + sameNativeChatComposerDraftImages(images, current.images) ) { if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { flushNativeChatComposerDrafts() @@ -318,38 +303,57 @@ export function clearNativeChatComposerDraftIfUnchanged( sent: NativeChatComposerDraft ): boolean { const current = readNativeChatComposerDraft(scopeKey) - if (current.text !== sent.text || !sameImages(current.images, sent.images)) { + if ( + current.text !== sent.text || + !sameNativeChatComposerDraftImages(current.images, sent.images) + ) { return false } updateNativeChatComposerDraft(scopeKey, { text: '', images: [] }, 'immediate') return true } -/** A pane key is `:`; the leaf is a UUID, while a tab id may hold ':' itself. */ -function scopeTabId(scopeKey: string): string { - return scopeKey.slice(0, scopeKey.lastIndexOf(':')) +const STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX = 'agent-session:' + +/** A structured chat's draft belongs to its conversation, so it outlives the tab showing it. */ +export function structuredAgentSessionDraftScopeKey(sessionId: string): string { + return `${STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX}${sessionId}` } -/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. */ -export function deleteNativeChatComposerDraftsForTab(tabId: string): void { - const inTab = (scopeKey: string): boolean => scopeTabId(scopeKey) === tabId - for (const scopeKey of records.keys()) { - if (inTab(scopeKey)) { - records.delete(scopeKey) - dirtyScopes.delete(scopeKey) - } - } +function deleteDraftsWhere(owned: (scopeKey: string) => boolean): void { const storage = nativeChatComposerDraftStorage() if (storage) { - removeStoredNativeChatComposerDraftsWhere(storage, inTab) + removeStoredNativeChatComposerDraftsWhere(storage, owned) } - for (const scopeKey of scopeListeners.keys()) { - if (inTab(scopeKey)) { + for (const scopeKey of new Set([...records.keys(), ...scopeListeners.keys()])) { + if (owned(scopeKey)) { + records.delete(scopeKey) + dirtyScopes.delete(scopeKey) notifyScope(scopeKey) } } } +/** Drops one scope's draft, for an owner that is gone for good. */ +export function deleteNativeChatComposerDraft(scopeKey: string): void { + deleteDraftsWhere((key) => key === scopeKey) +} + +/** A pane key is `:`; the leaf is a UUID, while a tab id may hold ':' itself. */ +function scopeTabId(scopeKey: string): string { + return scopeKey.slice(0, scopeKey.lastIndexOf(':')) +} + +/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. A + * conversation's draft is never a tab's. */ +export function deleteNativeChatComposerDraftsForTab(tabId: string): void { + deleteDraftsWhere( + (scopeKey) => + !scopeKey.startsWith(STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX) && + scopeTabId(scopeKey) === tabId + ) +} + export function clearNativeChatComposerDraftsForTests(): void { if (flushTimer !== null) { clearTimeout(flushTimer) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 508a7c20e5ea..25782623dfd7 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -53,11 +53,20 @@ import { } from '../../../../preload/preload-runtime-support' // Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache. -function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) { +function ComposerProbe({ + pane, + draft = pane, + hidden = false +}: { + pane: string + /** The draft's owner; a structured chat's composers share their conversation's. */ + draft?: string + hidden?: boolean +}) { const textareaRef = useRef(null) const [notice, setNotice] = useState(null) const attachments = useNativeChatComposerAttachments({ - attachmentScopeKey: pane, + attachmentScopeKey: draft, allowWithoutTarget: true, caret: 0, disabled: false, @@ -79,7 +88,7 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index ceb812e6291c..0d9342df2a7f 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -104,7 +104,9 @@ export function NativeChatPromptEditor({ ) }, onTransaction: ({ editor: current, transaction }) => { - if (scopeKey && transaction.docChanged) { + // Why: a value set from outside (the store's own draft, another window's, a late load) + // is already the store's; saving it back would make it a local change of this window. + if (scopeKey && transaction.docChanged && !transaction.getMeta('preventUpdate')) { writeNativeChatDraftDocument( scopeKey, promptTextMap(current.state.doc).text, @@ -148,6 +150,12 @@ export function NativeChatPromptEditor({ if (old.text === value) { return } + // A draft loaded or adopted whole keeps its skill chips: its stored document wins. + const stored = scopeKey ? readNativeChatDraftDocument(scopeKey, value) : undefined + if (stored) { + editor.commands.setContent(stored, { emitUpdate: false }) + return + } if (!value) { editor.commands.setContent(promptTextContent(''), { emitUpdate: false }) return @@ -231,7 +239,7 @@ export function NativeChatPromptEditor({ } } : null, - [editor] + [editor, scopeKey] ) useImperativeHandle(inputRef, () => input!, [input]) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts index 6898edc78302..53ef375c8547 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts @@ -1,10 +1,23 @@ // How two copies of a composer draft compare, such as the draft now against the one that was sent. +import type { JSONContent } from '@tiptap/react' import type { NativeChatComposerDraft, NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' +/** Equal documents count as unchanged even as new objects: an editor applying the stored draft + * produces its own copy, which must not read as a change to save. */ +export function sameNativeChatComposerDraftDocument( + left: JSONContent | undefined, + right: JSONContent | undefined +): boolean { + return ( + left === right || + (left !== undefined && right !== undefined && JSON.stringify(left) === JSON.stringify(right)) + ) +} + export function sameNativeChatComposerDraftImages( left: readonly NativeChatComposerDraftImage[], right: readonly NativeChatComposerDraftImage[] diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts index 39a7c131d749..a96692884d95 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts @@ -2,6 +2,7 @@ // re-grant its preview, which main does only for files really inside that folder; any other image // goes through the existing existence check (the workspace's read rules locally, the host over SSH). +import { useEffect, useSyncExternalStore } from 'react' import type { NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' import { isKeptLocalPaste, @@ -89,3 +90,20 @@ async function replaceMissingImages(scopeKey: string): Promise { 'immediate' ) } + +/** Whether the scope's restored images still wait on their check, which runs as they arrive: at + * mount, or later when the startup load or another window brings new ones. */ +export function useRestoredNativeChatComposerDraftImageCheck( + scopeKey: string, + subscribe: (listener: () => void) => () => void +): boolean { + const restoring = useSyncExternalStore(subscribe, () => + isNativeChatComposerDraftUnverified(scopeKey) + ) + useEffect(() => { + if (restoring) { + void verifyRestoredNativeChatComposerDraftImages(scopeKey) + } + }, [scopeKey, restoring]) + return restoring +} 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 new file mode 100644 index 000000000000..7434906fb39d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts @@ -0,0 +1,98 @@ +// The unload journal: changes storage had not confirmed when a window went away, written +// synchronously to localStorage and replayed by the next load. Every window of the app shares it, +// so each entry is pruned the moment any window confirms a change to that draft. + +import type { UnconfirmedDraftChange } from './native-chat-composer-draft-memory' +import { parseStoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' + +const JOURNAL_KEY = 'orca:nativeChatComposerDraftJournal:v1' +// Why: localStorage also holds the send outbox, and a send is refused when its entry can't be +// saved, so the journal never takes more than this. +export const MAX_JOURNAL_CHARS = 256_000 + +type JournalEntry = UnconfirmedDraftChange & { readonly scopeKey: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readEntries(): JournalEntry[] { + try { + const raw = localStorage.getItem(JOURNAL_KEY) + const entries: unknown = raw === null ? [] : JSON.parse(raw) + if (!Array.isArray(entries)) { + return [] + } + return entries.flatMap((entry: unknown) => { + if (!isRecord(entry)) { + return [] + } + const { scopeKey, draft, at } = entry + return typeof scopeKey === 'string' && typeof at === 'number' + ? [{ scopeKey, draft: parseStoredNativeChatComposerDraft(draft), at }] + : [] + }) + } catch { + return [] + } +} + +function writeEntries(entries: readonly JournalEntry[]): void { + try { + if (entries.length === 0) { + localStorage.removeItem(JOURNAL_KEY) + return + } + localStorage.setItem(JOURNAL_KEY, JSON.stringify(entries)) + } catch { + // A full localStorage loses only what storage itself had not yet confirmed. + } +} + +export function readNativeChatComposerDraftJournal(): Map { + return new Map(readEntries().map(({ scopeKey, draft, at }) => [scopeKey, { draft, at }])) +} + +/** Adds this window's unconfirmed changes, keeping other windows' entries, within the cap. An + * entry that doesn't fit is left out; its change was still issued to storage. */ +export function journalNativeChatComposerDraftChanges( + changes: ReadonlyMap +): void { + if (changes.size === 0) { + return + } + const kept = readEntries().filter((entry) => !changes.has(entry.scopeKey)) + let used = JSON.stringify(kept).length + const added: JournalEntry[] = [] + const bySize = [...changes] + .map(([scopeKey, change]) => ({ scopeKey, ...change })) + .map((entry) => ({ entry, size: JSON.stringify(entry).length + 1 })) + .sort((left, right) => left.size - right.size) + for (const { entry, size } of bySize) { + if (used + size > MAX_JOURNAL_CHARS) { + break + } + added.push(entry) + used += size + } + if (added.length > 0) { + writeEntries([...kept, ...added]) + } +} + +/** Drops a draft's entry once a change to it at least as new is confirmed, by any window, so a + * replay never brings back what was sent or replaced since. */ +export function pruneNativeChatComposerDraftJournal(scopeKey: string, confirmedAt: number): void { + try { + if (localStorage.getItem(JOURNAL_KEY) === null) { + return + } + } catch { + return + } + const entries = readEntries() + const kept = entries.filter((entry) => entry.scopeKey !== scopeKey || entry.at > confirmedAt) + if (kept.length !== entries.length) { + writeEntries(kept) + } +} 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 new file mode 100644 index 000000000000..f77d31a301c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -0,0 +1,136 @@ +// Loads every saved draft into memory once at startup, with the journal's unconfirmed changes +// replayed on top. A change made before the load lands is kept: an edit wins over the loaded +// draft, and an append is applied again on top of it. + +import { + dirtyScopes, + load, + nextSavedAt, + notifyScope, + records, + unverifiedScopes +} from './native-chat-composer-draft-memory' +import { + flushNativeChatComposerDrafts, + installNativeChatComposerDraftBroadcast +} from './native-chat-composer-draft-persistence' +import { + pruneNativeChatComposerDraftJournal, + readNativeChatComposerDraftJournal +} from './native-chat-composer-draft-journal' +import { + nativeChatComposerDraftStorage, + parseStoredNativeChatComposerDraft, + removeLegacyLocalStorageNativeChatComposerDrafts, + type StoredNativeChatComposerDraft +} from './native-chat-composer-draft-storage' + +// Why bounded: a database that will not open must not be retried for the whole run; after the +// last try the drafts stay in memory and a refused save shows as one. +const RETRY_DELAYS_MS = [1_000, 5_000, 30_000] + +let hydration: Promise | null = null +let failedLoads = 0 +let retryTimer: ReturnType | null = null + +function withAppends(scopeKey: string, loaded: StoredNativeChatComposerDraft): DraftLoadResult { + const pending = load.appendsBeforeLoad.get(scopeKey) + // Why the time check: a load that read after the append's own write already holds it. + if (!pending || loaded.savedAt >= pending.firstWrittenAt) { + return { draft: loaded, changed: false } + } + const merged = pending.appends.reduce((draft, append) => append(draft), loaded) + return { draft: { ...merged, savedAt: nextSavedAt() }, changed: true } +} + +type DraftLoadResult = { draft: StoredNativeChatComposerDraft; changed: boolean } + +function applyLoaded(loaded: ReadonlyMap): void { + const drafts = new Map() + for (const [scopeKey, value] of loaded) { + drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) + } + for (const [scopeKey, change] of readNativeChatComposerDraftJournal()) { + const stored = drafts.get(scopeKey) + if (stored && stored.savedAt >= change.at) { + pruneNativeChatComposerDraftJournal(scopeKey, change.at) + continue + } + drafts.set(scopeKey, change.draft) + dirtyScopes.add(scopeKey) + } + for (const [scopeKey, stored] of drafts) { + if (load.editedBeforeLoad.has(scopeKey)) { + continue + } + if (!stored || load.deletionsBeforeLoad.some((matches) => matches(scopeKey, stored))) { + // An unreadable record, a journaled removal, or one deleted here while loading. + dirtyScopes.add(scopeKey) + continue + } + const { draft, changed } = withAppends(scopeKey, stored) + records.set(scopeKey, draft) + unverifiedScopes.add(scopeKey) + if (changed) { + dirtyScopes.add(scopeKey) + } + notifyScope(scopeKey) + } + load.hydrated = true + load.editedBeforeLoad.clear() + load.appendsBeforeLoad.clear() + load.deletionsBeforeLoad.length = 0 + flushNativeChatComposerDrafts() +} + +function retryLater(error: unknown): void { + if (failedLoads === 0) { + console.warn('[native-chat-drafts] saved drafts could not be loaded', error) + } + const delay = RETRY_DELAYS_MS[failedLoads] + failedLoads += 1 + if (delay === undefined) { + // Given up: what was changed meanwhile is all there is, so nothing waits on a load any more. + load.hydrated = true + load.editedBeforeLoad.clear() + load.appendsBeforeLoad.clear() + load.deletionsBeforeLoad.length = 0 + return + } + retryTimer = setTimeout(() => { + retryTimer = null + hydration = null + void hydrateNativeChatComposerDrafts() + }, delay) +} + +/** Loads every saved draft into memory, once; a failed load is retried a few times, then left. */ +export function hydrateNativeChatComposerDrafts(): Promise { + hydration ??= (async () => { + removeLegacyLocalStorageNativeChatComposerDrafts() + installNativeChatComposerDraftBroadcast() + applyLoaded(await nativeChatComposerDraftStorage().loadAll()) + })().catch(retryLater) + return hydration +} + +/** Startup waits this long at most; a load still running fills its drafts in when it lands. */ +export async function waitForNativeChatComposerDrafts(timeoutMs: number): Promise { + let timer: ReturnType | undefined + await Promise.race([ + hydrateNativeChatComposerDrafts(), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + ]) + clearTimeout(timer) +} + +export function resetNativeChatComposerDraftLoadForTests(): void { + if (retryTimer !== null) { + clearTimeout(retryTimer) + retryTimer = null + } + hydration = null + failedLoads = 0 +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts index b1ffd63fe5ee..97f033667725 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts @@ -23,6 +23,29 @@ export const refusedScopes = new Set() export const unverifiedScopes = new Set() export const scopeListeners = new Map void>>() +/** How a change before the startup load lands meets the loaded draft: an edit replaces it, an + * append (text or images given back, a paste) is applied again on top of it. */ +export type DraftAppend = (draft: StoredNativeChatComposerDraft) => StoredNativeChatComposerDraft + +type LoadBookkeeping = { + hydrated: boolean + readonly editedBeforeLoad: Set + /** Per scope: the appends, and when the first of them was written. */ + readonly appendsBeforeLoad: Map + readonly deletionsBeforeLoad: (( + scopeKey: string, + draft: StoredNativeChatComposerDraft + ) => boolean)[] +} + +// The startup load's bookkeeping, emptied once it lands. +export const load: LoadBookkeeping = { + hydrated: false, + editedBeforeLoad: new Set(), + appendsBeforeLoad: new Map(), + deletionsBeforeLoad: [] +} + let lastSavedAt = 0 /** Monotonic within a run, so drafts changed in the same millisecond still age in order. */ @@ -47,4 +70,8 @@ export function clearDraftMemoryForTests(): void { refusedScopes.clear() unverifiedScopes.clear() lastSavedAt = 0 + load.hydrated = false + load.editedBeforeLoad.clear() + load.appendsBeforeLoad.clear() + load.deletionsBeforeLoad.length = 0 } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 710962af5422..2aef8d06c24c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -1,47 +1,41 @@ -// Keeps storage a copy of the draft store's memory: writes each changed draft whole, loads every -// draft once at startup, journals what storage has not confirmed when the window goes away, and -// follows other windows' writes. +// Keeps storage a copy of the draft store's memory: writes each changed draft whole, journals what +// storage has not confirmed when the window goes away, and follows other windows' writes. import { basename } from '@/lib/path' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { dirtyScopes, hasLocalChange, + load, nextSavedAt, notifyScope, records, refusedScopes, unconfirmed, unverifiedScopes, + type DraftAppend, type DraftRecord, type UnconfirmedDraftChange } from './native-chat-composer-draft-memory' +import { + journalNativeChatComposerDraftChanges, + pruneNativeChatComposerDraftJournal +} from './native-chat-composer-draft-journal' import { nativeChatComposerDraftStorage, parseStoredNativeChatComposerDraft, - removeLegacyLocalStorageNativeChatComposerDrafts, type NativeChatComposerDraftImage, type StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' const PERSIST_DEBOUNCE_MS = 250 -const JOURNAL_KEY = 'orca:nativeChatComposerDraftJournal:v1' const CHANNEL_NAME = 'orca-native-chat-composer-drafts' let flushTimer: ReturnType | null = null let flushOnHideInstalled = false -let journaled = false const inFlight = new Set>() let channel: BroadcastChannel | null = null -let hydrated = false -let hydration: Promise | null = null -// Before the load lands, a scope changed here keeps its local version, and a deletion here also -// applies to what the load brings. -const touchedBeforeLoad = new Set() -const deletionsBeforeLoad: ((scopeKey: string, draft: StoredNativeChatComposerDraft) => boolean)[] = - [] - /** An image the draft names but can no longer send, so the user can attach it again. */ export function unavailableNativeChatComposerDraftImage( image: NativeChatComposerDraftImage @@ -72,26 +66,14 @@ function savedForm(record: DraftRecord): StoredNativeChatComposerDraft | null { return { ...saved, text, images } } -function clearJournalIfSettled(): void { - if (!journaled || unconfirmed.size > 0) { - return - } - journaled = false - try { - localStorage.removeItem(JOURNAL_KEY) - } catch { - // Nothing to clear without localStorage. - } -} - function confirm(scopeKey: string, change: UnconfirmedDraftChange): void { if (unconfirmed.get(scopeKey) === change) { unconfirmed.delete(scopeKey) if (refusedScopes.delete(scopeKey)) { notifyScope(scopeKey) } - clearJournalIfSettled() } + pruneNativeChatComposerDraftJournal(scopeKey, change.at) channel?.postMessage({ scopeKey }) } @@ -127,31 +109,20 @@ export function flushNativeChatComposerDrafts(): void { clearTimeout(flushTimer) flushTimer = null } - if (!hydrated) { - // Why: a load that failed is retried, so a later one can still bring the drafts back. - void hydrateNativeChatComposerDrafts() - } for (const scopeKey of dirtyScopes) { dirtyScopes.delete(scopeKey) persist(scopeKey) } } -/** Why: storage commits after this task, so whatever it has not confirmed when the window goes - * away is also written here synchronously and replayed by the next run. */ +/** Why: storage commits after this task, so what it has not confirmed when the window goes away + * is also written synchronously and replayed by the next load. A draft storage already refused + * is left out: it is shown as not saved and retried instead. */ function journalUnconfirmed(): void { flushNativeChatComposerDrafts() - try { - if (unconfirmed.size === 0) { - clearJournalIfSettled() - return - } - const entries = [...unconfirmed].map(([scopeKey, change]) => ({ scopeKey, ...change })) - localStorage.setItem(JOURNAL_KEY, JSON.stringify(entries)) - journaled = true - } catch { - // A full localStorage loses only what storage itself had not yet confirmed. - } + journalNativeChatComposerDraftChanges( + new Map([...unconfirmed].filter(([scopeKey]) => !refusedScopes.has(scopeKey))) + ) } function journalWhenHidden(): void { @@ -176,14 +147,24 @@ function installFlushOnHide(): void { } /** Marks a scope changed in memory: `deferred` coalesces typing into one write, `immediate` - * writes now. */ + * writes now. Before the startup load lands, an edit wins over the loaded draft and an append + * is applied again on top of it. */ export function persistNativeChatComposerDraft( scopeKey: string, - persist: 'immediate' | 'deferred' + persist: 'immediate' | 'deferred', + append?: DraftAppend ): void { dirtyScopes.add(scopeKey) - if (!hydrated) { - touchedBeforeLoad.add(scopeKey) + if (!load.hydrated) { + if (append) { + const pending = load.appendsBeforeLoad.get(scopeKey) + load.appendsBeforeLoad.set(scopeKey, { + firstWrittenAt: pending?.firstWrittenAt ?? records.get(scopeKey)?.savedAt ?? 0, + appends: [...(pending?.appends ?? []), append] + }) + } else { + load.editedBeforeLoad.add(scopeKey) + } } installFlushOnHide() if (persist === 'immediate') { @@ -197,58 +178,17 @@ export function persistNativeChatComposerDraft( export function deleteLoadingNativeChatComposerDraftsWhere( matches: (scopeKey: string, draft: StoredNativeChatComposerDraft) => boolean ): void { - if (!hydrated) { - deletionsBeforeLoad.push(matches) - } -} - -function readJournal(): Map { - const journal = new Map() - try { - const entries: unknown = JSON.parse(localStorage.getItem(JOURNAL_KEY) ?? '[]') - journaled = Array.isArray(entries) && entries.length > 0 - for (const entry of Array.isArray(entries) ? entries : []) { - const { scopeKey, draft, at } = entry ?? {} - if (typeof scopeKey === 'string' && typeof at === 'number') { - journal.set(scopeKey, { draft: parseStoredNativeChatComposerDraft(draft), at }) - } - } - } catch { - // An unreadable journal holds nothing to replay. + if (!load.hydrated) { + load.deletionsBeforeLoad.push(matches) } - return journal } -function applyLoaded(loaded: ReadonlyMap): void { - const drafts = new Map() - for (const [scopeKey, value] of loaded) { - drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) - } - for (const [scopeKey, change] of readJournal()) { - const stored = drafts.get(scopeKey) - if (!stored || stored.savedAt < change.at) { - drafts.set(scopeKey, change.draft) - dirtyScopes.add(scopeKey) - } - } - for (const [scopeKey, draft] of drafts) { - if (touchedBeforeLoad.has(scopeKey)) { - continue - } - if (!draft || deletionsBeforeLoad.some((matches) => matches(scopeKey, draft))) { - // An unreadable record, a journaled removal, or one deleted here while loading. - dirtyScopes.add(scopeKey) - continue - } - records.set(scopeKey, draft) - unverifiedScopes.add(scopeKey) - notifyScope(scopeKey) - } - hydrated = true - touchedBeforeLoad.clear() - deletionsBeforeLoad.length = 0 - flushNativeChatComposerDrafts() - clearJournalIfSettled() +function addsAnImage( + draft: StoredNativeChatComposerDraft, + previous: DraftRecord | undefined +): boolean { + const held = new Set(previous?.images.map((image) => `${image.id}\0${image.path}`)) + return draft.images.some((image) => !held.has(`${image.id}\0${image.path}`)) } // Why: another window of the same app (two web-client tabs) can send or edit this draft; its write @@ -269,8 +209,11 @@ function adoptForeignChange(message: unknown): void { } const draft = parseStoredNativeChatComposerDraft(value) if (draft) { + // Why: images this window already checked stay checked, so their chips don't flash. + if (addsAnImage(draft, records.get(scopeKey))) { + unverifiedScopes.add(scopeKey) + } records.set(scopeKey, draft) - unverifiedScopes.add(scopeKey) } else { records.delete(scopeKey) } @@ -279,7 +222,7 @@ function adoptForeignChange(message: unknown): void { .catch(() => {}) } -function installBroadcast(): void { +export function installNativeChatComposerDraftBroadcast(): void { if (channel || typeof BroadcastChannel === 'undefined') { return } @@ -291,31 +234,6 @@ function installBroadcast(): void { } } -/** Loads every saved draft into memory, once; a failed load is retried by the next flush. */ -export function hydrateNativeChatComposerDrafts(): Promise { - hydration ??= (async () => { - removeLegacyLocalStorageNativeChatComposerDrafts() - installBroadcast() - applyLoaded(await nativeChatComposerDraftStorage().loadAll()) - })().catch((error: unknown) => { - console.warn('[native-chat-drafts] saved drafts could not be loaded', error) - hydration = null - }) - return hydration -} - -/** Startup waits this long at most; a load still running fills its drafts in when it lands. */ -export async function waitForNativeChatComposerDrafts(timeoutMs: number): Promise { - let timer: ReturnType | undefined - await Promise.race([ - hydrateNativeChatComposerDrafts(), - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - ]) - clearTimeout(timer) -} - /** Settles once every write issued so far has been confirmed or refused. */ export async function nativeChatComposerDraftWritesSettled(): Promise { while (inFlight.size > 0) { @@ -336,10 +254,5 @@ export function resetNativeChatComposerDraftPersistenceForTests(): void { window.removeEventListener('beforeunload', journalUnconfirmed) document.removeEventListener('visibilitychange', journalWhenHidden) } - hydrated = false - hydration = null - journaled = false - touchedBeforeLoad.clear() - deletionsBeforeLoad.length = 0 inFlight.clear() } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 9d175348237e..6f561e2fdcca 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -361,6 +361,166 @@ describe('native-chat composer draft store', () => { expect(reloaded.drafts.readNativeChatDraftCache('tab-2:pane')).toBe('other chat') }) + it('adds what is given back before a slow load lands to the saved draft, instead of replacing it', async () => { + storage.drafts.set('agent-session:s1', { + text: 'saved earlier', + images: [IMAGES[0]], + savedAt: 1 + }) + let land: () => void = () => {} + // Storage reads as of the load's start, as IndexedDB does for a transaction begun first. + const slow = { + ...storage, + loadAll: () => { + const snapshot = new Map(storage.drafts) + return new Promise>((resolve) => { + land = () => resolve(snapshot) + }) + } + } + const reloaded = await reload({ using: slow, hydrate: false }) + await reloaded.store.waitForNativeChatComposerDrafts(1) + // Stop's restore, and a paste, before the load lands. + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + reloaded.attachments.appendNativeChatAttachmentCache('agent-session:s1', [IMAGES[1]], { + fromUser: true + }) + + land() + await reloaded.store.hydrateNativeChatComposerDrafts() + await reloaded.store.nativeChatComposerDraftWritesSettled() + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'saved earlier\n\nreturned by Stop' + ) + expect(reloaded.attachments.readNativeChatAttachmentCache('agent-session:s1')).toEqual(IMAGES) + expect(storedDraft('agent-session:s1')).toMatchObject({ + text: 'saved earlier\n\nreturned by Stop', + images: IMAGES + }) + }) + + it('retries a failed load a few times, warns once, and then stops', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const broken = { + ...storage, + loadAll: vi.fn(() => Promise.reject(new Error('no backing store'))) + } + const reloaded = await reload({ using: broken, hydrate: false }) + await reloaded.store.hydrateNativeChatComposerDrafts() + for (let tick = 0; tick < 10; tick += 1) { + reloaded.drafts.writeNativeChatDraftCache('tab-1:pane', `typing ${tick}`) + await vi.advanceTimersByTimeAsync(60_000) + } + + expect(broken.loadAll).toHaveBeenCalledTimes(4) + expect(warn.mock.calls.filter(([message]) => String(message).includes('loaded'))).toHaveLength( + 1 + ) + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typing 9') + warn.mockRestore() + }) + + it('does not echo a draft adopted from another window as a change of its own', async () => { + const other = await reload() + const doc = (text: string) => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] + }) + other.drafts.writeNativeChatDraftDocument('agent-session:s3', 'abc', doc('abc')) + other.store.flushNativeChatComposerDrafts() + await vi.waitFor(() => + expect(modules.drafts.readNativeChatDraftCache('agent-session:s3')).toBe('abc') + ) + // The editor here shows the adopted draft and hands back an equal document of its own. + modules.drafts.writeNativeChatDraftDocument('agent-session:s3', 'abc', doc('abc')) + other.drafts.writeNativeChatDraftDocument('agent-session:s3', 'abcd', doc('abcd')) + other.store.flushNativeChatComposerDrafts() + await other.store.nativeChatComposerDraftWritesSettled() + await new Promise((resolve) => setTimeout(resolve, 20)) + modules.store.flushNativeChatComposerDrafts() + await modules.store.nativeChatComposerDraftWritesSettled() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(other.drafts.readNativeChatDraftCache('agent-session:s3')).toBe('abcd') + expect(storedDraft('agent-session:s3')?.text).toBe('abcd') + }) + + it('never replays a closed window’s journal over a draft another window sent since', async () => { + const tabB = modules + // Tab A's write lands, but the page goes away before it is confirmed. + const landsUnconfirmed = { + ...storage, + write: (scopeKey: string, draft: Parameters[1]) => { + storage.drafts.set(scopeKey, draft) + return new Promise(() => {}) + } + } + const tabA = await reload({ using: landsUnconfirmed }) + tabA.drafts.writeNativeChatDraftCache('agent-session:s1', 'typed in A, then A closed') + window.dispatchEvent(new Event('pagehide')) + expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toContain('typed in A') + tabA.store.clearNativeChatComposerDraftsForTests() + + tabB.drafts.writeNativeChatDraftCache('agent-session:s1', 'written in B') + tabB.store.clearNativeChatComposerDraftIfUnchanged('agent-session:s1', { + text: 'written in B', + images: [] + }) + await tabB.store.nativeChatComposerDraftWritesSettled() + + const tabC = await reload() + expect(tabC.drafts.readNativeChatDraftCache('agent-session:s1')).toBe('') + expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toBeNull() + }) + + it('keeps the journal small: never a refused draft, and never past its cap', async () => { + storage.refuseWrites = true + for (let index = 0; index < 10; index += 1) { + modules.drafts.appendNativeChatDraftCache( + `agent-session:refused-${index}`, + 'x'.repeat(200_000) + ) + } + await modules.store.nativeChatComposerDraftWritesSettled() + window.dispatchEvent(new Event('pagehide')) + expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toBeNull() + + storage.refuseWrites = false + const hanging = { ...storage, write: () => new Promise(() => {}) } + const unconfirmedTab = await reload({ using: hanging }) + for (let index = 0; index < 10; index += 1) { + unconfirmedTab.drafts.appendNativeChatDraftCache( + `agent-session:big-${index}`, + 'y'.repeat(60_000) + ) + } + window.dispatchEvent(new Event('pagehide')) + const journal = localStorage.getItem('orca:nativeChatComposerDraftJournal:v1') ?? '' + expect(journal.length).toBeGreaterThan(0) + expect(journal.length).toBeLessThanOrEqual(256_000) + }) + + it('keeps a restored image checked when another window saves the draft with the same image', async () => { + const other = await reload() + other.attachments.appendNativeChatAttachmentCache('tab-1:pane', [IMAGES[0]]) + await vi.waitFor(() => + expect(modules.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([IMAGES[0]]) + ) + modules.store.markNativeChatComposerDraftVerified('tab-1:pane') + + other.drafts.appendNativeChatDraftCache('tab-1:pane', 'caption') + await vi.waitFor(() => + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('caption') + ) + expect(modules.store.isNativeChatComposerDraftUnverified('tab-1:pane')).toBe(false) + + other.attachments.appendNativeChatAttachmentCache('tab-1:pane', [IMAGES[1]]) + await vi.waitFor(() => + expect(modules.store.isNativeChatComposerDraftUnverified('tab-1:pane')).toBe(true) + ) + }) + it('follows another window’s save of a draft, unless a change here is not saved yet', async () => { const other = await reload() other.drafts.appendNativeChatDraftCache('tab-1:pane', 'from the other tab') 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 62a0877c10ed..f797d928c928 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 @@ -4,7 +4,10 @@ // skipped write is repaired by the next one. import type { JSONContent } from '@tiptap/react' -import { sameNativeChatComposerDraftImages } from './native-chat-composer-draft-comparison' +import { + sameNativeChatComposerDraftDocument, + sameNativeChatComposerDraftImages +} from './native-chat-composer-draft-comparison' import { clearDraftMemoryForTests, dirtyScopes, @@ -14,6 +17,7 @@ import { refusedScopes, scopeListeners, unverifiedScopes, + type DraftAppend, type DraftRecord } from './native-chat-composer-draft-memory' import { @@ -21,6 +25,7 @@ import { persistNativeChatComposerDraft, resetNativeChatComposerDraftPersistenceForTests } from './native-chat-composer-draft-persistence' +import { resetNativeChatComposerDraftLoadForTests } from './native-chat-composer-draft-load' import type { NativeChatComposerDraft, NativeChatComposerDraftImage, @@ -30,12 +35,14 @@ import type { export { flushNativeChatComposerDrafts, - hydrateNativeChatComposerDrafts, isKeptLocalPaste, nativeChatComposerDraftWritesSettled, - unavailableNativeChatComposerDraftImage, - waitForNativeChatComposerDrafts + unavailableNativeChatComposerDraftImage } from './native-chat-composer-draft-persistence' +export { + hydrateNativeChatComposerDrafts, + waitForNativeChatComposerDrafts +} from './native-chat-composer-draft-load' export type NativeChatComposerDraftChange = { text?: string @@ -105,7 +112,8 @@ export function readNativeChatComposerDraft(scopeKey: string): NativeChatCompose export function updateNativeChatComposerDraft( scopeKey: string, change: NativeChatComposerDraftChange, - persist: 'immediate' | 'deferred' + persist: 'immediate' | 'deferred', + append?: DraftAppend ): void { const current = records.get(scopeKey) ?? EMPTY_DRAFT const text = change.text ?? current.text @@ -114,7 +122,7 @@ export function updateNativeChatComposerDraft( const unsavedText = change.unsavedText ?? current.unsavedText if ( text === current.text && - document === current.document && + sameNativeChatComposerDraftDocument(document, current.document) && unsavedText === current.unsavedText && sameNativeChatComposerDraftImages(images, current.images) ) { @@ -126,7 +134,7 @@ export function updateNativeChatComposerDraft( if (isEmptyDraft({ text, images })) { records.delete(scopeKey) notifyScope(scopeKey) - persistNativeChatComposerDraft(scopeKey, 'immediate') + persistNativeChatComposerDraft(scopeKey, 'immediate', append) return } // Why stamped once: a conversation never moves to another workspace, so its owner stays true. @@ -141,7 +149,33 @@ export function updateNativeChatComposerDraft( } records.set(scopeKey, record) notifyScope(scopeKey) - persistNativeChatComposerDraft(scopeKey, persist) + persistNativeChatComposerDraft(scopeKey, persist, append) +} + +/** + * Adds to the scope's draft as it is now: text or images given back, or attached. Saved at once. + * Before the startup load lands, the same addition is made again to the loaded draft, so nothing + * saved earlier is replaced by it. + */ +export function appendToNativeChatComposerDraft( + scopeKey: string, + append: (draft: NativeChatComposerDraft) => NativeChatComposerDraftChange +): void { + updateNativeChatComposerDraft( + scopeKey, + append(readNativeChatComposerDraft(scopeKey)), + 'immediate', + (loaded) => { + const change = append(loaded) + const document = 'document' in change ? change.document : loaded.document + return { + ...loaded, + text: change.text ?? loaded.text, + images: change.images ?? loaded.images, + ...(document ? { document } : { document: undefined }) + } + } + ) } /** @@ -225,5 +259,6 @@ export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerD export function clearNativeChatComposerDraftsForTests(): void { clearDraftMemoryForTests() resetNativeChatComposerDraftPersistenceForTests() + resetNativeChatComposerDraftLoadForTests() resolveOwner = null } 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 31507d023ced..50748f19d41c 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 @@ -6,6 +6,7 @@ import type { JSONContent } from '@tiptap/react' // reload or quit. import { + appendToNativeChatComposerDraft, clearNativeChatComposerDraftsForTests, readNativeChatComposerDraft, updateNativeChatComposerDraft @@ -46,11 +47,10 @@ export function appendNativeChatDraftCache(scopeKey: string, text: string): void } const previous = readNativeChatDraftCache(scopeKey) // Saved now: the copy it came from (an outbox entry, a queued card) goes right after this. - updateNativeChatComposerDraft( - scopeKey, - { text: appendNativeChatDraftText(previous, text), document: undefined }, - 'immediate' - ) + appendToNativeChatComposerDraft(scopeKey, (draft) => ({ + text: appendNativeChatDraftText(draft.text, text), + document: undefined + })) appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous)) } diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx new file mode 100644 index 000000000000..ecc546bcd290 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +// A value the field sets on the editor comes from the draft store, so the editor never saves it +// back: an echo would make another window's draft, or a late-loaded one, a change of this window. +import { createElement, createRef } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DraftStore from './native-chat-composer-draft-store' +import type * as DraftCache from './native-chat-draft-cache' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import { createMemoryNativeChatComposerDraftStorage } from './native-chat-composer-draft-storage' + +type Renderer = { drafts: typeof DraftCache; store: typeof DraftStore } + +const SCOPE = 'agent-session:s1' +let storage = createMemoryNativeChatComposerDraftStorage() +const loaded: Renderer[] = [] + +async function open(): Promise { + vi.resetModules() + const storageModule = await import('./native-chat-composer-draft-storage') + storageModule.setNativeChatComposerDraftStorageForTests(storage) + const renderer = { + drafts: await import('./native-chat-draft-cache'), + store: await import('./native-chat-composer-draft-store') + } + loaded.push(renderer) + await renderer.store.hydrateNativeChatComposerDrafts() + return renderer +} + +/** The editor, plus the field's effect that sets the store's draft on it. */ +async function mountEditor(renderer: Renderer): Promise<() => void> { + const { NativeChatPromptEditor } = await import('./NativeChatPromptEditor') + const inputRef = createRef() + render( + createElement(NativeChatPromptEditor, { + scopeKey: SCOPE, + inputRef, + initialValue: renderer.drafts.readNativeChatDraftCache(SCOPE), + disabled: false, + placeholder: 'Message', + onChange: () => {}, + onSelect: () => {} + }) + ) + return renderer.store.subscribeToNativeChatComposerDraft(SCOPE, () => { + const draft = renderer.drafts.readNativeChatDraftCache(SCOPE) + if (inputRef.current && inputRef.current.value !== draft) { + inputRef.current.value = draft + } + }) +} + +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +beforeEach(() => { + localStorage.clear() + storage = createMemoryNativeChatComposerDraftStorage() +}) + +afterEach(() => { + cleanup() + for (const renderer of loaded.splice(0)) { + renderer.store.clearNativeChatComposerDraftsForTests() + } +}) + +describe('the editor and the draft store', () => { + it('saves nothing when the store’s draft is set on the editor', async () => { + const renderer = await open() + const stopSync = await mountEditor(renderer) + await act(async () => renderer.drafts.appendNativeChatDraftCache(SCOPE, 'hello')) + await renderer.store.nativeChatComposerDraftWritesSettled() + const savedAt = storage.drafts.get(SCOPE)?.savedAt + + await act(async () => pause(400)) + await renderer.store.nativeChatComposerDraftWritesSettled() + stopSync() + expect(storage.drafts.get(SCOPE)?.savedAt).toBe(savedAt) + }) + + it('lets another window’s send stand, instead of echoing back the draft it showed', async () => { + const receiving = await open() + const stopSync = await mountEditor(receiving) + const sending = await open() + sending.drafts.writeNativeChatDraftCache(SCOPE, 'hello') + sending.store.flushNativeChatComposerDrafts() + await act(async () => { + await vi.waitFor(() => expect(receiving.drafts.readNativeChatDraftCache(SCOPE)).toBe('hello')) + }) + + sending.drafts.writeNativeChatDraftCache(SCOPE, '') + await sending.store.nativeChatComposerDraftWritesSettled() + await act(async () => pause(600)) + await receiving.store.nativeChatComposerDraftWritesSettled() + await act(async () => pause(50)) + stopSync() + + expect(storage.drafts.has(SCOPE)).toBe(false) + expect(sending.drafts.readNativeChatDraftCache(SCOPE)).toBe('') + expect(receiving.drafts.readNativeChatDraftCache(SCOPE)).toBe('') + }) + + it('shows a draft that arrives later with its skill chip, from its saved document', async () => { + const document = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: ' this' } + ] + } + ] + } + const receiving = await open() + const stopSync = await mountEditor(receiving) + const sending = await open() + sending.drafts.writeNativeChatDraftDocument(SCOPE, '$review this', document) + sending.store.flushNativeChatComposerDrafts() + await act(async () => { + await vi.waitFor(() => + expect(receiving.drafts.readNativeChatDraftCache(SCOPE)).toBe('$review this') + ) + }) + stopSync() + + expect(window.document.querySelector('[data-native-chat-skill]')).not.toBeNull() + expect(storage.drafts.get(SCOPE)?.document).toEqual(document) + }) +}) 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 ab2527f6db04..426b5f57e56f 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 @@ -16,14 +16,14 @@ import { } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { + appendToNativeChatComposerDraft, clearNativeChatComposerDraftsForTests, isKeptLocalPaste, - isNativeChatComposerDraftUnverified, readNativeChatComposerDraft, subscribeToNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' -import { verifyRestoredNativeChatComposerDraftImages } from './native-chat-composer-draft-image-check' +import { useRestoredNativeChatComposerDraftImageCheck } 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' @@ -74,16 +74,8 @@ export function useNativeChatComposerAttachments({ () => readNativeChatComposerDraft(attachmentScopeKey).images ) // Why: a restored paste can be previewed only once main re-grants it, so until the restore check - // is done it waits like a chip still saving. A draft the startup load fills in late is checked - // when it arrives. - const restoring = useSyncExternalStore(subscribe, () => - isNativeChatComposerDraftUnverified(attachmentScopeKey) - ) - useEffect(() => { - if (restoring) { - void verifyRestoredNativeChatComposerDraftImages(attachmentScopeKey) - } - }, [attachmentScopeKey, restoring]) + // is done it waits like a chip still saving. + const restoring = useRestoredNativeChatComposerDraftImageCheck(attachmentScopeKey, subscribe) // Chips still being written, and the clipboard previews this composer minted, are its own. const [local, setLocal] = useState(NO_LOCAL_ATTACHMENTS) // Read by callbacks between renders; only they change it, always together with the state. @@ -313,20 +305,22 @@ export function appendNativeChatAttachmentCache( 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.push(image) - } else { - images[placeholder] = image + appendToNativeChatComposerDraft(scopeKey, (draft) => { + const images = [...draft.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.push(image) + } else { + images[placeholder] = image + } } - } - updateNativeChatComposerDraft(scopeKey, { images }, 'immediate') + return { images } + }) } export function clearNativeChatAttachmentCacheForTests(): void { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4d29100edad2..668311ab42de 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17765,8 +17765,7 @@ "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", - "draftNotSaved": "This draft couldn't be saved. It's kept until Orca closes.", - "imageNotSavedWithDraft": "This image couldn't be saved with this draft. It's kept until Orca closes.", + "draftNotSaved": "This draft couldn't be saved yet. Orca keeps trying.", "imageNotBroughtBack": "{{name}} couldn't be brought back with this draft. Attach it again or remove it.", "pastedImageNotBroughtBack": "This pasted image couldn't be brought back with this draft. Remove it, and paste it again if you still need it.", "removeImageToSend": "An image couldn't be brought back. Remove it to send.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1b5ffa74c4cc..0f9989f67a21 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15084,8 +15084,7 @@ "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", - "draftNotSaved": "No se pudo guardar este borrador. Se conserva hasta que cierres Orca.", - "imageNotSavedWithDraft": "No se pudo guardar esta imagen con este borrador. Se conserva hasta que cierres Orca.", + "draftNotSaved": "Este borrador aún no se ha podido guardar. Orca lo sigue intentando.", "imageNotBroughtBack": "No se pudo recuperar la imagen {{name}} con este borrador. Vuelve a adjuntarla o quítala.", "pastedImageNotBroughtBack": "No se pudo recuperar esta imagen pegada con este borrador. Quítala y vuelve a pegarla si aún la necesitas.", "removeImageToSend": "No se pudo recuperar una imagen. Quítala para enviar.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 426d0ab5c6f8..42be8e7925e9 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17568,8 +17568,7 @@ "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", - "draftNotSaved": "Impossible d'enregistrer ce brouillon. Il est conservé jusqu'à la fermeture d'Orca.", - "imageNotSavedWithDraft": "Impossible d'enregistrer cette image avec ce brouillon. Elle est conservée jusqu'à la fermeture d'Orca.", + "draftNotSaved": "Ce brouillon n'a pas encore pu être enregistré. Orca continue d'essayer.", "imageNotBroughtBack": "L'image {{name}} n'a pas pu être récupérée avec ce brouillon. Joignez-la à nouveau ou retirez-la.", "pastedImageNotBroughtBack": "Cette image collée n'a pas pu être récupérée avec ce brouillon. Retirez-la, puis collez-la à nouveau si vous en avez encore besoin.", "removeImageToSend": "Une image n'a pas pu être récupérée. Retirez-la pour envoyer.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 65ca5e4640a6..815d592e168a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17490,8 +17490,7 @@ "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", - "draftNotSaved": "この下書きを保存できませんでした。Orca を閉じるまで保持されます。", - "imageNotSavedWithDraft": "この画像を下書きと一緒に保存できませんでした。Orca を閉じるまで保持されます。", + "draftNotSaved": "この下書きはまだ保存できていません。Orca は保存を再試行し続けます。", "imageNotBroughtBack": "{{name}} をこの下書きと一緒に復元できませんでした。もう一度添付するか、削除してください。", "pastedImageNotBroughtBack": "この貼り付けた画像をこの下書きと一緒に復元できませんでした。削除して、必要であればもう一度貼り付けてください。", "removeImageToSend": "復元できなかった画像があります。送信するには削除してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1587550b515b..c87b6a850476 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17490,8 +17490,7 @@ "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", - "draftNotSaved": "이 초안을 저장할 수 없습니다. Orca를 닫을 때까지 유지됩니다.", - "imageNotSavedWithDraft": "이 이미지를 초안과 함께 저장할 수 없습니다. Orca를 닫을 때까지 유지됩니다.", + "draftNotSaved": "이 초안을 아직 저장하지 못했습니다. Orca가 계속 다시 시도합니다.", "imageNotBroughtBack": "{{name}}을(를) 이 초안과 함께 복원하지 못했습니다. 다시 첨부하거나 제거하세요.", "pastedImageNotBroughtBack": "이 붙여넣은 이미지를 이 초안과 함께 복원하지 못했습니다. 제거한 후 필요하면 다시 붙여넣으세요.", "removeImageToSend": "복원하지 못한 이미지가 있습니다. 보내려면 제거하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bb287bd21387..f6be7aa11a13 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17507,8 +17507,7 @@ "localAttachmentUnsupported": "远程会话不支持本地附件。", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", - "draftNotSaved": "无法保存此草稿。在关闭 Orca 之前会一直保留。", - "imageNotSavedWithDraft": "无法将此图片与草稿一起保存。在关闭 Orca 之前会一直保留。", + "draftNotSaved": "此草稿尚未保存成功。Orca 会继续尝试。", "imageNotBroughtBack": "无法随此草稿恢复 {{name}}。请重新附加或将其移除。", "pastedImageNotBroughtBack": "无法随此草稿恢复这张粘贴的图片。请将其移除,如仍需要请重新粘贴。", "removeImageToSend": "有一张图片无法恢复。请将其移除后再发送。", diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 23c77a520a33..254a8c51c4e7 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -251,7 +251,8 @@ export function createTerminalTabCloseActions( if (intentReason && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { commitTerminalSurfaceClose(closingWorktreeId, { kind: 'tab', tabId }, intentReason) } - // Why only a user close: it is the explicit abandon; the drafts' count and size bounds retire the rest. + // Why only a user close: it is the explicit abandon. Other closes keep the drafts until their + // workspace is removed in Orca; drafts have no budget that retires them. if (closeReason === 'user') { deleteNativeChatComposerDraftsForTab(tabId) } From 47312edf47082832326cfc65f08a329b90ed8c25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:45:02 -0700 Subject: [PATCH 27/38] test(native-chat): an early append the load already read is not added twice --- .../native-chat-composer-draft-store.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 6f561e2fdcca..cddedca13a3a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -399,6 +399,26 @@ describe('native-chat composer draft store', () => { }) }) + it('does not add an early append twice when the load already read its write', async () => { + storage.drafts.set('agent-session:s1', { text: 'saved earlier', images: [], savedAt: 1 }) + let land: () => void = () => {} + // This load reads only when it lands, after the append's own write. + const late = { + ...storage, + loadAll: () => + new Promise>((resolve) => { + land = () => resolve(new Map(storage.drafts)) + }) + } + const reloaded = await reload({ using: late, hydrate: false }) + await reloaded.store.waitForNativeChatComposerDrafts(1) + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + + land() + await reloaded.store.hydrateNativeChatComposerDrafts() + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:s1')).toBe('returned by Stop') + }) + it('retries a failed load a few times, warns once, and then stops', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) From 2fc292914e842d229a1140e507829587ef2ad837 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:54:29 -0700 Subject: [PATCH 28/38] test(native-chat): the store-value editor test waits for the editor and checks no document is saved --- .../native-chat/native-chat-prompt-editor-store-value.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx index ecc546bcd290..3f118cc7e436 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx @@ -43,6 +43,7 @@ async function mountEditor(renderer: Renderer): Promise<() => void> { onSelect: () => {} }) ) + await vi.waitFor(() => expect(inputRef.current).not.toBeNull()) return renderer.store.subscribeToNativeChatComposerDraft(SCOPE, () => { const draft = renderer.drafts.readNativeChatDraftCache(SCOPE) if (inputRef.current && inputRef.current.value !== draft) { @@ -77,6 +78,8 @@ describe('the editor and the draft store', () => { await renderer.store.nativeChatComposerDraftWritesSettled() stopSync() expect(storage.drafts.get(SCOPE)?.savedAt).toBe(savedAt) + // An echo would have saved the editor's own document over the store's text-only draft. + expect(storage.drafts.get(SCOPE)?.document).toBeUndefined() }) it('lets another window’s send stand, instead of echoing back the draft it showed', async () => { From 393eb7f51e1fb03c52b39caad35a5f1c64223c1c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:29:14 -0700 Subject: [PATCH 29/38] fix(native-chat): early appends are read-modify-writes, and the load can't be skipped - Before the load lands, an append (text or images given back, a paste) reads the stored draft and writes it back with the append in one storage change, so a load that failed and was retried can never lose the saved draft. On landing, only appends that load could not have read (made after it began reading, or never written) are applied again, by in-run order rather than by comparing clocks. - The editor saves a value set from the store when this window has a change of its own behind it, so a mention accepted next to a skill chip keeps the chip; another window's draft or a late load still isn't saved back. - A refused draft is journaled again; the 256,000-character cap bounds it. - The draft load starts before the startup chain, and the first write starts it in a window whose startup never did; startup still waits for it before the session's tabs mount. --- src/renderer/src/App.tsx | 3 + .../app-shell/native-chat-draft-startup.ts | 12 ++- .../app-shell/use-app-startup-hydration.ts | 4 +- .../native-chat/NativeChatPromptEditor.tsx | 13 ++- .../native-chat-composer-draft-indexeddb.ts | 19 +++- .../native-chat-composer-draft-load.ts | 30 ++++-- .../native-chat-composer-draft-memory.ts | 16 ++- .../native-chat-composer-draft-persistence.ts | 60 ++++++++--- .../native-chat-composer-draft-storage.ts | 22 +++- .../native-chat-composer-draft-store.test.ts | 102 +++++++++++++----- .../native-chat-composer-draft-store.ts | 28 +++-- ...ve-chat-prompt-editor-store-value.test.tsx | 99 +++++++++++------ 12 files changed, 307 insertions(+), 101 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 903c8b81447f..1c1f72e5db82 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,6 +24,7 @@ import { useAppChromeLayout } from './app-shell/use-app-chrome-layout' import { useAppSessionPersistence } from './app-shell/use-app-session-persistence' import { useAppShellServices } from './app-shell/use-app-shell-services' import { useAppStartupHydration } from './app-shell/use-app-startup-hydration' +import { startNativeChatDraftLoad } from './app-shell/native-chat-draft-startup' import { useDocumentAppearance } from './app-shell/use-document-appearance' import { useFloatingWorkspacePanel } from './app-shell/use-floating-workspace-panel' import { useGlobalKeybindings } from './app-shell/use-global-keybindings' @@ -43,6 +44,8 @@ function App(): React.JSX.Element { useAppShellServices({ floatingPanelVisible: floatingWorkspace.enabled && floatingWorkspace.open }) + // Why before the startup chain: its effect runs first, and no startup step can skip the load. + useEffect(startNativeChatDraftLoad, []) useAppStartupHydration(onboardingGate.applyStartupOnboardingState) useAppSessionPersistence() useRuntimeGraphSync() 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 1bbdf6fd6f29..4d64bdd708f2 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -1,6 +1,7 @@ import { useAppStore } from '../store' import { resolveNativeChatDraftOwner } from '../lib/native-chat-draft-owner' import { + hydrateNativeChatComposerDrafts, setNativeChatComposerDraftOwnerResolver, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' @@ -9,11 +10,16 @@ import { // fills in every draft not edited meanwhile when it lands. const DRAFT_LOAD_WAIT_MS = 1_500 -/** Startup waits for the drafts alongside the session read, so a composer shows its draft from - * its first frame. */ -export function loadNativeChatDraftsForStartup(): Promise { +/** Before any startup step, so a step that fails can't leave the drafts unloaded. */ +export function startNativeChatDraftLoad(): void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) + void hydrateNativeChatComposerDrafts() +} + +/** Startup waits for the drafts 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/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index feae3b5b08c8..0c0ab0dd5436 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -5,7 +5,7 @@ import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-p import { useAppStore } from '../store' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' import { useStartupActions } from './use-app-startup-actions' -import { loadNativeChatDraftsForStartup } from './native-chat-draft-startup' +import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' @@ -182,7 +182,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta const [sessionOutcome, catalogOutcome] = await Promise.allSettled([ hydrationSessionChain, localCatalogChain, - timeRendererStartupStep('native-chat-drafts', loadNativeChatDraftsForStartup) + timeRendererStartupStep('native-chat-drafts', waitForNativeChatDraftsAtStartup) ]) if (sessionOutcome.status === 'rejected') { throw sessionOutcome.reason diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index 0d9342df2a7f..66d5870e1e15 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -2,6 +2,7 @@ import { readNativeChatDraftDocument, writeNativeChatDraftDocument } from './native-chat-draft-cache' +import { hasUnsavedNativeChatComposerDraftChange } from './native-chat-composer-draft-store' import { closeHistory } from '@tiptap/pm/history' import { Slice } from '@tiptap/pm/model' import { @@ -104,9 +105,15 @@ export function NativeChatPromptEditor({ ) }, onTransaction: ({ editor: current, transaction }) => { - // Why: a value set from outside (the store's own draft, another window's, a late load) - // is already the store's; saving it back would make it a local change of this window. - if (scopeKey && transaction.docChanged && !transaction.getMeta('preventUpdate')) { + // Why: a value set from the store with no change of this window behind it (another + // window's draft, a late load) is already saved; saving it back would make it one. A + // value this window just set keeps its document, skill chips included. + if ( + scopeKey && + transaction.docChanged && + (!transaction.getMeta('preventUpdate') || + hasUnsavedNativeChatComposerDraftChange(scopeKey)) + ) { writeNativeChatDraftDocument( scopeKey, promptTextMap(current.state.doc).text, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts index 9e9a09bd7b9e..32864af94ce5 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts @@ -92,6 +92,23 @@ export function createIndexedDbNativeChatComposerDraftStorage( for (const scopeKey of scopeKeys) { drafts.delete(scopeKey) } - }) + }), + update: async (scopeKey, apply) => { + const transaction = (await database()).transaction(DRAFTS, 'readwrite') + const done = committed(transaction) + const drafts = transaction.objectStore(DRAFTS) + const read = drafts.get(scopeKey) + // Why inside the read's callback: the write must land in the same transaction as the read. + read.onsuccess = () => { + const next = apply(read.result) + if (next) { + drafts.put(next, scopeKey) + } else { + drafts.delete(scopeKey) + } + transaction.commit?.() + } + await done + } } } 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 f77d31a301c8..a04d225dcb26 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 @@ -33,19 +33,25 @@ let hydration: Promise | null = null let failedLoads = 0 let retryTimer: ReturnType | null = null -function withAppends(scopeKey: string, loaded: StoredNativeChatComposerDraft): DraftLoadResult { - const pending = load.appendsBeforeLoad.get(scopeKey) - // Why the time check: a load that read after the append's own write already holds it. - if (!pending || loaded.savedAt >= pending.firstWrittenAt) { +/** The appends this load could not have read: made after it began reading, or never written. */ +function withAppends( + scopeKey: string, + loaded: StoredNativeChatComposerDraft, + readAtSequence: number +): DraftLoadResult { + const missing = (load.appendsBeforeLoad.get(scopeKey) ?? []).filter( + (entry) => !entry.committed || entry.sequence > readAtSequence + ) + if (missing.length === 0) { return { draft: loaded, changed: false } } - const merged = pending.appends.reduce((draft, append) => append(draft), loaded) + const merged = missing.reduce((draft, entry) => entry.append(draft), loaded) return { draft: { ...merged, savedAt: nextSavedAt() }, changed: true } } type DraftLoadResult = { draft: StoredNativeChatComposerDraft; changed: boolean } -function applyLoaded(loaded: ReadonlyMap): void { +function applyLoaded(loaded: ReadonlyMap, readAtSequence: number): void { const drafts = new Map() for (const [scopeKey, value] of loaded) { drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) @@ -59,6 +65,12 @@ function applyLoaded(loaded: ReadonlyMap): void { drafts.set(scopeKey, change.draft) dirtyScopes.add(scopeKey) } + for (const [scopeKey, appends] of load.appendsBeforeLoad) { + // Appended with nothing saved before: an empty draft is what the load would have read. + if (!drafts.has(scopeKey) && appends.some((entry) => !entry.committed)) { + drafts.set(scopeKey, { text: '', images: [], savedAt: 0 }) + } + } for (const [scopeKey, stored] of drafts) { if (load.editedBeforeLoad.has(scopeKey)) { continue @@ -68,7 +80,7 @@ function applyLoaded(loaded: ReadonlyMap): void { dirtyScopes.add(scopeKey) continue } - const { draft, changed } = withAppends(scopeKey, stored) + const { draft, changed } = withAppends(scopeKey, stored, readAtSequence) records.set(scopeKey, draft) unverifiedScopes.add(scopeKey) if (changed) { @@ -109,7 +121,9 @@ export function hydrateNativeChatComposerDrafts(): Promise { hydration ??= (async () => { removeLegacyLocalStorageNativeChatComposerDrafts() installNativeChatComposerDraftBroadcast() - applyLoaded(await nativeChatComposerDraftStorage().loadAll()) + // Why read here: storage applies changes in order, so this load reads every append made so far. + const readAtSequence = load.appendSequence + applyLoaded(await nativeChatComposerDraftStorage().loadAll(), readAtSequence) })().catch(retryLater) return hydration } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts index 97f033667725..96ba8bc26afb 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts @@ -27,11 +27,21 @@ export const scopeListeners = new Map void>>() * append (text or images given back, a paste) is applied again on top of it. */ export type DraftAppend = (draft: StoredNativeChatComposerDraft) => StoredNativeChatComposerDraft +/** An append made before the load landed: written onto the stored draft in one change, and made + * again on the loaded draft when that load could not have read it. */ +export type AppendBeforeLoad = { + /** In-run order, compared with the order at which a load attempt read storage. */ + readonly sequence: number + readonly append: DraftAppend + committed: boolean +} + type LoadBookkeeping = { hydrated: boolean + /** Sequence of the last append made before the load landed. */ + appendSequence: number readonly editedBeforeLoad: Set - /** Per scope: the appends, and when the first of them was written. */ - readonly appendsBeforeLoad: Map + readonly appendsBeforeLoad: Map readonly deletionsBeforeLoad: (( scopeKey: string, draft: StoredNativeChatComposerDraft @@ -41,6 +51,7 @@ type LoadBookkeeping = { // The startup load's bookkeeping, emptied once it lands. export const load: LoadBookkeeping = { hydrated: false, + appendSequence: 0, editedBeforeLoad: new Set(), appendsBeforeLoad: new Map(), deletionsBeforeLoad: [] @@ -71,6 +82,7 @@ export function clearDraftMemoryForTests(): void { unverifiedScopes.clear() lastSavedAt = 0 load.hydrated = false + load.appendSequence = 0 load.editedBeforeLoad.clear() load.appendsBeforeLoad.clear() load.deletionsBeforeLoad.length = 0 diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 2aef8d06c24c..06fc7c1eed8b 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -13,6 +13,7 @@ import { refusedScopes, unconfirmed, unverifiedScopes, + type AppendBeforeLoad, type DraftAppend, type DraftRecord, type UnconfirmedDraftChange @@ -116,13 +117,10 @@ export function flushNativeChatComposerDrafts(): void { } /** Why: storage commits after this task, so what it has not confirmed when the window goes away - * is also written synchronously and replayed by the next load. A draft storage already refused - * is left out: it is shown as not saved and retried instead. */ + * is also written synchronously and replayed by the next load, a refused draft included. */ function journalUnconfirmed(): void { flushNativeChatComposerDrafts() - journalNativeChatComposerDraftChanges( - new Map([...unconfirmed].filter(([scopeKey]) => !refusedScopes.has(scopeKey))) - ) + journalNativeChatComposerDraftChanges(unconfirmed) } function journalWhenHidden(): void { @@ -146,6 +144,42 @@ function installFlushOnHide(): void { document.addEventListener('visibilitychange', journalWhenHidden) } +/** Before the load lands, memory may not hold the saved draft, so an append is written onto what + * storage holds, read and written as one change. The loaded draft gets it again on landing. */ +function appendBeforeLoad(scopeKey: string, append: DraftAppend): void { + load.appendSequence += 1 + const entry: AppendBeforeLoad = { sequence: load.appendSequence, append, committed: false } + load.appendsBeforeLoad.set(scopeKey, [...(load.appendsBeforeLoad.get(scopeKey) ?? []), entry]) + const owner = records.get(scopeKey)?.owner + const empty: StoredNativeChatComposerDraft = { text: '', images: [], savedAt: 0 } + const written = nativeChatComposerDraftStorage() + .update(scopeKey, (stored) => { + const base = parseStoredNativeChatComposerDraft(stored) ?? empty + return savedForm({ + ...append({ ...base, ...(base.owner || !owner ? {} : { owner }) }), + savedAt: nextSavedAt() + }) + }) + .then( + () => { + entry.committed = true + channel?.postMessage({ scopeKey }) + }, + (error: unknown) => { + if (!refusedScopes.has(scopeKey)) { + console.warn( + '[native-chat-drafts] a draft could not be saved; it is kept in memory', + error + ) + refusedScopes.add(scopeKey) + notifyScope(scopeKey) + } + } + ) + inFlight.add(written) + void written.finally(() => inFlight.delete(written)) +} + /** Marks a scope changed in memory: `deferred` coalesces typing into one write, `immediate` * writes now. Before the startup load lands, an edit wins over the loaded draft and an append * is applied again on top of it. */ @@ -154,19 +188,15 @@ export function persistNativeChatComposerDraft( persist: 'immediate' | 'deferred', append?: DraftAppend ): void { + installFlushOnHide() + if (!load.hydrated && append && !load.editedBeforeLoad.has(scopeKey)) { + appendBeforeLoad(scopeKey, append) + return + } dirtyScopes.add(scopeKey) if (!load.hydrated) { - if (append) { - const pending = load.appendsBeforeLoad.get(scopeKey) - load.appendsBeforeLoad.set(scopeKey, { - firstWrittenAt: pending?.firstWrittenAt ?? records.get(scopeKey)?.savedAt ?? 0, - appends: [...(pending?.appends ?? []), append] - }) - } else { - load.editedBeforeLoad.add(scopeKey) - } + load.editedBeforeLoad.add(scopeKey) } - installFlushOnHide() if (persist === 'immediate') { flushNativeChatComposerDrafts() return diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index e90ca4641cbf..83323315c838 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -40,6 +40,11 @@ export type NativeChatComposerDraftStorage = { read(scopeKey: string): Promise write(scopeKey: string, draft: StoredNativeChatComposerDraft): Promise remove(scopeKeys: readonly string[]): Promise + /** Reads the stored record and writes what `apply` makes of it, as one change. */ + update( + scopeKey: string, + apply: (stored: unknown) => StoredNativeChatComposerDraft | null + ): Promise } /** Drafts kept for this run only: the storage of an environment without IndexedDB (unit tests), @@ -66,6 +71,21 @@ export function createMemoryNativeChatComposerDraftStorage(): NativeChatComposer drafts.delete(scopeKey) } return Promise.resolve() + }, + update: ( + scopeKey: string, + apply: (stored: unknown) => StoredNativeChatComposerDraft | null + ) => { + if (storage.refuseWrites) { + return Promise.reject(new DOMException('refused', 'QuotaExceededError')) + } + const next = apply(drafts.get(scopeKey)) + if (next) { + drafts.set(scopeKey, next) + } else { + drafts.delete(scopeKey) + } + return Promise.resolve() } } return storage @@ -82,7 +102,7 @@ export function nativeChatComposerDraftStorage(): NativeChatComposerDraftStorage } export function setNativeChatComposerDraftStorageForTests( - next: NativeChatComposerDraftStorage + next: NativeChatComposerDraftStorage | null ): void { storage = next } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index cddedca13a3a..5f07f1e96363 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -399,24 +399,74 @@ describe('native-chat composer draft store', () => { }) }) - it('does not add an early append twice when the load already read its write', async () => { - storage.drafts.set('agent-session:s1', { text: 'saved earlier', images: [], savedAt: 1 }) - let land: () => void = () => {} - // This load reads only when it lands, after the append's own write. - const late = { + it('keeps the saved draft when a load that failed once reads after text was given back', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + storage.drafts.set('agent-session:s1', { text: 'saved typing', images: [], savedAt: 1 }) + let failures = 1 + const flaky = { ...storage, loadAll: () => - new Promise>((resolve) => { - land = () => resolve(new Map(storage.drafts)) + failures-- > 0 ? Promise.reject(new Error('backing store')) : storage.loadAll() + } + const reloaded = await reload({ using: flaky, hydrate: false }) + await reloaded.store.waitForNativeChatComposerDrafts(1) + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'given back') + await reloaded.store.nativeChatComposerDraftWritesSettled() + await new Promise((resolve) => setTimeout(resolve, 1_200)) + await reloaded.store.hydrateNativeChatComposerDrafts() + await reloaded.store.nativeChatComposerDraftWritesSettled() + + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'saved typing\n\ngiven back' + ) + expect(storedDraft('agent-session:s1')?.text).toBe('saved typing\n\ngiven back') + warn.mockRestore() + }) + + it('keeps an append made after a retried load began reading', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let failures = 1 + let land: () => void = () => {} + const flaky = { + ...storage, + loadAll: () => { + if (failures-- > 0) { + return Promise.reject(new Error('backing store')) + } + const snapshot = new Map(storage.drafts) + return new Promise>((resolve) => { + land = () => resolve(snapshot) }) + } } - const reloaded = await reload({ using: late, hydrate: false }) + const reloaded = await reload({ using: flaky, hydrate: false }) await reloaded.store.waitForNativeChatComposerDrafts(1) - reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'first') + await reloaded.store.nativeChatComposerDraftWritesSettled() + await new Promise((resolve) => setTimeout(resolve, 1_200)) + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'second') + await reloaded.store.nativeChatComposerDraftWritesSettled() land() await reloaded.store.hydrateNativeChatComposerDrafts() - expect(reloaded.drafts.readNativeChatDraftCache('agent-session:s1')).toBe('returned by Stop') + await reloaded.store.nativeChatComposerDraftWritesSettled() + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:s1')).toBe('first\n\nsecond') + expect(storedDraft('agent-session:s1')?.text).toBe('first\n\nsecond') + warn.mockRestore() + }) + + it('starts the load on its first write when startup never did', async () => { + storage.drafts.set('agent-session:other', { text: 'saved elsewhere', images: [], savedAt: 1 }) + const counted = { ...storage, loadAll: vi.fn(() => storage.loadAll()) } + const reloaded = await reload({ using: counted, hydrate: false }) + reloaded.drafts.writeNativeChatDraftCache('tab-1:pane', 'typed') + + expect(counted.loadAll).toHaveBeenCalledTimes(1) + await vi.waitFor(() => + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:other')).toBe( + 'saved elsewhere' + ) + ) }) it('retries a failed load a few times, warns once, and then stops', async () => { @@ -494,31 +544,27 @@ describe('native-chat composer draft store', () => { expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toBeNull() }) - it('keeps the journal small: never a refused draft, and never past its cap', async () => { + it('keeps a refused draft in the journal for the next run, and the journal under its cap', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) storage.refuseWrites = true + modules.drafts.writeNativeChatDraftCache('agent-session:s1', 'typed while storage refuses') for (let index = 0; index < 10; index += 1) { - modules.drafts.appendNativeChatDraftCache( - `agent-session:refused-${index}`, - 'x'.repeat(200_000) - ) + modules.drafts.appendNativeChatDraftCache(`agent-session:big-${index}`, 'x'.repeat(200_000)) } + modules.store.flushNativeChatComposerDrafts() await modules.store.nativeChatComposerDraftWritesSettled() window.dispatchEvent(new Event('pagehide')) - expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toBeNull() - - storage.refuseWrites = false - const hanging = { ...storage, write: () => new Promise(() => {}) } - const unconfirmedTab = await reload({ using: hanging }) - for (let index = 0; index < 10; index += 1) { - unconfirmedTab.drafts.appendNativeChatDraftCache( - `agent-session:big-${index}`, - 'y'.repeat(60_000) - ) - } - window.dispatchEvent(new Event('pagehide')) + await modules.store.nativeChatComposerDraftWritesSettled() const journal = localStorage.getItem('orca:nativeChatComposerDraftJournal:v1') ?? '' - expect(journal.length).toBeGreaterThan(0) expect(journal.length).toBeLessThanOrEqual(256_000) + modules.store.clearNativeChatComposerDraftsForTests() + + storage.refuseWrites = false + const next = await reload() + expect(next.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'typed while storage refuses' + ) + warn.mockRestore() }) it('keeps a restored image checked when another window saves the draft with the same image', async () => { 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 f797d928c928..ac891bafa60b 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 @@ -11,6 +11,8 @@ import { import { clearDraftMemoryForTests, dirtyScopes, + hasLocalChange, + load, nextSavedAt, notifyScope, records, @@ -25,12 +27,16 @@ import { persistNativeChatComposerDraft, resetNativeChatComposerDraftPersistenceForTests } from './native-chat-composer-draft-persistence' -import { resetNativeChatComposerDraftLoadForTests } from './native-chat-composer-draft-load' -import type { - NativeChatComposerDraft, - NativeChatComposerDraftImage, - NativeChatComposerDraftOwner, - StoredNativeChatComposerDraft +import { + hydrateNativeChatComposerDrafts, + resetNativeChatComposerDraftLoadForTests +} from './native-chat-composer-draft-load' +import { + setNativeChatComposerDraftStorageForTests, + type NativeChatComposerDraft, + type NativeChatComposerDraftImage, + type NativeChatComposerDraftOwner, + type StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' export { @@ -44,6 +50,11 @@ export { waitForNativeChatComposerDrafts } from './native-chat-composer-draft-load' +/** This window has changed the draft and storage has not confirmed it yet. */ +export function hasUnsavedNativeChatComposerDraftChange(scopeKey: string): boolean { + return hasLocalChange(scopeKey) +} + export type NativeChatComposerDraftChange = { text?: string /** Present, even as undefined, to replace the document. */ @@ -150,6 +161,10 @@ export function updateNativeChatComposerDraft( records.set(scopeKey, record) notifyScope(scopeKey) persistNativeChatComposerDraft(scopeKey, persist, append) + if (!load.hydrated) { + // Why: a window whose startup never started the load still gets its saved drafts. + void hydrateNativeChatComposerDrafts() + } } /** @@ -260,5 +275,6 @@ export function clearNativeChatComposerDraftsForTests(): void { clearDraftMemoryForTests() resetNativeChatComposerDraftPersistenceForTests() resetNativeChatComposerDraftLoadForTests() + setNativeChatComposerDraftStorageForTests(null) resolveOwner = null } diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx index 3f118cc7e436..4aa15f40350a 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx @@ -15,7 +15,7 @@ const SCOPE = 'agent-session:s1' let storage = createMemoryNativeChatComposerDraftStorage() const loaded: Renderer[] = [] -async function open(): Promise { +async function open(options: { hydrate?: boolean } = {}): Promise { vi.resetModules() const storageModule = await import('./native-chat-composer-draft-storage') storageModule.setNativeChatComposerDraftStorageForTests(storage) @@ -24,15 +24,19 @@ async function open(): Promise { store: await import('./native-chat-composer-draft-store') } loaded.push(renderer) - await renderer.store.hydrateNativeChatComposerDrafts() + if (options.hydrate !== false) { + await renderer.store.hydrateNativeChatComposerDrafts() + } return renderer } /** The editor, plus the field's effect that sets the store's draft on it. */ -async function mountEditor(renderer: Renderer): Promise<() => void> { +async function mountEditor( + renderer: Renderer +): Promise<{ stopSync: () => void; input: () => NativeChatComposerInput; unmount: () => void }> { const { NativeChatPromptEditor } = await import('./NativeChatPromptEditor') const inputRef = createRef() - render( + const view = render( createElement(NativeChatPromptEditor, { scopeKey: SCOPE, inputRef, @@ -44,12 +48,29 @@ async function mountEditor(renderer: Renderer): Promise<() => void> { }) ) await vi.waitFor(() => expect(inputRef.current).not.toBeNull()) - return renderer.store.subscribeToNativeChatComposerDraft(SCOPE, () => { - const draft = renderer.drafts.readNativeChatDraftCache(SCOPE) - if (inputRef.current && inputRef.current.value !== draft) { - inputRef.current.value = draft - } + const stopSync = renderer.store.subscribeToNativeChatComposerDraft(SCOPE, () => { + // Like the field's layout effect, after the store change returns. + queueMicrotask(() => { + const draft = renderer.drafts.readNativeChatDraftCache(SCOPE) + if (inputRef.current && inputRef.current.value !== draft) { + inputRef.current.value = draft + } + }) }) + return { stopSync, input: () => inputRef.current!, unmount: view.unmount } +} + +const SKILL_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: ' this' } + ] + } + ] } const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -67,24 +88,49 @@ afterEach(() => { }) describe('the editor and the draft store', () => { - it('saves nothing when the store’s draft is set on the editor', async () => { - const renderer = await open() - const stopSync = await mountEditor(renderer) - await act(async () => renderer.drafts.appendNativeChatDraftCache(SCOPE, 'hello')) - await renderer.store.nativeChatComposerDraftWritesSettled() - const savedAt = storage.drafts.get(SCOPE)?.savedAt + it('saves nothing when a draft that loads late is set on the editor', async () => { + storage.drafts.set(SCOPE, { text: 'saved before the restart', images: [], savedAt: 1 }) + const renderer = await open({ hydrate: false }) + const { stopSync, input } = await mountEditor(renderer) + const write = vi.spyOn(storage, 'write') + await act(async () => renderer.store.hydrateNativeChatComposerDrafts()) await act(async () => pause(400)) await renderer.store.nativeChatComposerDraftWritesSettled() stopSync() - expect(storage.drafts.get(SCOPE)?.savedAt).toBe(savedAt) - // An echo would have saved the editor's own document over the store's text-only draft. - expect(storage.drafts.get(SCOPE)?.document).toBeUndefined() + expect(input().value).toBe('saved before the restart') + expect(write).not.toHaveBeenCalled() + }) + + it('keeps the skill chip when the composer itself changes the text, across a remount', async () => { + const renderer = await open() + renderer.drafts.writeNativeChatDraftDocument(SCOPE, '$review this', SKILL_DOCUMENT) + renderer.store.flushNativeChatComposerDrafts() + const first = await mountEditor(renderer) + await vi.waitFor(() => + expect(window.document.querySelector('[data-native-chat-skill]')).not.toBeNull() + ) + // A mention accepted: the composer writes the text, then the field sets it on the editor. + const next = '$review this @src/a.ts ' + act(() => { + renderer.drafts.writeNativeChatDraftCache(SCOPE, next) + first.input().value = next + }) + await act(async () => pause(400)) + await renderer.store.nativeChatComposerDraftWritesSettled() + expect(JSON.stringify(storage.drafts.get(SCOPE)?.document ?? null)).toContain('nativeChatSkill') + + first.stopSync() + first.unmount() + const second = await mountEditor(renderer) + await pause(50) + second.stopSync() + expect(window.document.querySelector('[data-native-chat-skill]')).not.toBeNull() }) it('lets another window’s send stand, instead of echoing back the draft it showed', async () => { const receiving = await open() - const stopSync = await mountEditor(receiving) + const { stopSync } = await mountEditor(receiving) const sending = await open() sending.drafts.writeNativeChatDraftCache(SCOPE, 'hello') sending.store.flushNativeChatComposerDrafts() @@ -105,20 +151,9 @@ describe('the editor and the draft store', () => { }) it('shows a draft that arrives later with its skill chip, from its saved document', async () => { - const document = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { type: 'nativeChatSkill', attrs: { token: '$review' } }, - { type: 'text', text: ' this' } - ] - } - ] - } + const document = SKILL_DOCUMENT const receiving = await open() - const stopSync = await mountEditor(receiving) + const { stopSync } = await mountEditor(receiving) const sending = await open() sending.drafts.writeNativeChatDraftDocument(SCOPE, '$review this', document) sending.store.flushNativeChatComposerDrafts() From a1fd9dfe2b30b5e33fc4fd6a864487058b588a2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:30:29 -0700 Subject: [PATCH 30/38] fix(native-chat): text given back is durable before its source is deleted Draft writes reach IndexedDB asynchronously, but Stop's restore and a queued card's Edit delete their source (the outbox entry, the card) right after adding the text to the draft. A crash before the write committed lost it. An addition (text or images given back or attached) is now journaled to localStorage synchronously, as data, the moment it is made, and dropped once storage confirms a change at least as new. The next run replays it onto the stored draft only when storage doesn't already hold it, and only where the draft doesn't already end with that text or hold that image, so a crash either side of the commit gives the text back exactly once and resends nothing. Only earlier runs' entries are replayed. The append reports whether the addition is durable. Edit, when it isn't (the journal full or refused), deletes the card only once the draft is saved, and keeps it if storage refuses. Additions may grow the journal to 800,000 characters; whole drafts still stop at 256,000. --- .../native-chat-composer-draft-addition.ts | 53 ++++++ ...tive-chat-composer-draft-indexeddb.test.ts | 112 +++++++++++++ .../native-chat-composer-draft-journal.ts | 152 +++++++++++++++--- .../native-chat-composer-draft-load.ts | 13 +- .../native-chat-composer-draft-persistence.ts | 2 + .../native-chat-composer-draft-store.test.ts | 95 ++++++++++- .../native-chat-composer-draft-store.ts | 45 ++++-- .../native-chat/native-chat-draft-cache.ts | 19 +-- .../use-native-chat-composer-attachments.ts | 26 ++- ...red-agent-session-queued-messages.test.tsx | 23 +++ ...tructured-agent-session-queued-messages.ts | 12 +- 11 files changed, 465 insertions(+), 87 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts 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 new file mode 100644 index 000000000000..de424cb261bd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts @@ -0,0 +1,53 @@ +// What is added to a draft without replacing it: text given back (Stop, a queued card's Edit) and +// images given back or attached. Kept as data, so the same addition can be made again on a draft +// loaded later, or replayed from the journal after a crash. + +import { basename } from '@/lib/path' +import type { + NativeChatComposerDraft, + NativeChatComposerDraftImage +} from './native-chat-composer-draft-storage' + +export type NativeChatComposerDraftAddition = { + readonly text?: string + readonly images?: readonly NativeChatComposerDraftImage[] + /** Only an image the user attaches takes the place of a placeholder with its file name. */ + readonly fromUser?: boolean +} + +export function appendNativeChatDraftText(draft: string, text: string): string { + return draft === '' ? text : `${draft.trimEnd()}\n\n${text}` +} + +/** + * The draft's text and images with the addition made. `once` makes it again only where it is not + * already there (text the draft already ends with, an image id it holds): a replay after a crash + * meets a draft that may already have been saved with it. + */ +export function withNativeChatComposerDraftAddition( + draft: NativeChatComposerDraft, + addition: NativeChatComposerDraftAddition, + options: { once?: boolean } = {} +): Pick { + const added = addition.text ?? '' + const text = + added === '' || (options.once && draft.text.endsWith(added)) + ? draft.text + : appendNativeChatDraftText(draft.text, added) + const images = [...draft.images] + for (const { id, path, connectionId } of addition.images ?? []) { + if (options.once && images.some((held) => held.id === id)) { + continue + } + const image = { id, path, ...(connectionId ? { connectionId } : {}) } + const placeholder = addition.fromUser + ? images.findIndex((held) => held.unavailableName === basename(path)) + : -1 + if (placeholder === -1) { + images.push(image) + } else { + images[placeholder] = image + } + } + return { text, images } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts new file mode 100644 index 000000000000..6278a69f1075 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts @@ -0,0 +1,112 @@ +// A write counts as saved only once IndexedDB has committed it (the transaction's `complete`), not +// when its `put` is queued: a crash in between would otherwise lose a draft reported as saved. +import { describe, expect, it } from 'vitest' +import { createIndexedDbNativeChatComposerDraftStorage } from './native-chat-composer-draft-indexeddb' + +type FakeTransaction = { + oncomplete: (() => void) | null + onerror: (() => void) | null + onabort: (() => void) | null + error: Error | null + objectStore: () => FakeStore + commit: () => void +} +type FakeStore = { + put: (value: unknown, key: string) => object + delete: (key: string) => object + get: (key: string) => { result: unknown; onsuccess: (() => void) | null } +} + +function fakeDatabase(): { + factory: IDBFactory + transactions: FakeTransaction[] + puts: unknown[] +} { + const transactions: FakeTransaction[] = [] + const puts: unknown[] = [] + const database = { + objectStoreNames: { contains: () => true }, + transaction: () => { + const store: FakeStore = { + put: (value) => { + puts.push(value) + return {} + }, + delete: () => ({}), + get: () => { + const request: { result: unknown; onsuccess: (() => void) | null } = { + result: undefined, + onsuccess: null + } + queueMicrotask(() => request.onsuccess?.()) + return request + } + } + const transaction: FakeTransaction = { + oncomplete: null, + onerror: null, + onabort: null, + error: null, + objectStore: () => store, + commit: () => {} + } + transactions.push(transaction) + return transaction + } + } + const factory = { + open: () => { + const request: { result: typeof database; onsuccess: (() => void) | null } = { + result: database, + onsuccess: null + } + queueMicrotask(() => request.onsuccess?.()) + return request + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in exposing only the calls the adapter makes. + return { factory: factory as unknown as IDBFactory, transactions, puts } +} + +const DRAFT = { text: 'unsent', images: [], savedAt: 1 } +const settledYet = async (promise: Promise): Promise => { + let settled = false + void promise.then(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + return settled +} + +describe('the IndexedDB draft storage', () => { + it('settles a write only when its transaction completes', async () => { + const { factory, transactions, puts } = fakeDatabase() + const write = createIndexedDbNativeChatComposerDraftStorage(factory).write('scope', DRAFT) + + expect(await settledYet(write)).toBe(false) + expect(puts).toEqual([DRAFT]) + transactions[0]!.oncomplete?.() + await expect(write).resolves.toBeUndefined() + }) + + it('settles a read-modify-write only when its transaction completes', async () => { + const { factory, transactions, puts } = fakeDatabase() + const update = createIndexedDbNativeChatComposerDraftStorage(factory).update( + 'scope', + () => DRAFT + ) + + expect(await settledYet(update)).toBe(false) + expect(puts).toEqual([DRAFT]) + transactions[0]!.oncomplete?.() + await expect(update).resolves.toBeUndefined() + }) + + it('rejects a write whose transaction aborts, as on a full disk', async () => { + const { factory, transactions } = fakeDatabase() + const write = createIndexedDbNativeChatComposerDraftStorage(factory).write('scope', DRAFT) + await new Promise((resolve) => setTimeout(resolve, 0)) + transactions[0]!.error = new Error('QuotaExceededError') + transactions[0]!.onabort?.() + + await expect(write).rejects.toThrow('QuotaExceededError') + }) +}) 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 7434906fb39d..8f837292927d 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 @@ -1,71 +1,162 @@ // The unload journal: changes storage had not confirmed when a window went away, written // synchronously to localStorage and replayed by the next load. Every window of the app shares it, // so each entry is pruned the moment any window confirms a change to that draft. +// +// Two kinds of entry: a whole draft (or its removal) journaled when the window goes away, and an +// addition (text or images given back) journaled the moment it is made, because the copy it came +// from is deleted right after and a crash fires no unload event. import type { UnconfirmedDraftChange } from './native-chat-composer-draft-memory' -import { parseStoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' +import { + withNativeChatComposerDraftAddition, + type NativeChatComposerDraftAddition +} from './native-chat-composer-draft-addition' +import { + parseStoredNativeChatComposerDraft, + type StoredNativeChatComposerDraft +} from './native-chat-composer-draft-storage' const JOURNAL_KEY = 'orca:nativeChatComposerDraftJournal:v1' // Why: localStorage also holds the send outbox, and a send is refused when its entry can't be -// saved, so the journal never takes more than this. +// saved, so the journal stays small: whole drafts stop at the first cap, and additions, at most +// one message each with their source deleted right after, at the second, which fits the largest. export const MAX_JOURNAL_CHARS = 256_000 +export const MAX_JOURNAL_WITH_ADDITIONS_CHARS = 800_000 -type JournalEntry = UnconfirmedDraftChange & { readonly scopeKey: string } +// Why: a load replays only what earlier runs left, never this run's own entries, which its +// memory already holds. +const THIS_RUN = `${Date.now()}-${Math.random()}` + +type EntryBase = { readonly scopeKey: string; readonly at: number; readonly run?: string } +type DraftEntry = EntryBase & { readonly draft: StoredNativeChatComposerDraft | null } +type AdditionEntry = EntryBase & { readonly addition: NativeChatComposerDraftAddition } +type JournalEntry = DraftEntry | AdditionEntry function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function isAddition(entry: JournalEntry): entry is AdditionEntry { + return 'addition' in entry +} + +function parseEntry(entry: unknown): JournalEntry[] { + if (!isRecord(entry)) { + return [] + } + const { scopeKey, draft, at, addition } = entry + if (typeof scopeKey !== 'string' || typeof at !== 'number') { + return [] + } + const run = typeof entry.run === 'string' ? entry.run : undefined + if (!isRecord(addition)) { + return [{ scopeKey, draft: parseStoredNativeChatComposerDraft(draft), at, run }] + } + // Parsed as a draft, so its text and images are checked the same way. + const parsed = parseStoredNativeChatComposerDraft({ + text: typeof addition.text === 'string' ? addition.text : '', + images: Array.isArray(addition.images) ? addition.images : [], + savedAt: at + }) + return parsed + ? [ + { + scopeKey, + at, + run, + addition: { + text: parsed.text, + images: parsed.images, + ...(addition.fromUser === true ? { fromUser: true } : {}) + } + } + ] + : [] +} + function readEntries(): JournalEntry[] { try { const raw = localStorage.getItem(JOURNAL_KEY) const entries: unknown = raw === null ? [] : JSON.parse(raw) - if (!Array.isArray(entries)) { - return [] - } - return entries.flatMap((entry: unknown) => { - if (!isRecord(entry)) { - return [] - } - const { scopeKey, draft, at } = entry - return typeof scopeKey === 'string' && typeof at === 'number' - ? [{ scopeKey, draft: parseStoredNativeChatComposerDraft(draft), at }] - : [] - }) + return Array.isArray(entries) ? entries.flatMap(parseEntry) : [] } catch { return [] } } -function writeEntries(entries: readonly JournalEntry[]): void { +function writeEntries(entries: readonly JournalEntry[]): boolean { try { if (entries.length === 0) { localStorage.removeItem(JOURNAL_KEY) - return + } else { + localStorage.setItem(JOURNAL_KEY, JSON.stringify(entries)) } - localStorage.setItem(JOURNAL_KEY, JSON.stringify(entries)) + return true } catch { // A full localStorage loses only what storage itself had not yet confirmed. + return false } } -export function readNativeChatComposerDraftJournal(): Map { - return new Map(readEntries().map(({ scopeKey, draft, at }) => [scopeKey, { draft, at }])) +/** + * Replays what earlier runs journaled onto the loaded drafts: a whole draft newer than the stored + * one replaces it, then each addition newer than the draft it meets is made again, once. Returns + * the drafts it changed, which must be written; entries already older than storage are dropped. + */ +export function replayNativeChatComposerDraftJournal( + drafts: Map +): Set { + const changed = new Set() + const entries = readEntries().filter((entry) => entry.run !== THIS_RUN) + const removedAt = new Map() + for (const entry of entries) { + if (isAddition(entry)) { + continue + } + const stored = drafts.get(entry.scopeKey) + if (stored && stored.savedAt >= entry.at) { + pruneNativeChatComposerDraftJournal(entry.scopeKey, entry.at) + continue + } + drafts.set(entry.scopeKey, entry.draft) + if (!entry.draft) { + removedAt.set(entry.scopeKey, entry.at) + } + changed.add(entry.scopeKey) + } + const additions = entries.filter(isAddition).sort((left, right) => left.at - right.at) + for (const { scopeKey, at, addition } of additions) { + const base = drafts.get(scopeKey) ?? null + if ((base?.savedAt ?? removedAt.get(scopeKey) ?? 0) >= at) { + pruneNativeChatComposerDraftJournal(scopeKey, at) + continue + } + const from = base ?? { text: '', images: [], savedAt: 0 } + const next = withNativeChatComposerDraftAddition(from, addition, { once: true }) + drafts.set(scopeKey, { + ...from, + ...next, + ...(next.text === from.text ? {} : { document: undefined }), + savedAt: at + }) + changed.add(scopeKey) + } + return changed } -/** Adds this window's unconfirmed changes, keeping other windows' entries, within the cap. An - * entry that doesn't fit is left out; its change was still issued to storage. */ +/** Adds this window's unconfirmed changes, keeping other entries, with whole drafts within the + * cap. A draft that doesn't fit is left out; its change was still issued to storage. */ export function journalNativeChatComposerDraftChanges( changes: ReadonlyMap ): void { if (changes.size === 0) { return } - const kept = readEntries().filter((entry) => !changes.has(entry.scopeKey)) + const kept = readEntries().filter((entry) => isAddition(entry) || !changes.has(entry.scopeKey)) let used = JSON.stringify(kept).length const added: JournalEntry[] = [] const bySize = [...changes] - .map(([scopeKey, change]) => ({ scopeKey, ...change })) + .map(([scopeKey, change]) => ({ scopeKey, ...change, run: THIS_RUN })) .map((entry) => ({ entry, size: JSON.stringify(entry).length + 1 })) .sort((left, right) => left.size - right.size) for (const { entry, size } of bySize) { @@ -80,7 +171,18 @@ export function journalNativeChatComposerDraftChanges( } } -/** Drops a draft's entry once a change to it at least as new is confirmed, by any window, so a +/** Journals an addition the moment it is made; false when it doesn't fit or localStorage + * refused it, and the addition is then durable only once storage confirms it. */ +export function journalNativeChatComposerDraftAddition( + scopeKey: string, + addition: NativeChatComposerDraftAddition, + at: number +): boolean { + const entries: JournalEntry[] = [...readEntries(), { scopeKey, at, run: THIS_RUN, addition }] + return JSON.stringify(entries).length <= MAX_JOURNAL_WITH_ADDITIONS_CHARS && writeEntries(entries) +} + +/** Drops a draft's entries once a change to it at least as new is confirmed, by any window, so a * replay never brings back what was sent or replaced since. */ export function pruneNativeChatComposerDraftJournal(scopeKey: string, confirmedAt: number): void { try { 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 a04d225dcb26..fa6a11bbd7af 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 @@ -14,10 +14,7 @@ import { flushNativeChatComposerDrafts, installNativeChatComposerDraftBroadcast } from './native-chat-composer-draft-persistence' -import { - pruneNativeChatComposerDraftJournal, - readNativeChatComposerDraftJournal -} from './native-chat-composer-draft-journal' +import { replayNativeChatComposerDraftJournal } from './native-chat-composer-draft-journal' import { nativeChatComposerDraftStorage, parseStoredNativeChatComposerDraft, @@ -56,13 +53,7 @@ function applyLoaded(loaded: ReadonlyMap, readAtSequence: numbe for (const [scopeKey, value] of loaded) { drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) } - for (const [scopeKey, change] of readNativeChatComposerDraftJournal()) { - const stored = drafts.get(scopeKey) - if (stored && stored.savedAt >= change.at) { - pruneNativeChatComposerDraftJournal(scopeKey, change.at) - continue - } - drafts.set(scopeKey, change.draft) + for (const scopeKey of replayNativeChatComposerDraftJournal(drafts)) { dirtyScopes.add(scopeKey) } for (const [scopeKey, appends] of load.appendsBeforeLoad) { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 06fc7c1eed8b..528ac519d706 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -151,6 +151,7 @@ function appendBeforeLoad(scopeKey: string, append: DraftAppend): void { const entry: AppendBeforeLoad = { sequence: load.appendSequence, append, committed: false } load.appendsBeforeLoad.set(scopeKey, [...(load.appendsBeforeLoad.get(scopeKey) ?? []), entry]) const owner = records.get(scopeKey)?.owner + const appendedAt = records.get(scopeKey)?.savedAt ?? 0 const empty: StoredNativeChatComposerDraft = { text: '', images: [], savedAt: 0 } const written = nativeChatComposerDraftStorage() .update(scopeKey, (stored) => { @@ -163,6 +164,7 @@ function appendBeforeLoad(scopeKey: string, append: DraftAppend): void { .then( () => { entry.committed = true + pruneNativeChatComposerDraftJournal(scopeKey, appendedAt) channel?.postMessage({ scopeKey }) }, (error: unknown) => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 5f07f1e96363..bf0ff0043066 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -117,6 +117,71 @@ describe('native-chat composer draft store', () => { expect(localStorage.length).toBe(0) }) + it('keeps text given back through a crash before storage commits it, once', async () => { + storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) + const crashing = { ...storage, write: () => new Promise(() => {}) } + const crashed = await reload({ using: crashing }) + // Stop gives a message back; its outbox entry is deleted right after, then Orca crashes. + expect(crashed.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop')).toBe( + true + ) + crashed.store.clearNativeChatComposerDraftsForTests() + + const next = await reload() + expect(next.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'typed earlier\n\nreturned by Stop' + ) + await next.store.nativeChatComposerDraftWritesSettled() + expect(storedDraft('agent-session:s1')?.text).toBe('typed earlier\n\nreturned by Stop') + expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toBeNull() + }) + + it('does not give text back twice when storage committed it but the window died first', async () => { + storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) + const landsUnconfirmed = { + ...storage, + write: (scopeKey: string, draft: Parameters[1]) => { + storage.drafts.set(scopeKey, draft) + return new Promise(() => {}) + } + } + const crashed = await reload({ using: landsUnconfirmed }) + crashed.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + crashed.attachments.appendNativeChatAttachmentCache('agent-session:s1', [IMAGES[0]]) + crashed.store.clearNativeChatComposerDraftsForTests() + + const next = await reload() + expect(next.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'typed earlier\n\nreturned by Stop' + ) + expect(next.attachments.readNativeChatAttachmentCache('agent-session:s1')).toEqual([IMAGES[0]]) + }) + + it('keeps text given back before the load lands through a crash, without losing the saved draft', async () => { + storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) + let land: () => void = () => {} + const crashing = { + ...storage, + loadAll: () => { + const snapshot = new Map(storage.drafts) + return new Promise>((resolve) => { + land = () => resolve(snapshot) + }) + }, + update: () => new Promise(() => {}) + } + const crashed = await reload({ using: crashing, hydrate: false }) + await crashed.store.waitForNativeChatComposerDrafts(1) + crashed.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + crashed.store.clearNativeChatComposerDraftsForTests() + land() + + const next = await reload() + expect(next.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'typed earlier\n\nreturned by Stop' + ) + }) + it('saves text and images given back to the composer at once, before their other copy goes', async () => { vi.useFakeTimers() // No timer advanced: a crash right after either restore still keeps it. @@ -544,19 +609,14 @@ describe('native-chat composer draft store', () => { expect(localStorage.getItem('orca:nativeChatComposerDraftJournal:v1')).toBeNull() }) - it('keeps a refused draft in the journal for the next run, and the journal under its cap', async () => { + it('keeps a refused draft in the journal for the next run', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) storage.refuseWrites = true modules.drafts.writeNativeChatDraftCache('agent-session:s1', 'typed while storage refuses') - for (let index = 0; index < 10; index += 1) { - modules.drafts.appendNativeChatDraftCache(`agent-session:big-${index}`, 'x'.repeat(200_000)) - } modules.store.flushNativeChatComposerDrafts() await modules.store.nativeChatComposerDraftWritesSettled() window.dispatchEvent(new Event('pagehide')) await modules.store.nativeChatComposerDraftWritesSettled() - const journal = localStorage.getItem('orca:nativeChatComposerDraftJournal:v1') ?? '' - expect(journal.length).toBeLessThanOrEqual(256_000) modules.store.clearNativeChatComposerDraftsForTests() storage.refuseWrites = false @@ -567,6 +627,29 @@ describe('native-chat composer draft store', () => { warn.mockRestore() }) + it('keeps the journal under its cap while storage refuses', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + storage.refuseWrites = true + const durable: boolean[] = [] + for (let index = 0; index < 10; index += 1) { + durable.push( + modules.store.appendToNativeChatComposerDraft(`agent-session:big-${index}`, { + text: 'x'.repeat(200_000) + }) + ) + modules.drafts.writeNativeChatDraftCache(`tab-${index}:pane`, 'y'.repeat(60_000)) + } + modules.store.flushNativeChatComposerDrafts() + await modules.store.nativeChatComposerDraftWritesSettled() + window.dispatchEvent(new Event('pagehide')) + + const journal = localStorage.getItem('orca:nativeChatComposerDraftJournal:v1') ?? '' + expect(journal.length).toBeLessThanOrEqual(800_000) + // What no longer fits is reported, so its source is kept until storage confirms it. + expect(durable).toContain(false) + warn.mockRestore() + }) + it('keeps a restored image checked when another window saves the draft with the same image', async () => { const other = await reload() other.attachments.appendNativeChatAttachmentCache('tab-1:pane', [IMAGES[0]]) 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 ac891bafa60b..add046336b1c 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 @@ -8,6 +8,11 @@ import { sameNativeChatComposerDraftDocument, sameNativeChatComposerDraftImages } from './native-chat-composer-draft-comparison' +import { + withNativeChatComposerDraftAddition, + type NativeChatComposerDraftAddition +} from './native-chat-composer-draft-addition' +import { journalNativeChatComposerDraftAddition } from './native-chat-composer-draft-journal' import { clearDraftMemoryForTests, dirtyScopes, @@ -167,30 +172,38 @@ export function updateNativeChatComposerDraft( } } +function withAddition( + draft: T, + addition: NativeChatComposerDraftAddition +): T { + const next = withNativeChatComposerDraftAddition(draft, addition) + return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) } +} + /** - * Adds to the scope's draft as it is now: text or images given back, or attached. Saved at once. - * Before the startup load lands, the same addition is made again to the loaded draft, so nothing - * saved earlier is replaced by it. + * Adds to the scope's draft as it is now: text or images given back, or attached. Before the + * startup load lands, the same addition is made again to the loaded draft, so nothing saved + * earlier is replaced by it. True once the addition is durable: storage commits later, so it is + * also journaled at once, and the copy it came from may then be deleted. */ export function appendToNativeChatComposerDraft( scopeKey: string, - append: (draft: NativeChatComposerDraft) => NativeChatComposerDraftChange -): void { + addition: NativeChatComposerDraftAddition +): boolean { + const before = records.get(scopeKey) + const current = readNativeChatComposerDraft(scopeKey) + const { text, images } = withAddition(current, addition) updateNativeChatComposerDraft( scopeKey, - append(readNativeChatComposerDraft(scopeKey)), + { text, images, ...(text === current.text ? {} : { document: undefined }) }, 'immediate', - (loaded) => { - const change = append(loaded) - const document = 'document' in change ? change.document : loaded.document - return { - ...loaded, - text: change.text ?? loaded.text, - images: change.images ?? loaded.images, - ...(document ? { document } : { document: undefined }) - } - } + (loaded) => withAddition(loaded, addition) ) + const after = records.get(scopeKey) + if (!after || after === before) { + return true + } + return journalNativeChatComposerDraftAddition(scopeKey, addition, after.savedAt) } /** 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 50748f19d41c..41a47cd1eaaf 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 @@ -33,25 +33,22 @@ export function writeNativeChatDraftCache( ) } -export function appendNativeChatDraftText(draft: string, text: string): string { - return draft === '' ? text : `${draft.trimEnd()}\n\n${text}` -} +export { appendNativeChatDraftText } from './native-chat-composer-draft-addition' // Why: a composer mid-IME-composition keeps showing what it had, so it is told what was appended. const appendListeners = new Map void>>() -/** Puts text back after whatever is typed, and tells a mounted composer to show it. */ -export function appendNativeChatDraftCache(scopeKey: string, text: string): void { +/** 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 { if (text === '') { - return + return true } const previous = readNativeChatDraftCache(scopeKey) - // Saved now: the copy it came from (an outbox entry, a queued card) goes right after this. - appendToNativeChatComposerDraft(scopeKey, (draft) => ({ - text: appendNativeChatDraftText(draft.text, text), - document: undefined - })) + // Durable now: the copy it came from (an outbox entry, a queued card) goes right after this. + const durable = appendToNativeChatComposerDraft(scopeKey, { text }) appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous)) + return durable } export function subscribeToNativeChatDraftAppend( 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 dbb67fbbb110..0d49b35d17f2 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 @@ -294,7 +293,7 @@ export function readNativeChatAttachmentCache( return readNativeChatComposerDraft(scopeKey).images.map((image) => ({ ...image })) } -/** Adds settled images after the ones the draft holds now. Saved at once: when Stop gives images +/** Adds settled images after the ones the draft holds now, durably 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( @@ -305,21 +304,14 @@ export function appendNativeChatAttachmentCache( if (appended.length === 0) { return } - appendToNativeChatComposerDraft(scopeKey, (draft) => { - const images = [...draft.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.push(image) - } else { - images[placeholder] = image - } - } - return { images } + // Preview URLs can retain the full clipboard Blob, so only the path is kept. + appendToNativeChatComposerDraft(scopeKey, { + images: appended.map(({ id, path, connectionId }) => ({ + id, + path, + ...(connectionId ? { connectionId } : {}) + })), + ...(options?.fromUser ? { fromUser: true } : {}) }) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 5fdfc0a95c7a..d0a68de3bafc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -17,6 +17,10 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' type MutateCall = [string, string, Record] @@ -150,6 +154,25 @@ describe('queued message actions', () => { expect(toast.error).toHaveBeenCalledWith('Already sent — your text is still in the composer.') }) + it('Edit keeps the card while its text is neither journaled nor saved', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const storage = createMemoryNativeChatComposerDraftStorage() + storage.refuseWrites = true + setNativeChatComposerDraftStorageForTests(storage) + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new DOMException('full', 'QuotaExceededError') + }) + try { + const harness = createHarness() + await act(() => harness.result.current.edit('draft-1')) + expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1') + expect(harness.mutate).not.toHaveBeenCalled() + } finally { + setItem.mockRestore() + warn.mockRestore() + } + }) + it('Edit with no composer to hold the text deletes nothing', async () => { const harness = createHarness({ composerScopeKey: undefined }) await act(() => harness.result.current.edit('draft-1')) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 4596fb67180f..c30ce4ad2a0f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -16,6 +16,10 @@ import type { AgentSessionSendResult } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { + isNativeChatComposerDraftUnsaved, + nativeChatComposerDraftWritesSettled +} from './native-chat-composer-draft-store' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, @@ -127,7 +131,13 @@ export function useStructuredAgentSessionQueuedMessages(args: { if (!card || !composerScopeKey) { return } - appendNativeChatDraftCache(composerScopeKey, card.text) + if (!appendNativeChatDraftCache(composerScopeKey, card.text)) { + // Not journaled: the card goes only once the draft is saved, never before. + await nativeChatComposerDraftWritesSettled() + if (isNativeChatComposerDraftUnsaved(composerScopeKey)) { + return + } + } const result = await mutate( 'agentSession.queuedMessageDelete', 'agentSession.queuedMessageDelete', From 02163b3fbc6766b7c1ccc6a09276e71df127ccdf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:33:26 -0700 Subject: [PATCH 31/38] test(native-chat): a kept paste is readable by the composer preview through chat-image access, with no grant --- .../window/native-chat-paste-files.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index 8c998c42fadd..2b925b42de30 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -15,6 +15,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' +import type { Store } from '../persistence' +import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' +import { readLocalFileContent } from '../ipc/filesystem/filesystem-file-content-inspection' import { NATIVE_CHAT_PASTE_TTL_MS, isInsideNativeChatPasteFolder, @@ -109,6 +112,25 @@ describe('native-chat paste folder on disk', () => { await expect(restoreNativeChatPastes('not a list')).resolves.toEqual([]) }) + it('leaves a kept paste readable by the composer preview, with no grant', async () => { + const kept = path.join(folder, 'orca-paste-1.png') + writeFileSync(kept, 'png') + // A store with no projects: nothing but the chat-image access lets the preview read it. + const noProjects: Store = Object.assign(Object.create(null), { + getRepos: () => [], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + }) + + await expect(restoreNativeChatPastes([kept])).resolves.toEqual([ + { path: kept, kept: true, exists: true } + ]) + const readable = await resolveLocalFileRequestPath(kept, { kind: 'chat-image' }, noProjects) + await expect(readLocalFileContent(readable)).resolves.toMatchObject({ mimeType: 'image/png' }) + }) + it('keeps a paste reached through a symlinked alias of the folder, as /var is of /private/var', async () => { const kept = path.join(folder, 'orca-paste-1.png') writeFileSync(kept, 'png') From 8b6c8c54b6e2b9551c5fb70e3cc751b581a4b1b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:39:37 -0700 Subject: [PATCH 32/38] fix(native-chat): a per-scope write confirm, and the paste rule as C2 has it - nativeChatComposerDraftWriteSettled(scopeKey) settles once every write of that scope issued before the call has: true only after IndexedDB completed each transaction (an append made before the load landed included), false when one was refused or failed, and never on a timeout. A change still waiting for its deferred write is issued first. - A queued card's Edit deletes the card only on true, and keeps it on false. - A restored paste is kept when its real path is a file inside Orca's paste folder, as C2 resolved it. The tests keep every containment case and now check that neither an outside file nor the file a stored spelling names becomes readable through the preview's chat-image access, which reads only image files and needs no grant. --- .../window/native-chat-paste-files.test.ts | 45 +++++++++---- src/main/window/native-chat-paste-files.ts | 12 +--- .../native-chat-composer-draft-persistence.ts | 48 +++++++++++-- .../native-chat-composer-draft-store.test.ts | 67 +++++++++++++++++++ .../native-chat-composer-draft-store.ts | 1 + ...red-agent-session-queued-messages.test.tsx | 10 +-- ...tructured-agent-session-queued-messages.ts | 15 ++--- 7 files changed, 155 insertions(+), 43 deletions(-) diff --git a/src/main/window/native-chat-paste-files.test.ts b/src/main/window/native-chat-paste-files.test.ts index 2b925b42de30..05599675c7c3 100644 --- a/src/main/window/native-chat-paste-files.test.ts +++ b/src/main/window/native-chat-paste-files.test.ts @@ -25,6 +25,27 @@ import { sweepExpiredNativeChatPastes } from './native-chat-paste-files' +// A store with no projects: nothing but an access kind decides what a read may reach. +const NO_PROJECTS: Store = Object.assign(Object.create(null), { + getRepos: () => [], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +}) + +/** Whether the composer preview's chat-image access can read `target`. */ +async function chatImageReadable(target: string): Promise { + try { + await readLocalFileContent( + await resolveLocalFileRequestPath(target, { kind: 'chat-image' }, NO_PROJECTS) + ) + return true + } catch { + return false + } +} + describe('isInsideNativeChatPasteFolder', () => { const posixFolder = '/data/native-chat-pastes' const winFolder = 'C:\\Users\\Me\\AppData\\Roaming\\Orca\\native-chat-pastes' @@ -115,19 +136,10 @@ describe('native-chat paste folder on disk', () => { it('leaves a kept paste readable by the composer preview, with no grant', async () => { const kept = path.join(folder, 'orca-paste-1.png') writeFileSync(kept, 'png') - // A store with no projects: nothing but the chat-image access lets the preview read it. - const noProjects: Store = Object.assign(Object.create(null), { - getRepos: () => [], - getProjects: () => [], - getProjectGroups: () => [], - getFolderWorkspaces: () => [], - getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) - }) - await expect(restoreNativeChatPastes([kept])).resolves.toEqual([ { path: kept, kept: true, exists: true } ]) - const readable = await resolveLocalFileRequestPath(kept, { kind: 'chat-image' }, noProjects) + const readable = await resolveLocalFileRequestPath(kept, { kind: 'chat-image' }, NO_PROJECTS) await expect(readLocalFileContent(readable)).resolves.toMatchObject({ mimeType: 'image/png' }) }) @@ -145,6 +157,8 @@ describe('native-chat paste folder on disk', () => { { path: viaAlias, kept: true, exists: true }, { path: realpathSync(kept), kept: true, exists: true } ]) + // The preview reads by the stored spelling, through chat-image access. + expect(await chatImageReadable(viaAlias)).toBe(true) } finally { rmSync(alias, { force: true }) } @@ -171,9 +185,12 @@ describe('native-chat paste folder on disk', () => { await expect(restoreNativeChatPastes([crafted])).resolves.toEqual([ { path: crafted, kept: false, exists: false } ]) + // An outside file never becomes readable: nothing is granted, and chat-image reads only images. + expect(await chatImageReadable(secret)).toBe(false) + expect(await chatImageReadable(crafted)).toBe(false) }) - it('keeps a paste only when its stored spelling names the same file as its real path', async () => { + it('keeps a paste by its real path, and never makes the file its stored spelling names readable', async () => { const secret = path.join(root, 'outside', 'id_rsa') mkdirSync(path.dirname(secret), { recursive: true }) writeFileSync(secret, 'PRIVATE KEY') @@ -187,10 +204,12 @@ describe('native-chat paste folder on disk', () => { const restored = `${folder}/link/../orca-paste-y.png` expect(realpathSync.native(restored)).toBe(realpathSync(path.join(sub, 'orca-paste-y.png'))) - // By its real path the file is a paste inside; by the stored text it is the link out. await expect(restoreNativeChatPastes([restored])).resolves.toEqual([ - { path: restored, kept: false, exists: false } + { path: restored, kept: true, exists: true } ]) + expect(await chatImageReadable(secret)).toBe(false) + expect(await chatImageReadable(realpathSync(secret))).toBe(false) + expect(await chatImageReadable(path.join(folder, 'orca-paste-y.png'))).toBe(false) }) it('neither restores from nor sweeps a paste folder that is itself a link', async () => { diff --git a/src/main/window/native-chat-paste-files.ts b/src/main/window/native-chat-paste-files.ts index 74d3565513ca..a2ef9682426b 100644 --- a/src/main/window/native-chat-paste-files.ts +++ b/src/main/window/native-chat-paste-files.ts @@ -49,8 +49,8 @@ export function isInsideNativeChatPasteFolder( /** * For each restored local paste: kept only when its real path is a file inside the real paste - * folder (symlinks and junctions resolved) and the stored spelling names that same file. Anything - * else comes back as a placeholder. Never throws. + * folder (symlinks and junctions resolved). Anything else comes back as a placeholder. Nothing is + * granted: the preview reads a paste with chat-image access. Never throws. */ export async function restoreNativeChatPastes(paths: unknown): Promise { if (!Array.isArray(paths)) { @@ -100,13 +100,7 @@ async function restoreNativeChatPaste( if (!isInsideNativeChatPasteFolder(folders.real, real) || !(await stat(real)).isFile()) { return refused } - // Why: the preview and the send read by the stored spelling, which a link inside the folder - // could point at another file than the real path checked above. - const sameFile = await realpath(named).then( - (namedReal) => namedReal === real, - () => false - ) - return sameFile ? { path: restored, kept: true, exists: true } : refused + return { path: restored, kept: true, exists: true } } catch { // Missing or unreadable: not kept, and nothing about an outside path is reported. return refused diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 528ac519d706..cd4a11faf776 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -34,7 +34,9 @@ const CHANNEL_NAME = 'orca-native-chat-composer-drafts' let flushTimer: ReturnType | null = null let flushOnHideInstalled = false -const inFlight = new Set>() +const inFlight = new Set>() +// Per scope, the writes not yet settled: each resolves true once committed, false when refused. +const scopeWrites = new Map>>() let channel: BroadcastChannel | null = null /** An image the draft names but can no longer send, so the user can attach it again. */ @@ -98,11 +100,30 @@ function persist(scopeKey: string): void { unconfirmed.set(scopeKey, change) const storage = nativeChatComposerDraftStorage() const written = (draft ? storage.write(scopeKey, draft) : storage.remove([scopeKey])).then( - () => confirm(scopeKey, change), - (error: unknown) => refuse(scopeKey, change, error) + () => { + confirm(scopeKey, change) + return true + }, + (error: unknown) => { + refuse(scopeKey, change, error) + return false + } ) + track(scopeKey, written) +} + +function track(scopeKey: string, written: Promise): void { inFlight.add(written) - void written.finally(() => inFlight.delete(written)) + const pending = scopeWrites.get(scopeKey) ?? new Set() + scopeWrites.set(scopeKey, pending) + pending.add(written) + void written.finally(() => { + inFlight.delete(written) + pending.delete(written) + if (pending.size === 0 && scopeWrites.get(scopeKey) === pending) { + scopeWrites.delete(scopeKey) + } + }) } export function flushNativeChatComposerDrafts(): void { @@ -166,6 +187,7 @@ function appendBeforeLoad(scopeKey: string, append: DraftAppend): void { entry.committed = true pruneNativeChatComposerDraftJournal(scopeKey, appendedAt) channel?.postMessage({ scopeKey }) + return true }, (error: unknown) => { if (!refusedScopes.has(scopeKey)) { @@ -176,10 +198,10 @@ function appendBeforeLoad(scopeKey: string, append: DraftAppend): void { refusedScopes.add(scopeKey) notifyScope(scopeKey) } + return false } ) - inFlight.add(written) - void written.finally(() => inFlight.delete(written)) + track(scopeKey, written) } /** Marks a scope changed in memory: `deferred` coalesces typing into one write, `immediate` @@ -266,6 +288,19 @@ export function installNativeChatComposerDraftBroadcast(): void { } } +/** + * Settles once every write of this scope issued before the call has: true only when IndexedDB + * completed each transaction, false when one was refused or failed. A change still waiting for its + * deferred write is issued first, so it is included. Never settles on a timeout. + */ +export async function nativeChatComposerDraftWriteSettled(scopeKey: string): Promise { + if (dirtyScopes.has(scopeKey)) { + flushNativeChatComposerDrafts() + } + const pending = scopeWrites.get(scopeKey) + return pending ? (await Promise.all(pending)).every(Boolean) : true +} + /** Settles once every write issued so far has been confirmed or refused. */ export async function nativeChatComposerDraftWritesSettled(): Promise { while (inFlight.size > 0) { @@ -287,4 +322,5 @@ export function resetNativeChatComposerDraftPersistenceForTests(): void { document.removeEventListener('visibilitychange', journalWhenHidden) } inFlight.clear() + scopeWrites.clear() } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index bf0ff0043066..40dc09053d32 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -117,6 +117,73 @@ describe('native-chat composer draft store', () => { expect(localStorage.length).toBe(0) }) + it('reports a scope’s writes settled only once storage completes them, and false when refused', async () => { + const pending: (() => void)[] = [] + const slow = { + ...storage, + write: (scopeKey: string, draft: Parameters[1]) => + new Promise((resolve) => { + pending.push(() => { + storage.drafts.set(scopeKey, draft) + resolve() + }) + }) + } + const reloaded = await reload({ using: slow }) + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + // Typing still waiting for its deferred write is included too. + reloaded.drafts.writeNativeChatDraftCache('agent-session:s1', 'returned by Stop, edited') + let settled: boolean | undefined + void reloaded.store + .nativeChatComposerDraftWriteSettled('agent-session:s1') + .then((outcome) => (settled = outcome)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBeUndefined() + expect(pending).toHaveLength(2) + + pending.shift()?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(settled).toBeUndefined() + pending.shift()?.() + await vi.waitFor(() => expect(settled).toBe(true)) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + storage.refuseWrites = true + modules.drafts.appendNativeChatDraftCache('agent-session:s2', 'refused') + await expect( + modules.store.nativeChatComposerDraftWriteSettled('agent-session:s2') + ).resolves.toBe(false) + warn.mockRestore() + }) + + it('reports an append made before the load landed settled only once its write completes', async () => { + storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) + let complete: () => void = () => {} + const slow = { + ...storage, + loadAll: () => new Promise>(() => {}), + update: (scopeKey: string, apply: Parameters[1]) => + new Promise((resolve) => { + complete = () => { + void storage.update(scopeKey, apply).then(resolve) + } + }) + } + const reloaded = await reload({ using: slow, hydrate: false }) + await reloaded.store.waitForNativeChatComposerDrafts(1) + reloaded.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + let settled: boolean | undefined + void reloaded.store + .nativeChatComposerDraftWriteSettled('agent-session:s1') + .then((outcome) => (settled = outcome)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBeUndefined() + + complete() + await vi.waitFor(() => expect(settled).toBe(true)) + expect(storedDraft('agent-session:s1')?.text).toBe('typed earlier\n\nreturned by Stop') + }) + it('keeps text given back through a crash before storage commits it, once', async () => { storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) const crashing = { ...storage, write: () => new Promise(() => {}) } 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 add046336b1c..c0af8dac2b67 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 @@ -47,6 +47,7 @@ import { export { flushNativeChatComposerDrafts, isKeptLocalPaste, + nativeChatComposerDraftWriteSettled, nativeChatComposerDraftWritesSettled, unavailableNativeChatComposerDraftImage } from './native-chat-composer-draft-persistence' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index d0a68de3bafc..2c5257725a58 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -154,21 +154,21 @@ describe('queued message actions', () => { expect(toast.error).toHaveBeenCalledWith('Already sent — your text is still in the composer.') }) - it('Edit keeps the card while its text is neither journaled nor saved', async () => { + it('Edit deletes the card only once storage holds the text, and keeps it when storage refuses', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const storage = createMemoryNativeChatComposerDraftStorage() storage.refuseWrites = true setNativeChatComposerDraftStorageForTests(storage) - const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { - throw new DOMException('full', 'QuotaExceededError') - }) try { const harness = createHarness() await act(() => harness.result.current.edit('draft-1')) expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1') expect(harness.mutate).not.toHaveBeenCalled() + + storage.refuseWrites = false + await act(() => harness.result.current.edit('draft-2')) + expect(harness.mutate).toHaveBeenCalledTimes(1) } finally { - setItem.mockRestore() warn.mockRestore() } }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index c30ce4ad2a0f..bd445b6e2ce9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -16,10 +16,7 @@ import type { AgentSessionSendResult } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' -import { - isNativeChatComposerDraftUnsaved, - nativeChatComposerDraftWritesSettled -} from './native-chat-composer-draft-store' +import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, @@ -131,12 +128,10 @@ export function useStructuredAgentSessionQueuedMessages(args: { if (!card || !composerScopeKey) { return } - if (!appendNativeChatDraftCache(composerScopeKey, card.text)) { - // Not journaled: the card goes only once the draft is saved, never before. - await nativeChatComposerDraftWritesSettled() - if (isNativeChatComposerDraftUnsaved(composerScopeKey)) { - return - } + appendNativeChatDraftCache(composerScopeKey, card.text) + // The card goes only once storage holds the draft; a refused save keeps it. + if (!(await nativeChatComposerDraftWriteSettled(composerScopeKey))) { + return } const result = await mutate( 'agentSession.queuedMessageDelete', From 873711a562870a912d592a24abb17dffe61fdc10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:41:51 -0700 Subject: [PATCH 33/38] test(native-chat): the per-scope confirm settles at the IndexedDB transaction's complete, and false on abort, for an append before the load --- ...tive-chat-composer-draft-indexeddb.test.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts index 6278a69f1075..af2b7cf889d1 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts @@ -1,6 +1,6 @@ // A write counts as saved only once IndexedDB has committed it (the transaction's `complete`), not // when its `put` is queued: a crash in between would otherwise lose a draft reported as saved. -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createIndexedDbNativeChatComposerDraftStorage } from './native-chat-composer-draft-indexeddb' type FakeTransaction = { @@ -109,4 +109,56 @@ describe('the IndexedDB draft storage', () => { await expect(write).rejects.toThrow('QuotaExceededError') }) + + describe('through the draft store', () => { + const loaded: { clearNativeChatComposerDraftsForTests: () => void }[] = [] + afterEach(() => { + for (const store of loaded.splice(0)) { + store.clearNativeChatComposerDraftsForTests() + } + }) + + async function storeOn(factory: IDBFactory) { + vi.resetModules() + const storageModule = await import('./native-chat-composer-draft-storage') + storageModule.setNativeChatComposerDraftStorageForTests( + createIndexedDbNativeChatComposerDraftStorage(factory) + ) + const store = await import('./native-chat-composer-draft-store') + loaded.push(store) + return { store, drafts: await import('./native-chat-draft-cache') } + } + + it('reports a scope’s write settled true only at its transaction’s complete', async () => { + const { factory, transactions } = fakeDatabase() + const { store, drafts } = await storeOn(factory) + // The stand-in has no reads, so the startup load fails and stays pending a retry: this is an + // append before the load landed, which reads and writes the stored draft in one transaction. + void store.hydrateNativeChatComposerDrafts() + drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + const settled = store.nativeChatComposerDraftWriteSettled('agent-session:s1') + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(await settledYet(settled.then(() => {}))).toBe(false) + const write = transactions.at(-1)! + write.oncomplete?.() + await expect(settled).resolves.toBe(true) + }) + + it('reports false when the scope’s transaction aborts', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { factory, transactions } = fakeDatabase() + const { store, drafts } = await storeOn(factory) + void store.hydrateNativeChatComposerDrafts() + drafts.appendNativeChatDraftCache('agent-session:s1', 'returned by Stop') + const settled = store.nativeChatComposerDraftWriteSettled('agent-session:s1') + + await new Promise((resolve) => setTimeout(resolve, 0)) + const write = transactions.at(-1)! + write.error = new Error('QuotaExceededError') + write.onabort?.() + await expect(settled).resolves.toBe(false) + warn.mockRestore() + }) + }) }) From 36f4664a4ac38677e634038acd9193904d49d478 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:51:52 -0700 Subject: [PATCH 34/38] fix(native-chat): a journaled removal always fits, and a load replays only what preceded it - A removal is never capped in the journal (it is tiny, and it is what keeps a sent draft from coming back); only whole drafts count against their 256,000 characters, and additions keep their own 800,000. - A load replays the journal as it stood when the load began reading, so an addition a live window journals later, whose own write is ordered after that read, isn't replayed over its newer typing. - A lifecycle test no longer says the paste is re-granted. --- .../native-chat-composer-draft-journal.ts | 37 +++++++--- ...ive-chat-composer-draft-lifecycle.test.tsx | 14 ++-- .../native-chat-composer-draft-load.ts | 17 +++-- .../native-chat-composer-draft-store.test.ts | 71 ++++++++++++++++++- 4 files changed, 116 insertions(+), 23 deletions(-) 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 8f837292927d..7dde1cb4fcad 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 @@ -18,10 +18,12 @@ import { const JOURNAL_KEY = 'orca:nativeChatComposerDraftJournal:v1' // Why: localStorage also holds the send outbox, and a send is refused when its entry can't be -// saved, so the journal stays small: whole drafts stop at the first cap, and additions, at most -// one message each with their source deleted right after, at the second, which fits the largest. +// saved, so the journal stays small: whole drafts together stop at the first cap, and additions, +// at most one message each with their source deleted right after, at the second, which fits the +// largest. A removal is never capped: it is tiny, and it is what keeps a sent draft from coming +// back. export const MAX_JOURNAL_CHARS = 256_000 -export const MAX_JOURNAL_WITH_ADDITIONS_CHARS = 800_000 +export const MAX_JOURNAL_ADDITIONS_CHARS = 800_000 // Why: a load replays only what earlier runs left, never this run's own entries, which its // memory already holds. @@ -98,16 +100,24 @@ function writeEntries(entries: readonly JournalEntry[]): boolean { } } +export type NativeChatComposerDraftJournalSnapshot = readonly JournalEntry[] + +/** Other runs' entries as a load begins reading storage. Why then: an entry journaled later is a + * live window's, whose own write is ordered after this read and must not be overwritten by it. */ +export function snapshotNativeChatComposerDraftJournal(): NativeChatComposerDraftJournalSnapshot { + return readEntries().filter((entry) => entry.run !== THIS_RUN) +} + /** - * Replays what earlier runs journaled onto the loaded drafts: a whole draft newer than the stored - * one replaces it, then each addition newer than the draft it meets is made again, once. Returns - * the drafts it changed, which must be written; entries already older than storage are dropped. + * Replays a snapshot onto the loaded drafts: a whole draft newer than the stored one replaces it, + * then each addition newer than the draft it meets is made again, once. Returns the drafts it + * changed, which must be written; entries already older than storage are dropped. */ export function replayNativeChatComposerDraftJournal( - drafts: Map + drafts: Map, + entries: NativeChatComposerDraftJournalSnapshot ): Set { const changed = new Set() - const entries = readEntries().filter((entry) => entry.run !== THIS_RUN) const removedAt = new Map() for (const entry of entries) { if (isAddition(entry)) { @@ -153,15 +163,19 @@ export function journalNativeChatComposerDraftChanges( return } const kept = readEntries().filter((entry) => isAddition(entry) || !changes.has(entry.scopeKey)) - let used = JSON.stringify(kept).length + let used = JSON.stringify(kept.filter((entry) => !isAddition(entry) && entry.draft)).length const added: JournalEntry[] = [] const bySize = [...changes] .map(([scopeKey, change]) => ({ scopeKey, ...change, run: THIS_RUN })) .map((entry) => ({ entry, size: JSON.stringify(entry).length + 1 })) .sort((left, right) => left.size - right.size) for (const { entry, size } of bySize) { + if (!entry.draft) { + added.push(entry) + continue + } if (used + size > MAX_JOURNAL_CHARS) { - break + continue } added.push(entry) used += size @@ -179,7 +193,8 @@ export function journalNativeChatComposerDraftAddition( at: number ): boolean { const entries: JournalEntry[] = [...readEntries(), { scopeKey, at, run: THIS_RUN, addition }] - return JSON.stringify(entries).length <= MAX_JOURNAL_WITH_ADDITIONS_CHARS && writeEntries(entries) + const additions = JSON.stringify(entries.filter(isAddition)).length + return additions <= MAX_JOURNAL_ADDITIONS_CHARS && writeEntries(entries) } /** Drops a draft's entries once a change to it at least as new is confirmed, by any window, so a diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 047e1b8e8efb..13e4bde85030 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -559,14 +559,14 @@ describe('native-chat composer draft lifecycle', () => { ]) }) - it('shows a restored paste from Orca’s paste folder once main re-grants it, and marks the rest', async () => { + it('shows a restored paste from Orca’s paste folder once main confirms it is kept, and marks the rest', async () => { const folder = '/Users/me/Library/Application Support/orca/native-chat-pastes' - let regrant: () => void = () => {} - const regranted = new Promise((resolve) => { - regrant = resolve + let answer: () => void = () => {} + const answered = new Promise((resolve) => { + answer = resolve }) const restoreNativeChatPastes = vi.fn(async (paths: string[]) => { - await regranted + await answered return paths.map((path) => ({ path, kept: path.endsWith('orca-paste-1-ab.png'), @@ -597,8 +597,8 @@ describe('native-chat composer draft lifecycle', () => { // Until main answers, the restored paste waits, so its preview is not read before the grant. expect(seen.api?.attachments.imageAttachments[0]?.pending).toBe(true) await act(async () => { - regrant() - await regranted + answer() + await answered }) } finally { vi.unstubAllGlobals() 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 fa6a11bbd7af..4912869e3332 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 @@ -14,7 +14,11 @@ import { flushNativeChatComposerDrafts, installNativeChatComposerDraftBroadcast } from './native-chat-composer-draft-persistence' -import { replayNativeChatComposerDraftJournal } from './native-chat-composer-draft-journal' +import { + replayNativeChatComposerDraftJournal, + snapshotNativeChatComposerDraftJournal, + type NativeChatComposerDraftJournalSnapshot +} from './native-chat-composer-draft-journal' import { nativeChatComposerDraftStorage, parseStoredNativeChatComposerDraft, @@ -48,12 +52,16 @@ function withAppends( type DraftLoadResult = { draft: StoredNativeChatComposerDraft; changed: boolean } -function applyLoaded(loaded: ReadonlyMap, readAtSequence: number): void { +function applyLoaded( + loaded: ReadonlyMap, + readAtSequence: number, + journal: NativeChatComposerDraftJournalSnapshot +): void { const drafts = new Map() for (const [scopeKey, value] of loaded) { drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) } - for (const scopeKey of replayNativeChatComposerDraftJournal(drafts)) { + for (const scopeKey of replayNativeChatComposerDraftJournal(drafts, journal)) { dirtyScopes.add(scopeKey) } for (const [scopeKey, appends] of load.appendsBeforeLoad) { @@ -114,7 +122,8 @@ export function hydrateNativeChatComposerDrafts(): Promise { installNativeChatComposerDraftBroadcast() // Why read here: storage applies changes in order, so this load reads every append made so far. const readAtSequence = load.appendSequence - applyLoaded(await nativeChatComposerDraftStorage().loadAll(), readAtSequence) + const journal = snapshotNativeChatComposerDraftJournal() + applyLoaded(await nativeChatComposerDraftStorage().loadAll(), readAtSequence, journal) })().catch(retryLater) return hydration } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 40dc09053d32..e0da93ddcd10 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -184,6 +184,74 @@ describe('native-chat composer draft store', () => { expect(storedDraft('agent-session:s1')?.text).toBe('typed earlier\n\nreturned by Stop') }) + it('never brings back a draft sent while storage refused, however much was given back to it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + storage.drafts.set('agent-session:s1', { text: 'old saved', images: [], savedAt: 1 }) + const refuse = () => Promise.reject(new DOMException('refused', 'UnknownError')) + const broken = { ...storage, write: refuse, remove: refuse, update: refuse } + const refusing = await reload({ using: broken }) + refusing.drafts.appendNativeChatDraftCache('agent-session:s1', 'a'.repeat(150_000)) + refusing.drafts.appendNativeChatDraftCache('agent-session:s1', 'b'.repeat(150_000)) + // The user sends it: the draft is cleared, and storage refuses that too. + refusing.drafts.writeNativeChatDraftCache('agent-session:s1', '') + await refusing.store.nativeChatComposerDraftWritesSettled() + window.dispatchEvent(new Event('pagehide')) + refusing.store.clearNativeChatComposerDraftsForTests() + + const next = await reload() + expect(next.drafts.readNativeChatDraftCache('agent-session:s1')).toBe('') + warn.mockRestore() + }) + + it('does not replay a live window’s addition journaled after this load began reading', async () => { + storage.drafts.set('agent-session:s1', { text: 'saved', images: [], savedAt: 1 }) + let commitA: () => void = () => {} + let aCommitted: () => void = () => {} + const aDone = new Promise((resolve) => (aCommitted = resolve)) + const aStorage = { + ...storage, + write: (scopeKey: string, draft: Parameters[1]) => + new Promise((resolve) => { + commitA = () => { + storage.drafts.set(scopeKey, draft) + resolve() + aCommitted() + } + }) + } + const a = await reload({ using: aStorage }) + let land: () => void = () => {} + const bStorage = { + ...storage, + loadAll: () => { + const snapshot = new Map(storage.drafts) + return new Promise>((resolve) => { + land = () => resolve(snapshot) + }) + }, + // IndexedDB orders B's write after A's, which was issued first. + write: async (scopeKey: string, draft: Parameters[1]) => { + await aDone + storage.drafts.set(scopeKey, draft) + } + } + const b = await reload({ using: bStorage, hydrate: false }) + const bLoad = b.store.hydrateNativeChatComposerDrafts() + a.drafts.writeNativeChatDraftCache('agent-session:s1', 'saved typed in A') + a.drafts.appendNativeChatDraftCache('agent-session:s1', 'returned') + land() + await bLoad + commitA() + await a.store.nativeChatComposerDraftWritesSettled() + await b.store.nativeChatComposerDraftWritesSettled() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(storedDraft('agent-session:s1')?.text).toBe('saved typed in A\n\nreturned') + expect(a.drafts.readNativeChatDraftCache('agent-session:s1')).toBe( + 'saved typed in A\n\nreturned' + ) + }) + it('keeps text given back through a crash before storage commits it, once', async () => { storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) const crashing = { ...storage, write: () => new Promise(() => {}) } @@ -711,7 +779,8 @@ describe('native-chat composer draft store', () => { window.dispatchEvent(new Event('pagehide')) const journal = localStorage.getItem('orca:nativeChatComposerDraftJournal:v1') ?? '' - expect(journal.length).toBeLessThanOrEqual(800_000) + // Additions keep to their own cap, whole drafts to theirs. + expect(journal.length).toBeLessThanOrEqual(800_000 + 256_000) // What no longer fits is reported, so its source is kept until storage confirms it. expect(durable).toContain(false) warn.mockRestore() From 071c934c41d8ac9dd527dce9b07cb6d350b24a23 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:19:06 -0700 Subject: [PATCH 35/38] fix(native-chat): a chat with a draft is never reused or taken over #24917's empty-chat check read the composer draft under the pane key, while this branch keeps a structured chat's draft under its conversation key, so a chat holding typed or given-back text, or only an image, read as empty: a new chat could reuse it and another request's text could go into it. It reads the conversation's draft now, as C2 adapted it (d4e469b697b), with the tests moved to that key. While the startup load of saved drafts is still running, no chat reads as empty: one may hold a saved draft memory doesn't have yet. The cost is only that a new chat opens instead of reusing one during that moment. --- .../native-chat-composer-draft-load.ts | 9 +++ .../native-chat-composer-draft-store.ts | 1 + ...red-agent-session-empty-chat-reuse.test.ts | 57 ++++++++++++++++--- ...t-session-launch-different-request.test.ts | 11 +--- ...uctured-agent-session-launch-empty-chat.ts | 24 ++++---- 5 files changed, 77 insertions(+), 25 deletions(-) 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 4912869e3332..02de2aa92bf1 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 @@ -31,6 +31,7 @@ import { const RETRY_DELAYS_MS = [1_000, 5_000, 30_000] let hydration: Promise | null = null +let started = false let failedLoads = 0 let retryTimer: ReturnType | null = null @@ -117,6 +118,7 @@ function retryLater(error: unknown): void { /** Loads every saved draft into memory, once; a failed load is retried a few times, then left. */ export function hydrateNativeChatComposerDrafts(): Promise { + started = true hydration ??= (async () => { removeLegacyLocalStorageNativeChatComposerDrafts() installNativeChatComposerDraftBroadcast() @@ -140,7 +142,14 @@ export async function waitForNativeChatComposerDrafts(timeoutMs: number): Promis clearTimeout(timer) } +/** The load of saved drafts has started and not landed (nor been given up): a draft may exist that + * memory doesn't hold yet. */ +export function isNativeChatComposerDraftLoadPending(): boolean { + return started && !load.hydrated +} + export function resetNativeChatComposerDraftLoadForTests(): void { + started = false if (retryTimer !== null) { clearTimeout(retryTimer) retryTimer = null 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 c0af8dac2b67..a5cba734505a 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 @@ -53,6 +53,7 @@ export { } from './native-chat-composer-draft-persistence' export { hydrateNativeChatComposerDrafts, + isNativeChatComposerDraftLoadPending, waitForNativeChatComposerDrafts } from './native-chat-composer-draft-load' diff --git a/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts index 9f4f2b0c4597..004de247a849 100644 --- a/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts +++ b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts @@ -115,10 +115,15 @@ import { clearNativeChatDraftCacheForTests, writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { - structuredAgentSessionPaneKey, - structuredAgentSessionTabId -} from '../../../shared/structured-agent-session-projection' + hydrateNativeChatComposerDrafts, + structuredAgentSessionDraftScopeKey +} from '@/components/native-chat/native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from '@/components/native-chat/native-chat-composer-draft-storage' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch, @@ -278,8 +283,8 @@ describe('a second "new chat" with no text', () => { it('opens a new chat when the idle chat has a typed draft', async () => { pick('plus-pick-1') await publishIdle(first.sessionId) - const tabId = structuredAgentSessionTabId(first.sessionId) - writeNativeChatDraftCache(structuredAgentSessionPaneKey(tabId, first.sessionId), 'half a q') + // Under the conversation's key, which the composer and every hand-back write. + writeNativeChatDraftCache(structuredAgentSessionDraftScopeKey(first.sessionId), 'half a q') expect(pick('plus-pick-2').sessionId).toBe(second.sessionId) }) @@ -287,8 +292,7 @@ describe('a second "new chat" with no text', () => { it('opens a new chat when the idle chat has an image in its composer', async () => { pick('plus-pick-1') await publishIdle(first.sessionId) - const tabId = structuredAgentSessionTabId(first.sessionId) - appendNativeChatAttachmentCache(structuredAgentSessionPaneKey(tabId, first.sessionId), [ + appendNativeChatAttachmentCache(structuredAgentSessionDraftScopeKey(first.sessionId), [ { id: 'shot', path: '/tmp/shot.png' } ]) @@ -483,6 +487,45 @@ describe('a "new chat" with text', () => { ) }) + it('leaves a starting chat whose draft holds text, given back or typed, to its user', () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + const blank = pick('plus-pick-1') + writeNativeChatDraftCache(structuredAgentSessionDraftScopeKey(blank.sessionId), 'my words') + + expect(pick('notes-send', { prompt: 'review notes' }).sessionId).toBe(second.sessionId) + expect(readOutbox(blank.sessionId)).toEqual([]) + }) + + it('never takes over or reuses a chat whose saved draft holds text or only an image', async () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + const typed = pick('plus-pick-1') + writeNativeChatDraftCache(structuredAgentSessionDraftScopeKey(typed.sessionId), 'my words') + // The saved drafts have loaded, so only the draft itself keeps this chat from looking empty. + await hydrateNativeChatComposerDrafts() + const taken = pick('notes-send', { prompt: 'review notes' }) + expect(taken.sessionId).not.toBe(typed.sessionId) + expect(readOutbox(typed.sessionId)).toEqual([]) + + appendNativeChatAttachmentCache(structuredAgentSessionDraftScopeKey(taken.sessionId), [ + { id: 'shot', path: '/tmp/shot.png' } + ]) + await hydrateNativeChatComposerDrafts() + expect(pick('notes-send-2', { prompt: 'more notes' }).sessionId).not.toBe(taken.sessionId) + }) + + it('takes no chat while saved drafts are still loading, since one may hold a draft', () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + const blank = pick('plus-pick-1') + const loading = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests({ + ...loading, + loadAll: () => new Promise>(() => undefined) + }) + void hydrateNativeChatComposerDrafts() + + expect(pick('notes-send', { prompt: 'review notes' }).sessionId).not.toBe(blank.sessionId) + }) + it('takes an empty chat still starting, as before', () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) const blank = pick('plus-pick-1') diff --git a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts index 253a91c85bfc..338e5ec8ad69 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts @@ -67,10 +67,7 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' -import { - structuredAgentSessionPaneKey, - structuredAgentSessionTabId -} from '../../../shared/structured-agent-session-projection' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { getStructuredAgentSessionLaunchLifecycle, @@ -520,10 +517,8 @@ describe('an empty chat still starting', () => { it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => { const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'plus-pick' }) - const paneKey = structuredAgentSessionPaneKey( - structuredAgentSessionTabId(blank.sessionId), - blank.sessionId - ) + // The conversation's draft, which its composer writes. + const paneKey = structuredAgentSessionDraftScopeKey(blank.sessionId) writeNativeChatDraftCache(paneKey, 'half a question') const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesSend) diff --git a/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts index 57efb3fedc80..fb2fd2f9e414 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts @@ -1,18 +1,22 @@ -import { - structuredAgentSessionPaneKey, - structuredAgentSessionTabId -} from '../../../shared/structured-agent-session-projection' import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' -import { readNativeChatAttachmentCache } from '@/components/native-chat/use-native-chat-composer-attachments' +// The draft store, not the composer hook: the launch must not load the composer (a store cycle). +import { + isNativeChatComposerDraftLoadPending, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey +} from '@/components/native-chat/native-chat-composer-draft-store' -/** A starting chat is empty until its user sends into it or puts text or images in its composer; - * after that it is theirs, and another request never goes into it. */ +/** A starting chat is empty until its user sends into it or puts text or images in its draft (typed, + * or given back); after that it is theirs, and another request never goes into it. Read by the + * conversation's key, which the composer and every hand-back write. While the startup load of saved + * drafts has not landed, no chat is empty: it may hold a draft memory doesn't have yet. */ export function isStructuredLaunchChatEmpty(sessionId: string): boolean { - const paneKey = structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) + const scopeKey = structuredAgentSessionDraftScopeKey(sessionId) return ( + !isNativeChatComposerDraftLoadPending() && getStructuredAgentSessionOutbox(sessionId).length === 0 && - readNativeChatDraftCache(paneKey).trim() === '' && - readNativeChatAttachmentCache(paneKey).length === 0 + readNativeChatDraftCache(scopeKey).trim() === '' && + readNativeChatComposerDraft(scopeKey).images.length === 0 ) } From 35904851d78cffa83a082b958f3062f5cf8b2056 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:03:11 -0700 Subject: [PATCH 36/38] fix(native-chat): a returned text counts as already there only as its own paragraph Replaying an addition after a crash skipped it whenever the draft merely ended with its text, so "go" given back onto "please go" was lost. It now counts only when the draft equals the text or ends with it after a blank line. A whitespace-only draft counts as empty when text is added, and an addition made before the startup load lands is made again on the loaded draft only where that draft doesn't already hold it. --- ...ative-chat-composer-draft-addition.test.ts | 108 ++++++++++++++++++ .../native-chat-composer-draft-addition.ts | 16 ++- .../native-chat-composer-draft-store.ts | 12 +- 3 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts new file mode 100644 index 000000000000..fae13e7bb595 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DraftStore from './native-chat-composer-draft-store' +import type * as DraftCache from './native-chat-draft-cache' +import { + appendNativeChatDraftText, + withNativeChatComposerDraftAddition +} from './native-chat-composer-draft-addition' +import { + createMemoryNativeChatComposerDraftStorage, + type NativeChatComposerDraftStorage +} from './native-chat-composer-draft-storage' + +type DraftModules = { drafts: typeof DraftCache; store: typeof DraftStore } + +let storage: ReturnType +const loaded: DraftModules[] = [] + +/** A fresh renderer: module memory is gone, the drafts' storage is not. */ +async function reload( + options: { using?: NativeChatComposerDraftStorage; hydrate?: boolean } = {} +): Promise { + vi.resetModules() + const storageModule = await import('./native-chat-composer-draft-storage') + storageModule.setNativeChatComposerDraftStorageForTests(options.using ?? storage) + const modules = { + drafts: await import('./native-chat-draft-cache'), + store: await import('./native-chat-composer-draft-store') + } + loaded.push(modules) + if (options.hydrate !== false) { + await modules.store.hydrateNativeChatComposerDrafts() + } + return modules +} + +/** Storage whose load waits for `land()`, with what it held when the load began reading. */ +function slowLoading(): { using: NativeChatComposerDraftStorage; land: () => void } { + let land: () => void = () => {} + const using = { + ...storage, + loadAll: () => { + const snapshot = new Map(storage.drafts) + return new Promise>((resolve) => { + land = () => resolve(snapshot) + }) + } + } + return { using, land: () => land() } +} + +const SCOPE = 'agent-session:s1' + +beforeEach(() => { + localStorage.clear() + storage = createMemoryNativeChatComposerDraftStorage() +}) + +afterEach(() => { + for (const instance of loaded.splice(0)) { + instance.store.clearNativeChatComposerDraftsForTests() + } +}) + +describe('native-chat composer draft addition', () => { + it('adds text to a whitespace-only draft without leading blank lines', () => { + expect(appendNativeChatDraftText(' \n\n', 'go')).toBe('go') + expect(appendNativeChatDraftText('please\n', 'go')).toBe('please\n\ngo') + }) + + it('makes text again only where the draft does not already hold it as its own paragraph', () => { + const add = (text: string): string => + withNativeChatComposerDraftAddition({ text, images: [] }, { text: 'go' }, { once: true }).text + expect(add('please go')).toBe('please go\n\ngo') + expect(add('please\n\ngo')).toBe('please\n\ngo') + expect(add('go\n')).toBe('go\n') + }) + + it('gives "go" back onto "please go" after a crash before storage commits it', async () => { + storage.drafts.set(SCOPE, { text: 'please go', images: [], 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() + expect(next.drafts.readNativeChatDraftCache(SCOPE)).toBe('please go\n\ngo') + }) + + it('makes text given back before the load lands again only where the loaded draft lacks it', async () => { + // An earlier run gave "go" back, then crashed before its copy was deleted. + storage.drafts.set(SCOPE, { text: 'typed earlier\n\ngo', images: [], savedAt: 1 }) + storage.drafts.set('agent-session:s2', { text: 'please go', images: [], savedAt: 1 }) + const { using, land } = slowLoading() + const next = await reload({ using, hydrate: false }) + await next.store.waitForNativeChatComposerDrafts(1) + next.drafts.appendNativeChatDraftCache(SCOPE, 'go') + next.drafts.appendNativeChatDraftCache('agent-session:s2', 'go') + land() + await next.store.waitForNativeChatComposerDrafts(1_000) + await next.store.nativeChatComposerDraftWritesSettled() + + expect(next.drafts.readNativeChatDraftCache(SCOPE)).toBe('typed earlier\n\ngo') + expect(storage.drafts.get(SCOPE)?.text).toBe('typed earlier\n\ngo') + 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') + }) +}) 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 de424cb261bd..4d51ce4961cf 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 @@ -15,14 +15,22 @@ export type NativeChatComposerDraftAddition = { readonly fromUser?: boolean } +/** A whitespace-only draft counts as empty, so the text never lands after blank lines. */ export function appendNativeChatDraftText(draft: string, text: string): string { - return draft === '' ? text : `${draft.trimEnd()}\n\n${text}` + return draft.trim() === '' ? text : `${draft.trimEnd()}\n\n${text}` +} + +/** Whether the draft already ends with `text` as its own paragraph. */ +export function nativeChatDraftEndsWith(draft: string, text: string): boolean { + const held = draft.trimEnd() + return held === text || held.endsWith(`\n\n${text}`) } /** * The draft's text and images with the addition made. `once` makes it again only where it is not - * already there (text the draft already ends with, an image id it holds): a replay after a crash - * meets a draft that may already have been saved with it. + * already there (text the draft already ends with as its own paragraph, an image id it holds): a + * replay after a crash meets a draft that may already have been saved with it. Only a whole + * paragraph counts, so "go" given back onto "please go" still comes back. */ export function withNativeChatComposerDraftAddition( draft: NativeChatComposerDraft, @@ -31,7 +39,7 @@ export function withNativeChatComposerDraftAddition( ): Pick { const added = addition.text ?? '' const text = - added === '' || (options.once && draft.text.endsWith(added)) + added === '' || (options.once && nativeChatDraftEndsWith(draft.text, added)) ? draft.text : appendNativeChatDraftText(draft.text, added) const images = [...draft.images] 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 a5cba734505a..0df74230e2aa 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 @@ -176,17 +176,19 @@ export function updateNativeChatComposerDraft( function withAddition( draft: T, - addition: NativeChatComposerDraftAddition + addition: NativeChatComposerDraftAddition, + options: { once?: boolean } = {} ): T { - const next = withNativeChatComposerDraftAddition(draft, addition) + const next = withNativeChatComposerDraftAddition(draft, addition, options) return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) } } /** * Adds to the scope's draft as it is now: text or images given back, or attached. Before the * startup load lands, the same addition is made again to the loaded draft, so nothing saved - * earlier is replaced by it. True once the addition is durable: storage commits later, so it is - * also journaled at once, and the copy it came from may then be deleted. + * earlier is replaced by it, unless that draft already holds it (a hand-back repeated after a + * crash before its copy was deleted). True once the addition is durable: storage commits later, + * so it is also journaled at once, and the copy it came from may then be deleted. */ export function appendToNativeChatComposerDraft( scopeKey: string, @@ -199,7 +201,7 @@ export function appendToNativeChatComposerDraft( scopeKey, { text, images, ...(text === current.text ? {} : { document: undefined }) }, 'immediate', - (loaded) => withAddition(loaded, addition) + (loaded) => withAddition(loaded, addition, { once: true }) ) const after = records.get(scopeKey) if (!after || after === before) { From b6b576b5731e15d2f76b0afbfdc4b214efc71127 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:10:03 -0700 Subject: [PATCH 37/38] fix(native-chat): a hand-back the draft already holds stays safe until that draft is saved After merging main's shared returned-text rule, a hand-back the draft already ends with changes nothing, so it wrote no backup entry yet reported durable. If the draft holding that text was not saved yet, a crash after the source copy was deleted lost it. Journal the addition whenever the draft is not confirmed saved. --- .../native-chat-composer-draft-addition.test.ts | 11 +++++++++++ .../native-chat/native-chat-composer-draft-store.ts | 6 +++++- .../components/native-chat/native-chat-draft-cache.ts | 1 - 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts index c2b238bf3174..25782727d406 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts @@ -86,6 +86,17 @@ describe('native-chat composer draft addition', () => { expect(next.drafts.readNativeChatDraftCache(SCOPE)).toBe('please go\n\ngo') }) + it('keeps a hand-back the draft already ends with when that draft was never saved', async () => { + const crashing = { ...storage, write: () => new Promise(() => {}) } + const crashed = await reload({ using: crashing }) + crashed.drafts.writeNativeChatDraftCache(SCOPE, 'hello') + expect(crashed.drafts.appendNativeChatDraftCache(SCOPE, 'hello')).toBe(true) + crashed.store.clearNativeChatComposerDraftsForTests() + + const next = await reload() + expect(next.drafts.readNativeChatDraftCache(SCOPE)).toBe('hello') + }) + it('makes text given back before the load lands again only where the loaded draft lacks it', async () => { // An earlier run gave "go" back, then crashed before its copy was deleted. storage.drafts.set(SCOPE, { text: 'typed earlier\n\ngo', images: [], savedAt: 1 }) 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 0df74230e2aa..b8ca9d888b00 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 @@ -204,7 +204,11 @@ export function appendToNativeChatComposerDraft( (loaded) => withAddition(loaded, addition, { once: true }) ) const after = records.get(scopeKey) - if (!after || after === before) { + if (!after) { + return true + } + // Why: a hand-back the draft already holds changes nothing, but that draft may not be saved yet. + if (after === before && !hasLocalChange(scopeKey) && !refusedScopes.has(scopeKey)) { return true } return journalNativeChatComposerDraftAddition(scopeKey, addition, after.savedAt) 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 967e39bcc66a..a50f1c600c1a 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 @@ -33,7 +33,6 @@ export function writeNativeChatDraftCache( ) } - // Why: a composer mid-IME-composition keeps showing what it had, so it is told what was appended. const appendListeners = new Map void>>() From 3b2ed2b516712ee10395bebc7061050a380c5f85 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:02:22 -0700 Subject: [PATCH 38/38] fix(lint): import worktree removal names once in the delete failure toast Main's #25668 imported from shared/worktree/removal twice in this file, which fails the type-aware lint gate (no-duplicate-imports) for every branch that merges current main. --- .../src/components/sidebar/delete-worktree-failure-toast.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx index 3ad1a939aa9b..5f6e93444fa7 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx @@ -4,10 +4,10 @@ import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' import { translate } from '@/i18n/i18n' import { DeleteNestedWorktreesDialog } from './DeleteNestedWorktreesDialog' import { isNestedWorktreeRemovalError } from '../../../../shared/worktree/nested-removal' -import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import { isLockedWorktreeRemovalError, - type WorktreeForceDeleteReason + type WorktreeForceDeleteReason, + type WorktreeRemovalTarget } from '../../../../shared/worktree/removal' type DeleteWorktreeFailureToastOptions = {