Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
d3fd788
feat(native-chat): keep composer drafts across restarts
brennanb2025 Oct 1, 2026
7a6cad6
feat(native-chat): share one draft per chat across every view of it
brennanb2025 Oct 1, 2026
cfbe390
refactor(native-chat): drop the text view's self-echo skip; pin chip …
brennanb2025 Oct 1, 2026
73cea0f
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 1, 2026
e93889a
test(native-chat): read a chip's preview from the pane, not the share…
brennanb2025 Oct 1, 2026
b3b9d24
fix(native-chat): a composing pane's keystrokes reach the shared draf…
brennanb2025 Oct 1, 2026
4a02787
fix(native-chat): a chat's draft dies with the chat
brennanb2025 Oct 1, 2026
cf7ed82
fix(native-chat): images in a saved draft survive a reboot, and a mis…
brennanb2025 Oct 1, 2026
4e424c9
fix(native-chat): a saved draft can no longer crowd a structured send…
brennanb2025 Oct 1, 2026
5f8811f
fix(native-chat): a web client tab follows drafts another tab saves o…
brennanb2025 Oct 1, 2026
4e58b41
fix(native-chat): a view re-reads the shared draft once it subscribes
brennanb2025 Oct 1, 2026
d1fc302
test(native-chat): an unsettled composition no longer reaches the sha…
brennanb2025 Oct 1, 2026
fb3ed65
fix(native-chat): the slash picker opens only in the pane being typed in
brennanb2025 Oct 1, 2026
59897b4
fix(native-chat): an untouched launch seed copy is not saved with the…
brennanb2025 Oct 1, 2026
6d7329c
fix(native-chat): chips keep their order and identity across panes on…
brennanb2025 Oct 1, 2026
8170e54
fix(native-chat): draft page listeners install only where the window …
brennanb2025 Oct 1, 2026
5291191
fix(main): authorize the chat attachment root in filesystem auth, not…
brennanb2025 Oct 1, 2026
b8af1be
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 1, 2026
0552dac
fix(native-chat): save what a terminal agent's input line holds with …
brennanb2025 Oct 1, 2026
afef2ff
refactor(native-chat): one ordered chip list per chat, edited by id
brennanb2025 Oct 1, 2026
8c78707
fix(main): a chat attachment root only has to be a real directory
brennanb2025 Oct 1, 2026
7a1238f
fix(native-chat): restored local images get their read grant back bef…
brennanb2025 Oct 1, 2026
6a1ddec
test(native-chat): a local chat paste asks for chat attachment storag…
brennanb2025 Oct 1, 2026
4eec291
fix(native-chat): a removed or re-paired runtime keeps its chats' drafts
brennanb2025 Oct 1, 2026
7bcaaea
perf(native-chat): look up finished image checks in a set
brennanb2025 Oct 1, 2026
2635c3a
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 1, 2026
42b98ec
fix(native-chat): a pane's image previews are released when it unmounts
brennanb2025 Oct 1, 2026
033542f
fix(native-chat): deleting a worktree deletes its terminal-agent chat…
brennanb2025 Oct 1, 2026
1f4c0c2
fix(main): chat image pastes and composer drops share one folder rule
brennanb2025 Oct 1, 2026
3138c5d
fix(native-chat): an image chip records where its file lives
brennanb2025 Oct 1, 2026
4a8073c
fix(native-chat): an image given back by a Stop keeps where its file …
brennanb2025 Oct 1, 2026
65ede01
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 1, 2026
1559d6f
refactor(native-chat): name every owner kind a returned image can get
brennanb2025 Oct 1, 2026
a36ef57
fix(native-chat): a sent message whose clear a crash lost is not rest…
brennanb2025 Oct 1, 2026
5f568e0
test(native-chat): type the held-draft history fixture as session state
brennanb2025 Oct 1, 2026
5f9de33
revert(native-chat): drop the slash picker's focus gating
brennanb2025 Oct 1, 2026
9338c41
fix(native-chat): a terminal-agent chat's sent message whose clear a …
brennanb2025 Oct 1, 2026
a550025
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 1, 2026
3c41189
test(native-chat): type the transcript fixture without casts
brennanb2025 Oct 1, 2026
8b37ff8
revert(native-chat): drop derive-on-restore for drafts
brennanb2025 Oct 2, 2026
c6fe749
feat(native-chat): keep desktop chat drafts in the main process and s…
brennanb2025 Oct 2, 2026
012450d
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
aac8729
fix(native-chat): sweep draft temp files a killed run left mid-write
brennanb2025 Oct 2, 2026
4236a2d
test(main): a sidecar snapshot still flushes to disk unless a caller …
brennanb2025 Oct 2, 2026
b770b6e
fix(native-chat): say why Send is held while a restored image is missing
brennanb2025 Oct 2, 2026
68b5172
fix(native-chat): a failed draft clear is retried, and load deletes o…
brennanb2025 Oct 2, 2026
6afc818
fix(native-chat): every send waits for its clear, and Stop during the…
brennanb2025 Oct 2, 2026
98e8d19
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
9391b60
fix(native-chat): a cancelled send puts back its launch draft and ima…
brennanb2025 Oct 2, 2026
271a441
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
7c354d6
fix(native-chat): one draft store per profile, and JSON without a new…
brennanb2025 Oct 2, 2026
c848731
fix(native-chat): save a send's cleared draft only once the host has …
brennanb2025 Oct 2, 2026
a3261a9
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
f5d8f28
fix(native-chat): the web client saves a send's clear at Enter
brennanb2025 Oct 2, 2026
a2ef3ab
fix(native-chat): save a send's clear on the host's ok reply, and nev…
brennanb2025 Oct 2, 2026
3c40cb8
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
dcd53b8
fix(native-chat): a graceful quit ends the saved-draft hold of a stru…
brennanb2025 Oct 2, 2026
e180f5c
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
7d07076
test(native-chat): a send's full clear writes nothing without the mes…
brennanb2025 Oct 2, 2026
dbad5ce
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
3ccbfab
fix(native-chat): keep a sent message's draft until the host has hand…
brennanb2025 Oct 2, 2026
460474f
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
69b390f
fix(native-chat): keep a sent message's draft until the agent has acc…
brennanb2025 Oct 2, 2026
9a992ea
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
2a56c4e
fix(native-chat): keep each sent message as a held send beside the bo…
brennanb2025 Oct 2, 2026
3026f47
docs(native-chat): say how a structured send's held copy is released,…
brennanb2025 Oct 2, 2026
d149ce0
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-dra…
brennanb2025 Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/main/codex-accounts/fs-utils-remove-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof NodeFsPromises>()
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()
})
})
17 changes: 16 additions & 1 deletion src/main/codex-accounts/fs-utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<void> {
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))
}
Expand Down
53 changes: 53 additions & 0 deletions src/main/durable-file-write-process.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof NodeFsPromises>()
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([])
})
})
22 changes: 21 additions & 1 deletion src/main/durable-file-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// hour's loss; fsync stops it from happening.

