{
+ const root = await mkdtemp(join(tmpdir(), 'orca-sidecar-snapshot-'))
+ roots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ vi.clearAllMocks()
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
describe('sidecar snapshot queues', () => {
it('releases completed per-file queue entries', async () => {
await Promise.all(
@@ -16,3 +49,36 @@ describe('sidecar snapshot queues', () => {
expect(_getSidecarSnapshotPendingFileCountForTests()).toBe(0)
})
})
+
+describe('sidecar snapshot writes', () => {
+ it('flushes to disk by default, as every existing caller expects', async () => {
+ const file = join(await freshRoot(), 'snapshot.json')
+
+ await writeSidecarSnapshot(file, { a: 1 })
+
+ expect(durable.writeFileDurable).toHaveBeenCalledOnce()
+ expect(durable.writeFileProcessDurable).not.toHaveBeenCalled()
+ expect(await readSidecarSnapshot(file)).toEqual({ a: 1 })
+ })
+
+ it("skips the disk flush only when asked for 'process' durability", async () => {
+ const file = join(await freshRoot(), 'snapshot.json')
+
+ await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' })
+
+ expect(durable.writeFileProcessDurable).toHaveBeenCalledOnce()
+ expect(durable.writeFileDurable).not.toHaveBeenCalled()
+ expect(await readSidecarSnapshot(file)).toEqual({ a: 1 })
+ })
+
+ it('removes a snapshot, and treats a missing one as removed', async () => {
+ const root = await freshRoot()
+ const file = join(root, 'snapshot.json')
+ await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' })
+
+ await withSidecarSnapshotQueue(file, () => removeSidecarSnapshot(file))
+ await expect(removeSidecarSnapshot(file)).resolves.toBeUndefined()
+
+ expect(await readdir(root)).toEqual([])
+ })
+})
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 7be6f7d4f59b..477f9b2cdbe9 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
@@ -1,7 +1,8 @@
-// Saved copy of the composer drafts, so a half-typed message survives quitting Orca. The desktop
-// app keeps it in the main process, which confirms a write once it is on disk; the web client
-// keeps it in browser storage. Typing is saved after a short pause and flushed when the page
-// hides; a clear or a put-back is saved at once, and a send waits for its clear (up to a bound).
+// Saved copy of the composer drafts, so a half-typed message survives quitting Orca. Typing is
+// saved after a short pause and flushed when the page hides; a clear or a put-back is saved at
+// once. On desktop the main process confirms each write once it is on disk, and a send waits (up
+// to a bound) for its clear, so a crash after Enter cannot bring sent text back. The web client
+// keeps browser storage, which reaches disk on the browser's own delay, so a browser crash can.
import type {
NativeChatDraftStoreResult,
From b770b6ef8b40de40ff31225e68377055cb6de588 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 1 Oct 2026 17:41:27 -0700
Subject: [PATCH 39/52] fix(native-chat): say why Send is held while a restored
image is missing
---
.../components/native-chat/NativeChatComposerField.tsx | 9 +++++++++
...ative-chat-structured-send-composition-clear.test.tsx | 3 +++
2 files changed, 12 insertions(+)
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
index bf86d41bfb03..861a1e9f4810 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
@@ -227,6 +227,15 @@ export function NativeChatComposerField({
))}
) : null}
+ {/* Why visible: Send stays disabled while a chip is missing, and a tooltip alone hides the reason. */}
+ {imageAttachments.some((attachment) => attachment.missing) ? (
+
+ {translate(
+ 'components.native-chat.composer.imageMissing',
+ 'Image no longer available. Remove it to send.'
+ )}
+
+ ) : null}
{
expect(
screen.getByRole('button', { name: /Image no longer available\. Remove it to send\./ })
).toBeTruthy()
+ // The reason Send is held is shown without hovering the chip.
+ expect(screen.getByText('Image no longer available. Remove it to send.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
+ expect(screen.queryByText('Image no longer available. Remove it to send.')).toBeNull()
await act(async () => pressEnter(textarea()))
expect(structured.send).toHaveBeenCalledWith('look at this', [])
})
From 68b51723cf32ff312acbe8705ec8bcf6f7d38aa3 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:09:10 -0700
Subject: [PATCH 40/52] fix(native-chat): a failed draft clear is retried, and
load deletes only broken files
- The clear's delete now retries a Windows sharing violation, as the rename already did (#1507).
- A write whose file op failed is kept per chat: the chat's next write supersedes it, and the
will-quit drain retries it once, then drops it with a log line. The main process's own copy
follows the newest write, so a reloaded window never gets back text a failed clear left behind.
- Load keeps a newer build's draft files and files it could not read; it deletes only files that
are not JSON or are broken version-1 drafts. Its deletions run in the chat's write queue and
skip chats written this run.
- A renamed temp file is not removed again, and the drafts folder and each file's stale-temp sweep
happen once per run instead of on every write.
---
.../fs-utils-remove-retry.test.ts | 60 +++++
src/main/codex-accounts/fs-utils.ts | 17 +-
src/main/durable-file-write-process.test.ts | 53 +++++
src/main/durable-file-write.ts | 7 +-
...tive-chat-draft-store.failed-write.test.ts | 135 +++++++++++
.../native-chat-draft-store.test.ts | 63 ++++-
.../native-chat/native-chat-draft-store.ts | 215 ++++++++++++++----
src/main/sidecar-snapshot-file.test.ts | 43 +++-
src/main/sidecar-snapshot-file.ts | 30 ++-
9 files changed, 556 insertions(+), 67 deletions(-)
create mode 100644 src/main/codex-accounts/fs-utils-remove-retry.test.ts
create mode 100644 src/main/durable-file-write-process.test.ts
create mode 100644 src/main/native-chat/native-chat-draft-store.failed-write.test.ts
diff --git a/src/main/codex-accounts/fs-utils-remove-retry.test.ts b/src/main/codex-accounts/fs-utils-remove-retry.test.ts
new file mode 100644
index 000000000000..aee362f9d10f
--- /dev/null
+++ b/src/main/codex-accounts/fs-utils-remove-retry.test.ts
@@ -0,0 +1,60 @@
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import type * as NodeFsPromises from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const fsPromises = vi.hoisted(() => ({ rm: vi.fn() }))
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ fsPromises.rm.mockImplementation(actual.rm)
+ return { ...actual, rm: fsPromises.rm }
+})
+
+import { removeFileWithWindowsRetryAsync } from './fs-utils'
+
+const originalPlatform = process.platform
+const dirs: string[] = []
+
+function fileInTempDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-fs-remove-retry-'))
+ dirs.push(dir)
+ const file = join(dir, 'draft.json')
+ writeFileSync(file, '{}')
+ return file
+}
+
+afterEach(() => {
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
+ fsPromises.rm.mockClear()
+ dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))
+})
+
+describe('removeFileWithWindowsRetryAsync', () => {
+ // An antivirus scan holding the file open must not fail the delete (#1507).
+ it('retries a Windows sharing violation', async () => {
+ Object.defineProperty(process, 'platform', { value: 'win32' })
+ const file = fileInTempDir()
+ fsPromises.rm.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' }))
+
+ await removeFileWithWindowsRetryAsync(file)
+
+ expect(fsPromises.rm).toHaveBeenCalledTimes(2)
+ expect(existsSync(file)).toBe(false)
+ })
+
+ it('treats a missing file as deleted', async () => {
+ const file = fileInTempDir()
+ await removeFileWithWindowsRetryAsync(file)
+ await expect(removeFileWithWindowsRetryAsync(file)).resolves.toBeUndefined()
+ })
+
+ it('does not retry off Windows', async () => {
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
+ fsPromises.rm.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' }))
+
+ await expect(removeFileWithWindowsRetryAsync(fileInTempDir())).rejects.toThrow('busy')
+ expect(fsPromises.rm).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts
index 295dfac9b66b..3d599a0671d6 100644
--- a/src/main/codex-accounts/fs-utils.ts
+++ b/src/main/codex-accounts/fs-utils.ts
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs'
-import { rename } from 'node:fs/promises'
+import { rename, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { setTimeout } from 'node:timers/promises'
import { grantDirAcl, isPermissionError } from '../win32-utils'
@@ -224,6 +224,21 @@ export async function renameFileWithWindowsRetryAsync(
}
}
+/** Deletes `target`, treating a missing file as deleted; retries like the rename above (#1507). */
+export async function removeFileWithWindowsRetryAsync(target: string): Promise {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ await rm(target, { force: true })
+ return
+ } catch (error) {
+ if (!shouldRetryFileOperation(error, attempt)) {
+ throw error
+ }
+ await setTimeout(attempt * 50)
+ }
+ }
+}
+
export function copyFileWithWindowsRetry(source: string, target: string): void {
runFileOperationWithWindowsRetry(() => copyFileSync(source, target))
}
diff --git a/src/main/durable-file-write-process.test.ts b/src/main/durable-file-write-process.test.ts
new file mode 100644
index 000000000000..ca8bd5ea8408
--- /dev/null
+++ b/src/main/durable-file-write-process.test.ts
@@ -0,0 +1,53 @@
+import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
+import type * as NodeFsPromises from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const fsPromises = vi.hoisted(() => ({ rm: vi.fn(), rename: vi.fn() }))
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ fsPromises.rm.mockImplementation(actual.rm)
+ fsPromises.rename.mockImplementation(actual.rename)
+ return { ...actual, rm: fsPromises.rm, rename: fsPromises.rename }
+})
+
+import { durableWriteTempPath, writeFileProcessDurable } from './durable-file-write'
+
+const dirs: string[] = []
+
+function tempDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-process-durable-'))
+ dirs.push(dir)
+ return dir
+}
+
+afterEach(() => {
+ fsPromises.rm.mockClear()
+ fsPromises.rename.mockClear()
+ dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))
+})
+
+describe('writeFileProcessDurable', () => {
+ // The temp file is gone once renamed; removing it again is a wasted file-system call.
+ it('renames the temp file into place without removing it afterwards', async () => {
+ const file = join(tempDir(), 'draft.json')
+
+ await writeFileProcessDurable(durableWriteTempPath(file), file, '{"a":1}')
+
+ expect(readFileSync(file, 'utf8')).toBe('{"a":1}')
+ expect(fsPromises.rm).not.toHaveBeenCalled()
+ })
+
+ it('removes the temp file when the rename fails', async () => {
+ const dir = tempDir()
+ const file = join(dir, 'draft.json')
+ fsPromises.rename.mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' }))
+
+ await expect(
+ writeFileProcessDurable(durableWriteTempPath(file), file, '{"a":1}')
+ ).rejects.toThrow('denied')
+ expect(readdirSync(dir)).toEqual([])
+ })
+})
diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts
index 14268a4a43b7..96c04c43f7d7 100644
--- a/src/main/durable-file-write.ts
+++ b/src/main/durable-file-write.ts
@@ -174,11 +174,14 @@ export async function writeFileProcessDurable(
finalPath: string,
payload: string
): Promise {
+ let renamed = false
try {
await writeFile(tmpPath, payload, { mode: 0o600 })
- await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
+ renamed = await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
} finally {
- await rm(tmpPath, { force: true }).catch(() => {})
+ if (!renamed) {
+ await rm(tmpPath, { force: true }).catch(() => {})
+ }
}
}
diff --git a/src/main/native-chat/native-chat-draft-store.failed-write.test.ts b/src/main/native-chat/native-chat-draft-store.failed-write.test.ts
new file mode 100644
index 000000000000..30e2efaa4c54
--- /dev/null
+++ b/src/main/native-chat/native-chat-draft-store.failed-write.test.ts
@@ -0,0 +1,135 @@
+import { mkdtemp, readdir, rm } from 'node:fs/promises'
+import type * as NodeFsPromises from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as SidecarSnapshotFile from '../sidecar-snapshot-file'
+
+const mocks = vi.hoisted(() => ({
+ removeSidecarSnapshot: vi.fn(),
+ writeSidecarSnapshot: vi.fn(),
+ mkdir: vi.fn()
+}))
+
+vi.mock('../sidecar-snapshot-file', async (importOriginal) => {
+ const actual = await importOriginal()
+ mocks.removeSidecarSnapshot.mockImplementation(actual.removeSidecarSnapshot)
+ mocks.writeSidecarSnapshot.mockImplementation(actual.writeSidecarSnapshot)
+ return {
+ ...actual,
+ removeSidecarSnapshot: mocks.removeSidecarSnapshot,
+ writeSidecarSnapshot: mocks.writeSidecarSnapshot
+ }
+})
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ mocks.mkdir.mockImplementation(actual.mkdir)
+ return { ...actual, mkdir: mocks.mkdir }
+})
+
+import { createNativeChatDraftStore } from './native-chat-draft-store'
+
+const roots: string[] = []
+const draft = (text: string) => ({ text, attachments: [] })
+const busy = () => Object.assign(new Error('busy'), { code: 'EBUSY' })
+
+async function freshRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-drafts-failed-'))
+ roots.push(root)
+ return join(root, 'native-chat-drafts')
+}
+
+async function sentDraftOnDisk() {
+ const root = await freshRoot()
+ const store = createNativeChatDraftStore(root)
+ await store.load()
+ await store.write('session:s1', draft('sent text'))
+ return { root, store }
+}
+
+beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+})
+
+afterEach(async () => {
+ vi.clearAllMocks()
+ vi.restoreAllMocks()
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+// A clear whose delete failed must not leave the sent text to come back, in this run or the next.
+describe('a draft write that failed', () => {
+ it('is not served to a reloaded window, and is retried when Orca quits', async () => {
+ const { root, store } = await sentDraftOnDisk()
+ mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy())
+
+ expect(await store.write('session:s1', null)).toBe('failed')
+ expect(await store.load()).toEqual([])
+
+ await store.drain()
+ expect(await readdir(root)).toEqual([])
+ expect(await createNativeChatDraftStore(root).load()).toEqual([])
+ })
+
+ it("is superseded by the chat's next write", async () => {
+ const { root, store } = await sentDraftOnDisk()
+ mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy())
+ await store.write('session:s1', null)
+
+ expect(await store.write('session:s1', draft('typed again'))).toBe('persisted')
+ await store.drain()
+
+ expect(mocks.removeSidecarSnapshot).toHaveBeenCalledOnce()
+ expect(await createNativeChatDraftStore(root).load()).toEqual([
+ { scopeKey: 'session:s1', draft: draft('typed again') }
+ ])
+ })
+
+ it('is dropped, and logged, when the retry at quit fails too', async () => {
+ const { store } = await sentDraftOnDisk()
+ mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy()).mockRejectedValueOnce(busy())
+ await store.write('session:s1', null)
+
+ await store.drain()
+ await store.drain()
+
+ expect(mocks.removeSidecarSnapshot).toHaveBeenCalledTimes(2)
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining('dropped a draft write'),
+ 'EBUSY'
+ )
+ })
+
+ it('never outlives a newer write asked for before it failed', async () => {
+ const { root, store } = await sentDraftOnDisk()
+ mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy())
+ const clear = store.write('session:s1', null)
+ const next = store.write('session:s1', draft('newer'))
+
+ expect(await clear).toBe('failed')
+ expect(await next).toBe('persisted')
+ await store.drain()
+
+ expect(mocks.removeSidecarSnapshot).toHaveBeenCalledOnce()
+ expect(await createNativeChatDraftStore(root).load()).toEqual([
+ { scopeKey: 'session:s1', draft: draft('newer') }
+ ])
+ })
+})
+
+describe('the drafts folder', () => {
+ it('is created once, and again after a write failed', async () => {
+ const root = await freshRoot()
+ const store = createNativeChatDraftStore(root)
+ await store.write('session:s1', draft('a'))
+ await store.write('session:s1', draft('ab'))
+ await store.write('session:s2', draft('b'))
+ expect(mocks.mkdir).toHaveBeenCalledOnce()
+
+ mocks.writeSidecarSnapshot.mockRejectedValueOnce(busy())
+ expect(await store.write('session:s1', draft('abc'))).toBe('failed')
+ await store.write('session:s1', draft('abcd'))
+ expect(mocks.mkdir).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/main/native-chat/native-chat-draft-store.test.ts b/src/main/native-chat/native-chat-draft-store.test.ts
index 92a29b342883..4e4a62261bb3 100644
--- a/src/main/native-chat/native-chat-draft-store.test.ts
+++ b/src/main/native-chat/native-chat-draft-store.test.ts
@@ -1,9 +1,11 @@
-import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
+import { createHash } from 'node:crypto'
+import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { build } from 'esbuild'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
+import { withSidecarSnapshotQueue } from '../sidecar-snapshot-file'
import { createNativeChatDraftStore } from './native-chat-draft-store'
const roots: string[] = []
@@ -17,6 +19,7 @@ async function freshRoot(): Promise {
}
const draft = (text: string) => ({ text, attachments: [] })
+const fileOf = (scopeKey: string) => `${createHash('sha256').update(scopeKey).digest('hex')}.json`
// A renderer that sends, then the app is killed: the clear it was told had landed must hold.
const CHILD_SOURCE = `
@@ -166,6 +169,64 @@ describe('the native chat draft store', () => {
expect(await readdir(root)).toEqual([saved])
})
+ // A downgrade, or a file that can't be read right now, must not cost the user a draft.
+ it("keeps a newer build's drafts and files it could not read", async () => {
+ const root = await freshRoot()
+ const store = createNativeChatDraftStore(root)
+ await store.write('session:s1', draft('mine'))
+ const newer = { v: 2, scopeKey: 'session:s2', savedAt: 1, text: 'from a newer build' }
+ await writeFile(join(root, fileOf('session:s2')), JSON.stringify(newer))
+ // A directory where a file should be: reading it fails with something other than ENOENT.
+ await mkdir(join(root, fileOf('session:s3')))
+ await writeFile(join(root, fileOf('session:s4')), JSON.stringify({ v: 1, scopeKey: 7 }))
+
+ expect(await createNativeChatDraftStore(root).load()).toEqual([
+ { scopeKey: 'session:s1', draft: draft('mine') }
+ ])
+ expect((await readdir(root)).sort()).toEqual(
+ [fileOf('session:s1'), fileOf('session:s2'), fileOf('session:s3')].sort()
+ )
+ })
+
+ // Deletions at load share the chat's queue, so a write made meanwhile always survives them.
+ it('never deletes, at load, a draft written while loading', async () => {
+ const root = await freshRoot()
+ const seed = createNativeChatDraftStore(root)
+ for (let index = 0; index < 130; index += 1) {
+ await seed.write(`session:${index}`, draft(`draft-${index}`))
+ if (index < 2) {
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+ }
+ await writeFile(join(root, fileOf('session:garbage')), '{not json')
+
+ // Hold both chats' queues so load reads the old files, then the writes land, then load decides.
+ let release = () => {}
+ const held = new Promise((resolve) => {
+ release = resolve
+ })
+ void withSidecarSnapshotQueue(join(root, fileOf('session:0')), () => held)
+ void withSidecarSnapshotQueue(join(root, fileOf('session:garbage')), () => held)
+ const store = createNativeChatDraftStore(root)
+ const oldest = store.write('session:0', draft('typed after relaunch'))
+ const overGarbage = store.write('session:garbage', draft('typed over garbage'))
+ const loaded = store.load()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ release()
+ await loaded
+
+ expect(await oldest).toBe('persisted')
+ expect(await overGarbage).toBe('persisted')
+ const next = new Map(
+ (await createNativeChatDraftStore(root).load()).map(({ scopeKey, draft }) => [
+ scopeKey,
+ draft
+ ])
+ )
+ expect(next.get('session:0')).toEqual(draft('typed after relaunch'))
+ expect(next.get('session:garbage')).toEqual(draft('typed over garbage'))
+ })
+
it('keeps only the newest drafts past the bound', async () => {
const root = await freshRoot()
const store = createNativeChatDraftStore(root)
diff --git a/src/main/native-chat/native-chat-draft-store.ts b/src/main/native-chat/native-chat-draft-store.ts
index 71895d81979e..67cce163533f 100644
--- a/src/main/native-chat/native-chat-draft-store.ts
+++ b/src/main/native-chat/native-chat-draft-store.ts
@@ -4,11 +4,10 @@
import { createHash } from 'node:crypto'
import { readdirSync, readFileSync } from 'node:fs'
-import { mkdir, readdir, rm } from 'node:fs/promises'
+import { mkdir, readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { removeStaleDurableWriteTempFiles } from '../durable-file-write'
import {
- readSidecarSnapshot,
removeSidecarSnapshot,
sidecarSnapshotFile,
withSidecarSnapshotQueue,
@@ -27,6 +26,15 @@ const FILE_VERSION = 1
const MAX_SAVED_DRAFTS = 128
type Saved = { draft: PersistedNativeChatDraft; savedAt: number }
+type Intent = { draft: PersistedNativeChatDraft | null; savedAt: number }
+
+type DraftFile =
+ | ({ kind: 'draft'; scopeKey: string } & Saved)
+ /** Not JSON, or a version-1 file that is not a draft: nothing can ever read it. */
+ | { kind: 'garbage' }
+ /** A newer build's draft, or a file that could not be read this time: never deleted. */
+ | { kind: 'kept'; reason: string }
+ | { kind: 'gone' }
export type NativeChatDraftStore = {
/** Every saved draft, oldest first, once the writes already asked for have landed. */
@@ -38,7 +46,7 @@ export type NativeChatDraftStore = {
scopeKey: string,
draft: PersistedNativeChatDraft | null
) => Promise
- /** Waits for every write asked for so far. */
+ /** Waits for every write asked for so far, then retries each failed one once (at quit). */
drain: () => Promise
}
@@ -47,17 +55,37 @@ function draftFile(root: string, scopeKey: string): string {
return sidecarSnapshotFile(root, `${createHash('sha256').update(scopeKey).digest('hex')}.json`)
}
-function parseDraftFile(value: unknown): ({ scopeKey: string } & Saved) | null {
- if (typeof value !== 'object' || value === null) {
- return null
+function classifyDraftFile(raw: string): DraftFile {
+ let value: unknown
+ try {
+ value = JSON.parse(raw)
+ } catch {
+ return { kind: 'garbage' }
+ }
+ const v = typeof value === 'object' && value !== null && 'v' in value ? value.v : undefined
+ if (v !== FILE_VERSION) {
+ return { kind: 'kept', reason: typeof v === 'number' ? `version ${v}` : 'no version' }
}
- const v = 'v' in value ? value.v : undefined
- const scopeKey = 'scopeKey' in value ? value.scopeKey : undefined
- const savedAt = 'savedAt' in value ? value.savedAt : undefined
+ const scopeKey =
+ typeof value === 'object' && value !== null && 'scopeKey' in value && value.scopeKey
+ const savedAt = typeof value === 'object' && value !== null && 'savedAt' in value && value.savedAt
const draft = parseNativeChatDraft(value)
- return v === FILE_VERSION && typeof scopeKey === 'string' && draft
- ? { scopeKey, draft, savedAt: typeof savedAt === 'number' ? savedAt : 0 }
- : null
+ return typeof scopeKey === 'string' && draft
+ ? { kind: 'draft', scopeKey, draft, savedAt: typeof savedAt === 'number' ? savedAt : 0 }
+ : { kind: 'garbage' }
+}
+
+function errorCode(error: unknown): string {
+ return error instanceof Error && 'code' in error ? String(error.code) : 'unknown'
+}
+
+async function readDraftFile(file: string): Promise {
+ try {
+ return classifyDraftFile(await readFile(file, 'utf8'))
+ } catch (error) {
+ const code = errorCode(error)
+ return code === 'ENOENT' ? { kind: 'gone' } : { kind: 'kept', reason: code }
+ }
}
function oldestFirst(saved: Iterable<[string, Saved]>): SavedNativeChatDraft[] {
@@ -67,13 +95,38 @@ function oldestFirst(saved: Iterable<[string, Saved]>): SavedNativeChatDraft[] {
}
export function createNativeChatDraftStore(root: string): NativeChatDraftStore {
+ // What each chat's draft should be, failed writes included, so a reloaded window never sees
+ // text whose clear did not reach the file.
const saved = new Map()
const written = new Set()
const pending = new Set>()
+ // The newest write per chat, so an older failure never outlives a newer write.
+ const latest = new Map()
+ // A write whose file op failed: superseded by the chat's next write, else retried once at quit.
+ const failed = new Map()
+ let rootReady: Promise | null = null
let loaded = false
+ const track = (work: Promise): Promise => {
+ pending.add(work)
+ void work.then(
+ () => pending.delete(work),
+ () => pending.delete(work)
+ )
+ return work
+ }
+
+ // Load-time deletes go through the chat's queue and recheck, so they never undo a write.
+ const removeIfStill = (file: string, stillDoomed: () => Promise | boolean) =>
+ withSidecarSnapshotQueue(file, async () => {
+ if (await stillDoomed()) {
+ await removeSidecarSnapshot(file)
+ }
+ }).catch((error: unknown) => console.warn('[native-chat-drafts] could not delete', file, error))
+
const ready = (async () => {
- const found: ({ scopeKey: string; file: string } & Saved)[] = []
+ const found: ({ file: string; scopeKey: string } & Saved)[] = []
+ const removals: Promise[] = []
const names = await readdir(root).catch(() => [])
// Temp files a killed run left mid-write; this run's own are never swept.
const interrupted = new Set(
@@ -86,53 +139,67 @@ export function createNativeChatDraftStore(root: string): NativeChatDraftStore {
)
for (const name of names.filter((entry) => entry.endsWith('.json'))) {
const file = join(root, name)
- const parsed = parseDraftFile(await readSidecarSnapshot(file))
- if (parsed) {
- found.push({ ...parsed, file })
- } else {
- await rm(file, { force: true }).catch(() => {})
+ const read = await readDraftFile(file)
+ switch (read.kind) {
+ case 'draft':
+ found.push({ ...read, file })
+ break
+ case 'garbage':
+ removals.push(
+ removeIfStill(file, async () => (await readDraftFile(file)).kind === 'garbage')
+ )
+ break
+ case 'kept':
+ console.warn('[native-chat-drafts] kept a draft file it cannot use:', name, read.reason)
+ break
+ case 'gone':
+ break
}
}
found.sort((left, right) => left.savedAt - right.savedAt)
for (const [index, entry] of found.entries()) {
+ if (written.has(entry.scopeKey)) {
+ continue
+ }
if (index < found.length - MAX_SAVED_DRAFTS) {
- await rm(entry.file, { force: true }).catch(() => {})
- } else if (!written.has(entry.scopeKey)) {
+ removals.push(removeIfStill(entry.file, () => !written.has(entry.scopeKey)))
+ } else {
saved.set(entry.scopeKey, { draft: entry.draft, savedAt: entry.savedAt })
}
}
+ await Promise.all(removals)
loaded = true
})()
- const apply = async (
- scopeKey: string,
- draft: PersistedNativeChatDraft | null
- ): Promise => {
+ const apply = async (scopeKey: string, { draft, savedAt }: Intent): Promise => {
const file = draftFile(root, scopeKey)
if (!draft || isEmptyNativeChatDraft(draft)) {
await removeSidecarSnapshot(file)
- saved.delete(scopeKey)
- return 'persisted'
+ return
+ }
+ // Why once: a mkdir per typing pause queues ahead of the clear; any failure makes it again.
+ rootReady ??= mkdir(root, { recursive: true, mode: 0o700 })
+ try {
+ await rootReady
+ await writeSidecarSnapshot(
+ file,
+ { v: FILE_VERSION, scopeKey, savedAt, ...draft },
+ { durability: 'process' }
+ )
+ } catch (error) {
+ rootReady = null
+ throw error
}
- const savedAt = Date.now()
- await mkdir(root, { recursive: true, mode: 0o700 })
- await writeSidecarSnapshot(
- file,
- { v: FILE_VERSION, scopeKey, savedAt, ...draft },
- { durability: 'process' }
- )
- saved.set(scopeKey, { draft, savedAt })
- return 'persisted'
}
- const drain = async (): Promise => {
+ const settle = async (): Promise => {
await Promise.all(pending)
}
return {
load: async () => {
await ready
- await drain()
+ await settle()
return oldestFirst(saved)
},
loadSync: () => {
@@ -140,28 +207,76 @@ export function createNativeChatDraftStore(root: string): NativeChatDraftStore {
return oldestFirst(saved)
}
const found: [string, Saved][] = []
+ let names: string[] = []
try {
- for (const name of readdirSync(root).filter((entry) => entry.endsWith('.json'))) {
- const parsed = parseDraftFile(JSON.parse(readFileSync(join(root, name), 'utf8')))
- if (parsed) {
- found.push([parsed.scopeKey, parsed])
+ names = readdirSync(root).filter((entry) => entry.endsWith('.json'))
+ } catch {
+ // No drafts folder yet.
+ }
+ for (const name of names) {
+ try {
+ const read = classifyDraftFile(readFileSync(join(root, name), 'utf8'))
+ if (read.kind === 'draft') {
+ found.push([read.scopeKey, read])
}
+ } catch {
+ // Unreadable for now: skipped here, judged by the async load.
}
- } catch {
- // Unreadable: start from what was read rather than block the renderer.
}
return oldestFirst(found).slice(-MAX_SAVED_DRAFTS)
},
write: (scopeKey, draft) => {
written.add(scopeKey)
+ failed.delete(scopeKey)
+ const intent: Intent = { draft, savedAt: Date.now() }
+ latest.set(scopeKey, intent)
+ if (draft && !isEmptyNativeChatDraft(draft)) {
+ saved.set(scopeKey, { draft, savedAt: intent.savedAt })
+ } else {
+ saved.delete(scopeKey)
+ }
// Why queued per chat: a typing write still in flight must never land after the clear behind it.
- const result = withSidecarSnapshotQueue(draftFile(root, scopeKey), () =>
- apply(scopeKey, draft)
- ).catch((): NativeChatDraftStoreResult => 'failed')
- pending.add(result)
- void result.then(() => pending.delete(result))
- return result
+ return track(
+ withSidecarSnapshotQueue(draftFile(root, scopeKey), () => apply(scopeKey, intent)).then(
+ (): NativeChatDraftStoreResult => {
+ if (latest.get(scopeKey) === intent) {
+ latest.delete(scopeKey)
+ }
+ return 'persisted'
+ },
+ (error: unknown): NativeChatDraftStoreResult => {
+ if (latest.get(scopeKey) === intent) {
+ failed.set(scopeKey, intent)
+ }
+ console.warn(
+ '[native-chat-drafts] write failed; retrying at its next write or at quit:',
+ errorCode(error)
+ )
+ return 'failed'
+ }
+ )
+ )
},
- drain
+ drain: async () => {
+ await settle()
+ const retries = Array.from(failed)
+ failed.clear()
+ await Promise.all(
+ retries.map(([scopeKey, intent]) =>
+ withSidecarSnapshotQueue(draftFile(root, scopeKey), () => apply(scopeKey, intent)).then(
+ () => {
+ if (latest.get(scopeKey) === intent) {
+ latest.delete(scopeKey)
+ }
+ },
+ (error: unknown) =>
+ console.warn(
+ '[native-chat-drafts] dropped a draft write that failed twice:',
+ errorCode(error)
+ )
+ )
+ )
+ )
+ }
}
}
diff --git a/src/main/sidecar-snapshot-file.test.ts b/src/main/sidecar-snapshot-file.test.ts
index eefe1d9ac42f..45f956cac228 100644
--- a/src/main/sidecar-snapshot-file.test.ts
+++ b/src/main/sidecar-snapshot-file.test.ts
@@ -2,17 +2,30 @@ import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
+import type * as NodeFsPromises from 'node:fs/promises'
import type * as DurableFileWrite from './durable-file-write'
const durable = vi.hoisted(() => ({
writeFileDurable: vi.fn(),
- writeFileProcessDurable: vi.fn()
+ writeFileProcessDurable: vi.fn(),
+ removeStaleDurableWriteTempFiles: vi.fn()
}))
+const fsPromises = vi.hoisted(() => ({ rm: vi.fn() }))
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ fsPromises.rm.mockImplementation(actual.rm)
+ return { ...actual, rm: fsPromises.rm }
+})
+
vi.mock('./durable-file-write', async (importOriginal) => {
const actual = await importOriginal()
durable.writeFileDurable.mockImplementation(actual.writeFileDurable)
durable.writeFileProcessDurable.mockImplementation(actual.writeFileProcessDurable)
+ durable.removeStaleDurableWriteTempFiles.mockImplementation(
+ actual.removeStaleDurableWriteTempFiles
+ )
return { ...actual, ...durable }
})
@@ -32,7 +45,10 @@ async function freshRoot(): Promise {
return root
}
+const originalPlatform = process.platform
+
afterEach(async () => {
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.clearAllMocks()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
@@ -71,6 +87,18 @@ describe('sidecar snapshot writes', () => {
expect(await readSidecarSnapshot(file)).toEqual({ a: 1 })
})
+ // Only a crashed run leaves temp files; listing the folder on every write only adds latency.
+ it("sweeps a file's stale temp files once per run, not on every write", async () => {
+ const file = join(await freshRoot(), 'snapshot.json')
+
+ await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' })
+ await writeSidecarSnapshot(file, { a: 2 }, { durability: 'process' })
+ await writeSidecarSnapshot(file, { a: 3 })
+
+ expect(durable.removeStaleDurableWriteTempFiles).toHaveBeenCalledOnce()
+ expect(await readSidecarSnapshot(file)).toEqual({ a: 3 })
+ })
+
it('removes a snapshot, and treats a missing one as removed', async () => {
const root = await freshRoot()
const file = join(root, 'snapshot.json')
@@ -81,4 +109,17 @@ describe('sidecar snapshot writes', () => {
expect(await readdir(root)).toEqual([])
})
+
+ // A virus scanner holding a just-written draft open must not fail the clear at send (#1507).
+ it('retries a delete Windows refused while another process held the file', async () => {
+ Object.defineProperty(process, 'platform', { value: 'win32' })
+ const root = await freshRoot()
+ const file = join(root, 'snapshot.json')
+ await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' })
+ fsPromises.rm.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' }))
+
+ await removeSidecarSnapshot(file)
+
+ expect(await readdir(root)).toEqual([])
+ })
})
diff --git a/src/main/sidecar-snapshot-file.ts b/src/main/sidecar-snapshot-file.ts
index c130696d8f5d..6e8087b76621 100644
--- a/src/main/sidecar-snapshot-file.ts
+++ b/src/main/sidecar-snapshot-file.ts
@@ -1,7 +1,7 @@
// Why a sidecar next to orca-data.json: scan snapshots rewrite wholesale and can reach hundreds
// of KB; folding them into orca-data.json would rewrite the whole state file per scan (the
// githubCache sidecar precedent). Best-effort by design: a lost snapshot only costs a rescan.
-import { readFile, rm } from 'node:fs/promises'
+import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
durableWriteTempPath,
@@ -9,9 +9,12 @@ import {
writeFileDurable,
writeFileProcessDurable
} from './durable-file-write'
+import { removeFileWithWindowsRetryAsync } from './codex-accounts/fs-utils'
const queues = new Map>()
const staleTempCleanups = new Map>()
+// Why once per run: only a crashed run leaves temp files, so sweeping again finds nothing.
+const sweptFiles = new Set()
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000
export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string): string {
@@ -53,24 +56,27 @@ export async function writeSidecarSnapshot(
payload: unknown,
options: { durability?: 'power-loss' | 'process' } = {}
): Promise {
- let cleanup = staleTempCleanups.get(file)
- if (!cleanup) {
- cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS })
- staleTempCleanups.set(file, cleanup)
- void cleanup.then(() => {
- if (staleTempCleanups.get(file) === cleanup) {
- staleTempCleanups.delete(file)
- }
- })
+ if (!sweptFiles.has(file)) {
+ let cleanup = staleTempCleanups.get(file)
+ if (!cleanup) {
+ cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS })
+ staleTempCleanups.set(file, cleanup)
+ void cleanup.then(() => {
+ sweptFiles.add(file)
+ if (staleTempCleanups.get(file) === cleanup) {
+ staleTempCleanups.delete(file)
+ }
+ })
+ }
+ await cleanup
}
- await cleanup
const write = options.durability === 'process' ? writeFileProcessDurable : writeFileDurable
await write(durableWriteTempPath(file), file, JSON.stringify(payload))
}
/** Delete a sidecar; a missing one is already removed. Run it inside the file's queue. */
export async function removeSidecarSnapshot(file: string): Promise {
- await rm(file, { force: true })
+ await removeFileWithWindowsRetryAsync(file)
}
export function _getSidecarSnapshotPendingFileCountForTests(): number {
From 6afc818de021b2c58e4314c6e21654a69d3ce4a8 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 1 Oct 2026 18:09:13 -0700
Subject: [PATCH 41/52] fix(native-chat): every send waits for its clear, and
Stop during the wait cancels it
- Both terminal send paths, typed and picked from the slash-command menu, go through one helper:
clear, wait (capped) for the clear to be written, then write to the terminal.
- The wait is tracked like a pending terminal write, so Stop, Escape or a pane switch during it
cancels the send and puts the message back into the box, as a Stop before Enter would leave it.
- A queued message taken back is deleted from the host only once its copy in the box is written
(capped; deleted anyway on failure).
- The web client reads drafts live when a chat first needs them, so it never misses another
tab's send that happened earlier.
- A clear that failed or outlasted the wait is logged with its chat and the reason.
- Typing is also flushed on beforeunload.
---
src/preload/api/native-chat-api.ts | 2 +-
.../native-chat/NativeChatComposer.tsx | 4 +-
.../native-chat/native-chat-draft-cache.ts | 4 +-
.../native-chat-draft-persistence.test.ts | 57 +++++++-
.../native-chat/native-chat-draft-storage.ts | 48 ++++---
.../native-chat-send-after-draft-clear.ts | 62 +++++++++
...ative-chat-structured-composer-dispatch.ts | 13 +-
...structured-send-composition-clear.test.tsx | 14 ++
...tructured-session-takeover-report.test.tsx | 3 +-
...tive-chat-picker-command-dispatch.test.tsx | 82 ++++++++++--
...use-native-chat-picker-command-dispatch.ts | 36 +++--
...use-native-chat-pty-composer-send.test.tsx | 123 +++++++++++++-----
.../use-native-chat-pty-composer-send.ts | 24 ++--
...ive-chat-structured-composer-send.test.tsx | 3 +-
...se-native-chat-structured-composer-send.ts | 51 ++++----
...red-agent-session-queued-messages.test.tsx | 47 +++++++
...tructured-agent-session-queued-messages.ts | 8 +-
17 files changed, 467 insertions(+), 114 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/native-chat-send-after-draft-clear.ts
diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts
index 8bc8ba2243ee..0f15aaae095f 100644
--- a/src/preload/api/native-chat-api.ts
+++ b/src/preload/api/native-chat-api.ts
@@ -85,7 +85,7 @@ export type NativeChatDraftsApi = {
load: () => Promise
/** The same, blocking; only for a renderer that needs drafts before `load` returned. */
loadSync: () => SavedNativeChatDraft[]
- /** `null` clears. Resolves once the draft is on disk (or the write failed). */
+ /** `null` clears. Resolves once written to the file (no fsync), or once the write failed. */
write: (
scopeKey: string,
draft: PersistedNativeChatDraft | null
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
index 91945f960d3e..04232e354f6e 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
@@ -258,7 +258,8 @@ const NativeChatComposerPane = forwardRef {
+/** Waits (briefly) until the chat's last write, such as the clear at send, is written. */
+export function awaitNativeChatDraftWritten(draftKey: string): Promise {
return awaitNativeChatDraftSaved(draftKey)
}
diff --git a/src/renderer/src/components/native-chat/native-chat-draft-persistence.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-persistence.test.ts
index 2bbb69a95d4f..7a1ec868df01 100644
--- a/src/renderer/src/components/native-chat/native-chat-draft-persistence.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-draft-persistence.test.ts
@@ -9,7 +9,10 @@ import { useLayoutEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache'
import type * as NativeChatDraftCache from './native-chat-draft-cache'
-import { installLocalStorageNativeChatDrafts } from './native-chat-draft-store.test-support'
+import {
+ installHeldNativeChatDrafts,
+ installLocalStorageNativeChatDrafts
+} from './native-chat-draft-store.test-support'
const PREFIX = 'orca:nativeChatComposerDraft:v1:'
const SCOPE = 'structured-agent-session-s1:pane'
@@ -114,6 +117,7 @@ describe('composer draft persistence', () => {
})
it.each([
+ ['beforeunload', () => window.dispatchEvent(new Event('beforeunload'))],
['pagehide', () => window.dispatchEvent(new Event('pagehide'))],
[
'the page becoming hidden',
@@ -367,6 +371,17 @@ describe('another window of the web client', () => {
expect(cache.readNativeChatDraftCache(SCOPE)).toBe('from the other tab')
})
+ // This tab hears other tabs only once a chat first reads drafts, so that read must be live.
+ it("misses no send another tab made before this tab's chat first read its draft", async () => {
+ cache.writeNativeChatDraftCache(SCOPE, 'deploy to prod', 'now')
+ const next = await relaunch()
+ await (await import('./native-chat-draft-storage')).preloadNativeChatDrafts()
+
+ localStorage.removeItem(`${PREFIX}${encodeURIComponent(SCOPE)}`)
+
+ expect(next.readNativeChatDraftCache(SCOPE)).toBe('')
+ })
+
it("keeps this tab's unsaved typing over another tab's write", () => {
cache.writeNativeChatDraftCache(SCOPE, 'typing here', 'after-pause')
@@ -375,3 +390,43 @@ describe('another window of the web client', () => {
expect(cache.readNativeChatDraftCache(SCOPE)).toBe('typing here')
})
})
+
+// A send goes ahead without a saved clear only when saving failed or was slow; say so in the log.
+describe('a clear that was not saved before the send went on', () => {
+ it('is logged once with its chat and why, never its text', async () => {
+ const writes = installHeldNativeChatDrafts()
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ cache.writeNativeChatDraftCache(SCOPE, 'secret plan', 'now')
+ writes.shift()?.settle('persisted')
+ cache.writeNativeChatDraftCache(SCOPE, '', 'now')
+
+ const waited = cache.awaitNativeChatDraftWritten(SCOPE)
+ await vi.advanceTimersByTimeAsync(250)
+ await waited
+
+ expect(warn).toHaveBeenCalledOnce()
+ expect(warn).toHaveBeenCalledWith(expect.any(String), { scopeKey: SCOPE, reason: 'timed out' })
+ expect(JSON.stringify(warn.mock.calls)).not.toContain('secret plan')
+ })
+
+ it('is logged when the store refused the clear', async () => {
+ const writes = installHeldNativeChatDrafts()
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ cache.writeNativeChatDraftCache(SCOPE, '', 'now')
+
+ const waited = cache.awaitNativeChatDraftWritten(SCOPE)
+ writes.forEach((write) => write.settle('failed'))
+ await waited
+
+ expect(warn).toHaveBeenCalledWith(expect.any(String), { scopeKey: SCOPE, reason: 'failed' })
+ })
+
+ it('is not logged when it was saved', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ cache.writeNativeChatDraftCache(SCOPE, '', 'now')
+
+ await cache.awaitNativeChatDraftWritten(SCOPE)
+
+ expect(warn).not.toHaveBeenCalled()
+ })
+})
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 477f9b2cdbe9..719f32bf8a06 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
@@ -1,8 +1,10 @@
// Saved copy of the composer drafts, so a half-typed message survives quitting Orca. Typing is
-// saved after a short pause and flushed when the page hides; a clear or a put-back is saved at
-// once. On desktop the main process confirms each write once it is on disk, and a send waits (up
-// to a bound) for its clear, so a crash after Enter cannot bring sent text back. The web client
-// keeps browser storage, which reaches disk on the browser's own delay, so a browser crash can.
+// saved after a short pause and flushed when the page hides or unloads; a clear or a put-back is
+// saved at once. On desktop the main process confirms each write once the file op returned, and a
+// send waits (up to a bound) for its clear, so a crash after Enter cannot bring sent text back,
+// except when that save failed or outlasted the bound, and for host commands, which still clear
+// once the host accepts them. The web client keeps browser storage, which reaches disk on the
+// browser's own delay, so a browser crash can.
import type {
NativeChatDraftStoreResult,
@@ -27,7 +29,10 @@ const SEND_WAIT_FOR_CLEAR_MS = 250
const pendingDrafts = new Map()
const pendingTimers = new Map>()
-const latestWrites = new Map