import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs'
import { copyFile, open, readdir, rm, stat } from 'node:fs/promises'
import { copyFile, open, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import {
publishFileWithoutOverwrite,
Expand Down Expand Up @@ -165,6 +165,26 @@ export async function writeFileDurableIfCurrent(
}
}

/**
* Write to a temp file and rename it over `finalPath`, with no fsync. Survives the process being
* killed once it returns, since the rename sits in the page cache; a power loss can still revert it.
*/
export async function writeFileProcessDurable(
tmpPath: string,
finalPath: string,
payload: string
): Promise<void> {
let renamed = false
try {
await writeFile(tmpPath, payload, { mode: 0o600 })
renamed = await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
} finally {
if (!renamed) {
await rm(tmpPath, { force: true }).catch(() => {})
}
}
}

/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
export function durableWriteTempPath(finalPath: string): string {
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
Expand Down
12 changes: 9 additions & 3 deletions src/main/ipc/filesystem-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { realpathSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import type { Store } from '../persistence'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { getNativeChatAttachmentAllowedRoots } from '../window/native-chat-attachment-store'
import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment'
import {
ensureAuthorizedRootsCache,
Expand Down Expand Up @@ -54,7 +55,12 @@ type AllowedRootsSnapshot = { get: () => readonly string[] }

function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
return { get: () => (roots ??= getAuthorizedRoots(store)) }
}

/** Workspace roots, plus Orca's own storage for chat draft images so a relaunch can read them. */
function getAuthorizedRoots(store: Store): string[] {
return [...getAllowedRoots(store), ...getNativeChatAttachmentAllowedRoots()]
}

export function isPathAllowed(
Expand All @@ -71,7 +77,7 @@ export function isPathAllowed(
return true
}
}
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
return (allowedRoots?.get() ?? getAuthorizedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
}
Expand Down Expand Up @@ -223,7 +229,7 @@ async function isPathAllowedByCanonicalAllowedRoot(
if (!sourcePath) {
return false
}
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
for (const root of allowedRoots?.get() ?? getAuthorizedRoots(store)) {
const resolvedRoot = resolve(root)
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
continue
Expand Down
129 changes: 129 additions & 0 deletions src/main/ipc/native-chat-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { mkdtemp, readdir, rm } 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 { handlers, listeners, ipcMainMock, isTrustedUIRendererMock } = vi.hoisted(() => {
const handlerMap = new Map<string, (...args: unknown[]) => unknown>()
const listenerMap = new Map<string, (...args: unknown[]) => void>()
return {
handlers: handlerMap,
listeners: listenerMap,
ipcMainMock: {
removeHandler: vi.fn(),
removeAllListeners: vi.fn(),
handle: (channel: string, fn: (...args: unknown[]) => unknown) => handlerMap.set(channel, fn),
on: (channel: string, fn: (...args: unknown[]) => void) => listenerMap.set(channel, fn)
},
isTrustedUIRendererMock: vi.fn(() => true)
}
})

const sidecar = vi.hoisted(() => ({ removeSidecarSnapshot: vi.fn() }))

vi.mock('../sidecar-snapshot-file', async (importOriginal) => {
const actual = await importOriginal<typeof SidecarSnapshotFile>()
sidecar.removeSidecarSnapshot.mockImplementation(actual.removeSidecarSnapshot)
return { ...actual, removeSidecarSnapshot: sidecar.removeSidecarSnapshot }
})
vi.mock('electron', () => ({ ipcMain: ipcMainMock }))
vi.mock('./ui', () => ({ isTrustedUIRenderer: isTrustedUIRendererMock }))

import { drainNativeChatDrafts, registerNativeChatDraftHandlers } from './native-chat-drafts'

const trusted = { sender: { id: 1 } }
const draft = { text: 'hello', attachments: [] }
let tempRoot = ''
let root = ''

function invoke(channel: string, event: unknown, args?: unknown): unknown {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`no handler for ${channel}`)
}
return handler(event, args)
}

function loadSync(event: { sender: { id: number } }): unknown {
const reply: { sender: { id: number }; returnValue?: unknown } = { ...event }
listeners.get('nativeChat:drafts:loadSync')?.(reply)
return reply.returnValue
}

beforeEach(async () => {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-native-chat-draft-ipc-'))
root = join(tempRoot, 'native-chat-drafts')
isTrustedUIRendererMock.mockReturnValue(true)
registerNativeChatDraftHandlers(root)
})

afterEach(async () => {
await drainNativeChatDrafts()
await rm(tempRoot, { recursive: true, force: true })
})

describe('native chat draft IPC', () => {
it('saves, loads and clears a draft for the app window', async () => {
await expect(
invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft })
).resolves.toBe('persisted')
await expect(invoke('nativeChat:drafts:load', trusted)).resolves.toEqual([
{ scopeKey: 'session:s1', draft }
])
expect(loadSync(trusted)).toEqual([{ scopeKey: 'session:s1', draft }])

await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft: null })
expect(await readdir(root)).toEqual([])
})

it('refuses every other renderer', async () => {
await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft })
isTrustedUIRendererMock.mockReturnValue(false)
const other = { sender: { id: 2 } }

expect(await invoke('nativeChat:drafts:write', other, { scopeKey: 'session:s2', draft })).toBe(
'failed'
)
expect(
await invoke('nativeChat:drafts:write', other, { scopeKey: 'session:s1', draft: null })
).toBe('failed')
expect(await invoke('nativeChat:drafts:load', other)).toEqual([])
expect(loadSync(other)).toEqual([])
isTrustedUIRendererMock.mockReturnValue(true)
expect(await invoke('nativeChat:drafts:load', trusted)).toEqual([
{ scopeKey: 'session:s1', draft }
])
})

it('refuses a write without a chat', async () => {
expect(await invoke('nativeChat:drafts:write', trusted, { draft })).toBe('failed')
expect(await invoke('nativeChat:drafts:write', trusted, null)).toBe('failed')
})

// macOS keeps Orca running with no window; reopening one registers the handlers again.
it('keeps the same store when the window is reopened, so a failed clear is still owed', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft })
sidecar.removeSidecarSnapshot.mockRejectedValueOnce(
Object.assign(new Error('denied'), { code: 'EACCES' })
)
expect(
await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft: null })
).toBe('failed')

registerNativeChatDraftHandlers(root)

expect(await invoke('nativeChat:drafts:load', trusted)).toEqual([])
await drainNativeChatDrafts()
expect(await readdir(root)).toEqual([])
})

it('lets a draft written just before quitting land before the app exits', async () => {
void invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft })

await drainNativeChatDrafts()

expect((await readdir(root)).filter((name) => name.endsWith('.json'))).toHaveLength(1)
})
})
Loading
Loading