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 e6fdd9f900b6..7e479a9ecba0 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,12 +1,16 @@ +import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({ +const { writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({ writeFileMock: vi.fn(), - getPathMock: vi.fn(() => '/var/folders/ab/T'), + mkdirMock: vi.fn(), + getPathMock: vi.fn((name: string) => + name === 'temp' ? '/os/temp' : '/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 }) @@ -25,10 +29,28 @@ beforeEach(() => { }) describe('saveClipboardImageBufferAsTempFile', () => { - it('writes the pasted image to the local temp folder', 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(savedPath.startsWith('/var/folders/ab/T')).toBe(true) + expect(getPathMock).toHaveBeenCalledWith('temp') + expect(getPathMock).not.toHaveBeenCalledWith('userData') + expect(mkdirMock).not.toHaveBeenCalled() + expect(dirname(savedPath)).toBe('/os/temp') + expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) + }) + + 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 } + ) + expect(dirname(savedPath)).toBe( + join('/Users/me/Library/Application Support/orca', 'native-chat-pastes') + ) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index cadbd6776504..3a599e4ac5e2 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -2,14 +2,17 @@ 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 { getAppEnvironment } from '../../shared/app-environment' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' +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' @@ -39,7 +42,14 @@ export async function saveClipboardImageBufferAsTempFile( return remotePath } - const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) + // 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) return tempPath } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 79f9eabc066c..2d283a50de2e 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -84,9 +84,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 7873316ef3d2..f5fb9032f2dc 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -44,6 +44,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 @@ -101,8 +102,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) => { @@ -116,6 +119,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 979c5b7d413e..1bc2915508f3 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', 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..05599675c7c3 --- /dev/null +++ b/src/main/window/native-chat-paste-files.test.ts @@ -0,0 +1,290 @@ +import { + existsSync, + lutimesSync, + 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 } 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, + restoreNativeChatPastes, + 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' + + 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(() => { + 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('keeps 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 } + ]) + 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') + await expect(restoreNativeChatPastes([kept])).resolves.toEqual([ + { path: kept, kept: true, exists: true } + ]) + const readable = await resolveLocalFileRequestPath(kept, { kind: 'chat-image' }, NO_PROJECTS) + 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') + 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, realpathSync(kept)])).resolves.toEqual([ + { 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 }) + } + }) + + 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 } + ]) + // 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 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') + // `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(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 () => { + 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( + [{ 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')) + 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') + 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 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 new file mode 100644 index 000000000000..a2ef9682426b --- /dev/null +++ b/src/main/window/native-chat-paste-files.ts @@ -0,0 +1,138 @@ +// Local native-chat pastes live in an Orca-owned folder, so a restored draft can show and send them: +// a restore keeps only 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' + +// 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 + +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: PathApi, 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: PathApi = 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: kept only when its real path is a file inside the real paste + * 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)) { + return [] + } + const folders = await realPasteFolder() + return Promise.all( + paths + .slice(0, MAX_RESTORED_PASTES) + .flatMap((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( + folders: { named: string; real: string } | null, + restored: string +): Promise { + const refused = { path: restored, kept: false, exists: false } + if (folders === null || restored === '' || !path.isAbsolute(restored)) { + return refused + } + // Why both: the text the draft stores 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(folders.real, real) || !(await stat(real)).isFile()) { + return refused + } + 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: 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 { + const folders = await realPasteFolder() + if (!folders) { + return + } + const folder = folders.named + let entries + try { + entries = await readdir(folder, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isFile() || !PASTE_FILE_NAME.test(entry.name)) { + 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..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,9 +93,15 @@ 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'), + 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..e18fa1513948 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -12,10 +12,16 @@ 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. */ readClipboardFilePaths: () => Promise + /** Which restored draft pastes are still kept: files really 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/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 new file mode 100644 index 000000000000..4d64bdd708f2 --- /dev/null +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -0,0 +1,25 @@ +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' + +// Why bounded: loading drafts is bookkeeping and must never hold startup; a slower load still +// fills in every draft not edited meanwhile when it lands. +const DRAFT_LOAD_WAIT_MS = 1_500 + +/** Before any startup step, so a step that fails can't leave the drafts unloaded. */ +export function startNativeChatDraftLoad(): void { + 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 4186481e7fa9..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,6 +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 { 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' @@ -180,7 +181,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward. const [sessionOutcome, catalogOutcome] = await Promise.allSettled([ hydrationSessionChain, - localCatalogChain + localCatalogChain, + timeRendererStartupStep('native-chat-drafts', waitForNativeChatDraftsAtStartup) ]) if (sessionOutcome.status === 'rejected') { throw sessionOutcome.reason diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 29f7b099109a..f0f138ff9c1f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,6 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' import { forwardRef, useCallback, useState } from 'react' -import { useAppStore } from '../../store' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' @@ -14,13 +13,14 @@ 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' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import { useNativeChatSessionOptions } from './use-native-chat-session-options' import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' -import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' +import { useNativeChatDictation } from './use-native-chat-dictation' import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import { useNativeChatPickerState } from './use-native-chat-picker-state' @@ -54,6 +54,7 @@ const NativeChatComposerPane = forwardRef(EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) const [notice, setNotice] = useState(null) - const [dictationPressed, setDictationPressed] = useState(false) const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, targetPtyId, onOptimisticSendCanceled ) - const dictationState = useAppStore((store) => store.dictationState) - const voiceSettings = useAppStore((store) => store.settings?.voice) - const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel - const isDictating = - dictationPressed || - dictationState === 'starting' || - dictationState === 'listening' || - dictationState === 'stopping' const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog( agent, @@ -155,7 +147,7 @@ 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, @@ -224,8 +214,7 @@ const NativeChatComposerPane = forwardRef { @@ -412,9 +404,9 @@ const NativeChatComposerPane = forwardRef removeImageAttachment(id)} onAttach={pickAttachment} - onDictationToggle={toggleDictation} - onDictationHoldStart={startHoldDictation} - onDictationHoldEnd={stopHoldDictation} + onDictationToggle={dictation.toggleDictation} + onDictationHoldStart={dictation.startHoldDictation} + onDictationHoldEnd={dictation.stopHoldDictation} onSend={send} onStop={interrupt} sessionOptionsSurface={sessionOptionsSurface} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx index 99db859aab5a..44f2f806e487 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -114,4 +114,55 @@ describe('NativeChatComposerActions', () => { 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: "An image couldn't be brought back. Remove it to send." + }) + expect(send.hasAttribute('disabled')).toBe(true) + }) + + it('marks a draft that could not be saved only after storage refused it', () => { + const props = { + attachDisabled: false, + dictationDisabled: false, + sendDisabled: false, + isWorking: false, + isDictating: false, + isDictationHoldMode: false, + onAttach: vi.fn(), + onDictationToggle: vi.fn(), + onDictationHoldStart: vi.fn(), + onDictationHoldEnd: vi.fn(), + onSend: vi.fn(), + sessionOptionsSurface: null, + sessionOptionsSnapshot: [] + } + const explanation = "This draft couldn't be saved yet. Orca keeps trying." + const { rerender } = render() + expect(screen.queryByRole('img', { name: explanation })).toBeNull() + + rerender() + expect(screen.getByRole('img', { name: explanation })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(false) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 3012aa28cc44..a8fed079f694 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, Mic, Plus, Square } from 'lucide-react' +import { ArrowUp, CircleAlert, Mic, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' @@ -16,6 +16,10 @@ 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 + /** Storage refused this draft; it is held in memory only. */ + draftNotSaved?: boolean isWorking: boolean isDictating: boolean isDictationHoldMode: boolean @@ -38,6 +42,8 @@ export function NativeChatComposerActions({ attachDisabled, dictationDisabled, sendDisabled, + sendBlockedReason, + draftNotSaved, isWorking, isDictating, isDictationHoldMode, @@ -68,6 +74,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,27 +171,44 @@ export function NativeChatComposerActions({ {dictationLabel} - + + {sendReason ? ( + + + {/* A disabled button gets no pointer events, so the wrapper carries the hover. */} + {sendButton} + + + {sendReason} + + + ) : ( + sendButton + )}
) } + +/** Shown only after storage refused the draft, so it never appears on a normal save. */ +function DraftNotSavedIcon({ shown }: { shown: boolean }): React.JSX.Element | null { + if (!shown) { + return null + } + const explanation = translate( + 'components.native-chat.composer.draftNotSaved', + "This draft couldn't be saved yet. Orca keeps trying." + ) + return ( + + + + + + + + {explanation} + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index ccbd38afe84f..77f33c633344 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -19,11 +19,14 @@ import type { NativeChatOptionPickerRequest } from './native-chat-composer-types import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit' import { translate } from '@/i18n/i18n' +import { useNativeChatComposerDraftUnsaved } from './use-native-chat-draft-unsaved' export type NativeChatComposerFieldProps = { /** Pane identity published to the drop pipeline so a native file drop lands * only in the composer it was dropped on. */ - composerScopeKey: string + dropScopeKey: string + /** Owner of the draft the editor's document is saved with. */ + draftScopeKey: string textareaRef: RefObject draft: string disabled: boolean @@ -34,6 +37,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 +78,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 } /** @@ -97,7 +104,8 @@ function imeComposedSegment(base: string, settled: string): string { } export function NativeChatComposerField({ - composerScopeKey, + dropScopeKey, + draftScopeKey, textareaRef, draft, disabled, @@ -108,6 +116,7 @@ export function NativeChatComposerField({ notice, imageAttachments, sendButtonDisabled, + sendBlockedReason, isWorking, attachDisabled, dictationDisabled, @@ -136,6 +145,7 @@ export function NativeChatComposerField({ sessionOptionsPickerRequest, goalMode }: NativeChatComposerFieldProps): React.JSX.Element { + const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) // Value the IME started from, and whether a programmatic clear was dropped on top of it. const compositionBaseRef = useRef('') const droppedDraftClearRef = useRef(false) @@ -193,7 +203,7 @@ export function NativeChatComposerField({ ) : null}
) : null} ({ })) 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', () => ({ @@ -54,4 +55,45 @@ describe('NativeChatImageAttachmentPreview', () => { kind: 'chat-image' }) }) + + 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) + render( + + ) + + expect( + screen.getByRole('img', { + 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 819cfaeaf9e5..1a928c7c8ad7 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' @@ -18,6 +18,86 @@ 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 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, + onRemove +}: { + id: string + name: string + onRemove: (id: string) => void +}): React.JSX.Element { + 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)} /> +
+ ) +} + +function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.Element { const [isOpen, setIsOpen] = useState(false) const [isNearViewport, setIsNearViewport] = useState(false) const thumbnailRef = useRef(null) @@ -55,9 +135,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…' @@ -94,17 +172,7 @@ export function NativeChatImageAttachmentPreview({ ) : null} - + onRemove(attachment.id)} />
diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index ceb812e6291c..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,7 +105,15 @@ export function NativeChatPromptEditor({ ) }, onTransaction: ({ editor: current, transaction }) => { - if (scopeKey && transaction.docChanged) { + // 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, @@ -148,6 +157,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 +246,7 @@ export function NativeChatPromptEditor({ } } : null, - [editor] + [editor, scopeKey] ) useImperativeHandle(inputRef, () => input!, [input]) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e2c4c6ff0304..c29afa08fa62 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -28,6 +28,7 @@ vi.mock('./NativeChatQuestionCard', () => 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 a81019456b56..b89546d8a424 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' @@ -51,7 +52,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, @@ -375,6 +377,7 @@ export function NativeChatStructuredSession( ref={composerRef} terminalTabId={props.tabId} paneKey={paneKey} + draftScopeKey={structuredAgentSessionDraftScopeKey(props.sessionId)} targetPtyId={null} agent={props.agent} isWorking={controller.canStop} 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-addition.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts new file mode 100644 index 000000000000..25782727d406 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts @@ -0,0 +1,118 @@ +// @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 { 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', () => { + const add = (text: string): string => + withNativeChatComposerDraftAddition({ text, images: [] }, { text: 'go' }).text + expect(add(' \n\n')).toBe('go') + expect(add('please\n')).toBe('please\n\ngo') + }) + + it('adds text only where the draft does not already end with 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('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 }) + 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 new file mode 100644 index 000000000000..76b82af53935 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts @@ -0,0 +1,46 @@ +// 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 { appendReturnedDraftText } from '../../../../shared/returned-draft-text' +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 +} + +/** + * The draft's text and images with the addition made. Text follows the shared returned-text rule, + * which never adds a paragraph the draft already ends with. `once` does the same for images (an id + * the draft 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 text = appendReturnedDraftText(draft.text, addition.text ?? '') + 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-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..53ef375c8547 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts @@ -0,0 +1,59 @@ +// 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[] +): 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-image-check.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts new file mode 100644 index 000000000000..37c7d0124f84 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts @@ -0,0 +1,109 @@ +// Which restored draft images are known to be gone. A paste in Orca's paste folder asks main +// whether it is still kept, which holds only for a file 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, + readNativeChatComposerDraft, + isNativeChatComposerDraftUnverified, + markNativeChatComposerDraftVerified, + 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 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( + checkable + .filter((image) => !isKeptLocalPaste(image)) + .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 +} + +const checking = new Set() + +/** 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 (!isNativeChatComposerDraftUnverified(scopeKey) || checking.has(scopeKey)) { + return + } + checking.add(scopeKey) + try { + await replaceMissingImages(scopeKey) + } finally { + checking.delete(scopeKey) + markNativeChatComposerDraftVerified(scopeKey) + } +} + +async function replaceMissingImages(scopeKey: string): Promise { + 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' + ) +} + +/** 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-indexeddb.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts new file mode 100644 index 000000000000..af2b7cf889d1 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts @@ -0,0 +1,164 @@ +// 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 { afterEach, describe, expect, it, vi } 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') + }) + + 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() + }) + }) +}) 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 new file mode 100644 index 000000000000..32864af94ce5 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts @@ -0,0 +1,114 @@ +// Composer drafts in IndexedDB: one record per scope, keyed by the scope. Kept thin on purpose; +// the draft store holds every rule, and unit tests drive it through the in-memory storage. + +import type { + NativeChatComposerDraftStorage, + StoredNativeChatComposerDraft +} from './native-chat-composer-draft-storage' + +const DATABASE_NAME = 'orca-native-chat-composer-drafts' +const DATABASE_VERSION = 1 +const DRAFTS = 'drafts' + +function settled(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +/** Resolves once the transaction is on disk; rejects when it aborts (a full disk included). */ +function committed(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error ?? new Error('draft write aborted')) + }) +} + +export function createIndexedDbNativeChatComposerDraftStorage( + factory: IDBFactory +): NativeChatComposerDraftStorage { + let opened: Promise | null = null + const database = (): Promise => { + opened ??= new Promise((resolve, reject) => { + const request = factory.open(DATABASE_NAME, DATABASE_VERSION) + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(DRAFTS)) { + request.result.createObjectStore(DRAFTS) + } + } + request.onsuccess = () => { + const db = request.result + // Why: a newer Orca upgrading the database, or the browser closing it, must not leave + // every later write failing against a dead connection. + db.onversionchange = () => db.close() + db.onclose = () => { + opened = null + } + resolve(db) + } + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('draft database is blocked')) + }).catch((error: unknown) => { + opened = null + throw error + }) + return opened + } + + const change = async (apply: (drafts: IDBObjectStore) => void): Promise => { + const transaction = (await database()).transaction(DRAFTS, 'readwrite') + const done = committed(transaction) + apply(transaction.objectStore(DRAFTS)) + // Why: commit now rather than when the task ends, so a quit right after still lands it. + transaction.commit?.() + await done + } + + return { + loadAll: async () => { + const drafts = (await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS) + const [keys, values] = await Promise.all([ + settled(drafts.getAllKeys()), + settled(drafts.getAll()) + ]) + const loaded = new Map() + if (Array.isArray(keys) && Array.isArray(values)) { + keys.forEach((key, index) => { + if (typeof key === 'string') { + loaded.set(key, values[index]) + } + }) + } + return loaded + }, + read: async (scopeKey) => + settled((await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS).get(scopeKey)), + write: (scopeKey, draft: StoredNativeChatComposerDraft) => + change((drafts) => drafts.put(draft, scopeKey)), + remove: (scopeKeys) => + change((drafts) => { + 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-journal.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts new file mode 100644 index 000000000000..7dde1cb4fcad --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts @@ -0,0 +1,215 @@ +// 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 { + 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 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_ADDITIONS_CHARS = 800_000 + +// 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) + return Array.isArray(entries) ? entries.flatMap(parseEntry) : [] + } catch { + return [] + } +} + +function writeEntries(entries: readonly JournalEntry[]): boolean { + try { + if (entries.length === 0) { + localStorage.removeItem(JOURNAL_KEY) + } else { + 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 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 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, + entries: NativeChatComposerDraftJournalSnapshot +): Set { + const changed = new Set() + 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 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) => isAddition(entry) || !changes.has(entry.scopeKey)) + 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) { + continue + } + added.push(entry) + used += size + } + if (added.length > 0) { + writeEntries([...kept, ...added]) + } +} + +/** 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 }] + 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 + * 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-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx new file mode 100644 index 000000000000..13e4bde85030 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -0,0 +1,722 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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' +import type * as AttachmentsHook from './use-native-chat-composer-attachments' +import type * as SendHook from './use-native-chat-structured-composer-send' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import { createMemoryNativeChatComposerDraftStorage } from './native-chat-composer-draft-storage' + +vi.mock('@/i18n/i18n', () => ({ 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 } +}) +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] + } + }) + } + ) +})) + +let storage = createMemoryNativeChatComposerDraftStorage() + +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(hostCommand = false): { + 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( + hostCommand + ? { handled: true, accepted: true, error: null } + : { handled: false, accepted: false, error: null } + ) + await dispatched + }) + } + } +} + +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 { + return storage.drafts.get(scopeKey) ?? null +} + +const loadedStores: { clearNativeChatComposerDraftsForTests: () => void }[] = [] + +/** A fresh renderer: module memory is gone, the drafts' storage is not. */ +async function loadHooks(): Promise<{ + draftHook: typeof DraftHook + attachmentsHook: typeof AttachmentsHook + sendHook: typeof SendHook +}> { + vi.resetModules() + const storageModule = await import('./native-chat-composer-draft-storage') + storageModule.setNativeChatComposerDraftStorageForTests(storage) + const store = await import('./native-chat-composer-draft-store') + loadedStores.push(store) + await store.hydrateNativeChatComposerDrafts() + return { + draftHook: await import('./use-native-chat-draft'), + 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, + transport?: NativeChatStructuredComposerTransport +): (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: () => {} + }) + const send = hooks.sendHook.useNativeChatStructuredComposerSend({ + agent: 'claude', + draftScopeKey: scopeKey, + imageAttachments: attachments.imageAttachments, + structuredTransport: transport, + isComposing: () => false, + clearSkillOrigin: () => {}, + setHistory: () => {}, + setDraft, + setCaret + }) + onRender({ draft, setDraft, attachments, send }) + return null + } +} + +beforeEach(() => { + localStorage.clear() + storage = createMemoryNativeChatComposerDraftStorage() + mocks.launchDrafts = {} +}) + +afterEach(async () => { + await unmount() + vi.useRealTimers() + for (const store of loadedStores.splice(0)) { + store.clearNativeChatComposerDraftsForTests() + } +}) + +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('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() + 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('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('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('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('does not bring back a sent image when a paste from a replaced composer is dropped late', 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 pending: { id?: string | null } = {} + await act(async () => { + pending.id = first.api?.attachments.beginPendingImageAttachment('data:image/png;base64,AA') + }) + 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() + + // 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('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('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 } }) + storage.drafts.set('tab-1:pane', { + 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('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 answer: () => void = () => {} + const answered = new Promise((resolve) => { + answer = resolve + }) + const restoreNativeChatPastes = vi.fn(async (paths: string[]) => { + await answered + 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 } + }) + storage.drafts.set('tab-1:pane', { + 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 () => { + answer() + await answered + }) + } 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 () => { + storage.drafts.set('tab-1:pane', { 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. + await loadHooks() + const otherDrafts = await import('./native-chat-draft-cache') + otherDrafts.writeNativeChatDraftCache('tab-1:pane', '') + await act(async () => { + await vi.waitFor(() => 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')) + otherDrafts.appendNativeChatDraftCache('tab-1:pane', 'theirs') + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(seen.api?.draft).toBe('mine, unsaved') + }) + + it('shows a composer its draft was refused, keeps it, and clears the mark once a retry lands', async () => { + const hooks = await loadHooks() + const { useNativeChatComposerDraftUnsaved } = await import('./use-native-chat-draft-unsaved') + const store = await import('./native-chat-composer-draft-store') + const seen: { api?: ComposerApi; unsaved?: boolean } = {} + const Draft = composer(hooks, (next) => (seen.api = next)) + function Marked({ scopeKey }: { scopeKey: string }): React.JSX.Element { + seen.unsaved = useNativeChatComposerDraftUnsaved(scopeKey) + return createElement(Draft, { scopeKey }) + } + await mount(createElement(Marked, { scopeKey: 'tab-1:pane' })) + + storage.refuseWrites = true + await act(async () => { + seen.api?.setDraft('typed on a full disk') + store.flushNativeChatComposerDrafts() + await store.nativeChatComposerDraftWritesSettled() + }) + expect(seen.unsaved).toBe(true) + expect(seen.api?.draft).toBe('typed on a full disk') + + storage.refuseWrites = false + await act(async () => { + window.dispatchEvent(new Event('pagehide')) + await store.nativeChatComposerDraftWritesSettled() + }) + expect(seen.unsaved).toBe(false) + expect(storedDraft('tab-1:pane')).toMatchObject({ text: 'typed on a full disk' }) + }) + + 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-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts new file mode 100644 index 000000000000..02de2aa92bf1 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -0,0 +1,159 @@ +// 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 { + replayNativeChatComposerDraftJournal, + snapshotNativeChatComposerDraftJournal, + type NativeChatComposerDraftJournalSnapshot +} 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 started = false +let failedLoads = 0 +let retryTimer: ReturnType | null = null + +/** The appends this load could not have read: made after it began reading, or never written. */ +function withAppends( + scopeKey: string, + 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 = 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, + 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, journal)) { + 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 + } + 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, readAtSequence) + 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 { + started = true + hydration ??= (async () => { + removeLegacyLocalStorageNativeChatComposerDrafts() + installNativeChatComposerDraftBroadcast() + // Why read here: storage applies changes in order, so this load reads every append made so far. + const readAtSequence = load.appendSequence + const journal = snapshotNativeChatComposerDraftJournal() + applyLoaded(await nativeChatComposerDraftStorage().loadAll(), readAtSequence, journal) + })().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) +} + +/** 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 + } + 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 new file mode 100644 index 000000000000..96ba8bc26afb --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-memory.ts @@ -0,0 +1,89 @@ +// The draft store's memory, shared by its API (the store module) and its persistence. Memory is +// the owner of every draft; storage only keeps a copy of it. + +import type { StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' + +// 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. +export type DraftRecord = StoredNativeChatComposerDraft & { readonly unsavedText?: string } + +/** A write or removal handed to storage and not yet confirmed; `draft` is null for a removal. */ +export type UnconfirmedDraftChange = { + readonly draft: StoredNativeChatComposerDraft | null + readonly at: number +} + +export const records = new Map() +// Changed in memory with no write issued yet; a refused write comes back here for the next flush. +export const dirtyScopes = new Set() +export const unconfirmed = new Map() +// Drafts whose last write storage refused; shown so the user knows they are held in memory only. +export const refusedScopes = new Set() +// Records read back from storage this run whose image files have not been checked yet. +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 + +/** 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 + 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, + appendSequence: 0, + 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. */ +export function nextSavedAt(): number { + lastSavedAt = Math.max(Date.now(), lastSavedAt + 1) + return lastSavedAt +} + +export function notifyScope(scopeKey: string): void { + scopeListeners.get(scopeKey)?.forEach((listener) => listener()) +} + +/** A local change not yet in storage, which a write from another window must not replace. */ +export function hasLocalChange(scopeKey: string): boolean { + return dirtyScopes.has(scopeKey) || unconfirmed.has(scopeKey) +} + +export function clearDraftMemoryForTests(): void { + records.clear() + dirtyScopes.clear() + unconfirmed.clear() + refusedScopes.clear() + 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 new file mode 100644 index 000000000000..cd4a11faf776 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -0,0 +1,326 @@ +// 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 AppendBeforeLoad, + type DraftAppend, + type DraftRecord, + type UnconfirmedDraftChange +} from './native-chat-composer-draft-memory' +import { + journalNativeChatComposerDraftChanges, + pruneNativeChatComposerDraftJournal +} from './native-chat-composer-draft-journal' +import { + nativeChatComposerDraftStorage, + parseStoredNativeChatComposerDraft, + type NativeChatComposerDraftImage, + type StoredNativeChatComposerDraft +} from './native-chat-composer-draft-storage' + +const PERSIST_DEBOUNCE_MS = 250 +const CHANNEL_NAME = 'orca-native-chat-composer-drafts' + +let flushTimer: ReturnType | null = null +let flushOnHideInstalled = false +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. */ +export function unavailableNativeChatComposerDraftImage( + image: NativeChatComposerDraftImage +): NativeChatComposerDraftImage { + return image.unavailableName === undefined + ? { id: image.id, path: '', unavailableName: basename(image.path) } + : image +} + +/** 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) && !isKeptLocalPaste(image) + ? unavailableNativeChatComposerDraftImage(image) + : image + ) + const text = saved.text === unsavedText ? '' : saved.text + if (text === '' && images.length === 0) { + return null + } + return { ...saved, text, images } +} + +function confirm(scopeKey: string, change: UnconfirmedDraftChange): void { + if (unconfirmed.get(scopeKey) === change) { + unconfirmed.delete(scopeKey) + if (refusedScopes.delete(scopeKey)) { + notifyScope(scopeKey) + } + } + pruneNativeChatComposerDraftJournal(scopeKey, change.at) + channel?.postMessage({ scopeKey }) +} + +function refuse(scopeKey: string, change: UnconfirmedDraftChange, error: unknown): void { + if (unconfirmed.get(scopeKey) !== change) { + return + } + unconfirmed.delete(scopeKey) + dirtyScopes.add(scopeKey) + 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) + } +} + +function persist(scopeKey: string): void { + const record = records.get(scopeKey) + const draft = record ? savedForm(record) : null + const change: UnconfirmedDraftChange = { draft, at: draft?.savedAt ?? nextSavedAt() } + unconfirmed.set(scopeKey, change) + const storage = nativeChatComposerDraftStorage() + const written = (draft ? storage.write(scopeKey, draft) : storage.remove([scopeKey])).then( + () => { + 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) + 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 { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + for (const scopeKey of dirtyScopes) { + dirtyScopes.delete(scopeKey) + persist(scopeKey) + } +} + +/** 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 refused draft included. */ +function journalUnconfirmed(): void { + flushNativeChatComposerDrafts() + journalNativeChatComposerDraftChanges(unconfirmed) +} + +function journalWhenHidden(): void { + if (document.visibilityState === 'hidden') { + journalUnconfirmed() + } +} + +function installFlushOnHide(): void { + if ( + flushOnHideInstalled || + typeof window === 'undefined' || + typeof window.addEventListener !== 'function' || + typeof document === 'undefined' + ) { + return + } + flushOnHideInstalled = true + window.addEventListener('pagehide', journalUnconfirmed) + window.addEventListener('beforeunload', journalUnconfirmed) + 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 appendedAt = records.get(scopeKey)?.savedAt ?? 0 + 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 + pruneNativeChatComposerDraftJournal(scopeKey, appendedAt) + channel?.postMessage({ scopeKey }) + return true + }, + (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) + } + return false + } + ) + track(scopeKey, 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. */ +export function persistNativeChatComposerDraft( + scopeKey: string, + 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) { + load.editedBeforeLoad.add(scopeKey) + } + if (persist === 'immediate') { + flushNativeChatComposerDrafts() + return + } + flushTimer ??= setTimeout(flushNativeChatComposerDrafts, PERSIST_DEBOUNCE_MS) +} + +/** A deletion also applies to drafts a load still in flight brings back. */ +export function deleteLoadingNativeChatComposerDraftsWhere( + matches: (scopeKey: string, draft: StoredNativeChatComposerDraft) => boolean +): void { + if (!load.hydrated) { + load.deletionsBeforeLoad.push(matches) + } +} + +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 +// replaces what this window holds unless this window has a change of its own not yet saved. +function adoptForeignChange(message: unknown): void { + const scopeKey = + typeof message === 'object' && message !== null && 'scopeKey' in message + ? message.scopeKey + : null + if (typeof scopeKey !== 'string' || hasLocalChange(scopeKey)) { + return + } + void nativeChatComposerDraftStorage() + .read(scopeKey) + .then((value) => { + if (hasLocalChange(scopeKey)) { + return + } + 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) + } else { + records.delete(scopeKey) + } + notifyScope(scopeKey) + }) + .catch(() => {}) +} + +export function installNativeChatComposerDraftBroadcast(): void { + if (channel || typeof BroadcastChannel === 'undefined') { + return + } + channel = new BroadcastChannel(CHANNEL_NAME) + channel.onmessage = (event) => adoptForeignChange(event.data) + // Why: Node's channel (unit tests) would otherwise keep the process alive. + if ('unref' in channel && typeof channel.unref === 'function') { + channel.unref() + } +} + +/** + * 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) { + await Promise.allSettled(inFlight) + } +} + +export function resetNativeChatComposerDraftPersistenceForTests(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer) + flushTimer = null + } + channel?.close() + channel = null + if (flushOnHideInstalled) { + flushOnHideInstalled = false + window.removeEventListener('pagehide', journalUnconfirmed) + window.removeEventListener('beforeunload', journalUnconfirmed) + document.removeEventListener('visibilitychange', journalWhenHidden) + } + inFlight.clear() + scopeWrites.clear() +} 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..83323315c838 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -0,0 +1,186 @@ +// The stored form of composer drafts and the storage they live in. Drafts are kept in their own +// store (IndexedDB), not localStorage, so they never compete with the send outbox for its quota +// and need no budget: every draft comes back. + +import type { JSONContent } from '@tiptap/react' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { createIndexedDbNativeChatComposerDraftStorage } from './native-chat-composer-draft-indexeddb' + +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 = { + readonly text: string + /** The editor's document for exactly `text`; dropped when the text changes without one. */ + readonly document?: JSONContent + readonly images: readonly NativeChatComposerDraftImage[] +} + +/** The workspace whose chat the draft was written in; its removal deletes the draft. */ +export type NativeChatComposerDraftOwner = { + readonly workspaceId: string + readonly executionHostId: ExecutionHostId +} + +export type StoredNativeChatComposerDraft = NativeChatComposerDraft & { + readonly savedAt: number + readonly owner?: NativeChatComposerDraftOwner +} + +/** Where drafts are kept. Every write and removal settles once storage has committed it, and + * rejects when storage refused it. */ +export type NativeChatComposerDraftStorage = { + loadAll(): Promise> + 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), + * and the fake those tests drive. */ +export function createMemoryNativeChatComposerDraftStorage(): NativeChatComposerDraftStorage & { + readonly drafts: Map + refuseWrites: boolean +} { + const drafts = new Map() + const storage = { + drafts, + refuseWrites: false, + loadAll: async () => new Map(drafts), + read: async (scopeKey: string) => drafts.get(scopeKey), + write: (scopeKey: string, draft: StoredNativeChatComposerDraft) => { + if (storage.refuseWrites) { + return Promise.reject(new DOMException('refused', 'QuotaExceededError')) + } + drafts.set(scopeKey, draft) + return Promise.resolve() + }, + remove: (scopeKeys: readonly string[]) => { + for (const scopeKey of scopeKeys) { + 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 +} + +let storage: NativeChatComposerDraftStorage | null = null + +export function nativeChatComposerDraftStorage(): NativeChatComposerDraftStorage { + storage ??= + typeof indexedDB === 'undefined' + ? createMemoryNativeChatComposerDraftStorage() + : createIndexedDbNativeChatComposerDraftStorage(indexedDB) + return storage +} + +export function setNativeChatComposerDraftStorageForTests( + next: NativeChatComposerDraftStorage | null +): void { + storage = next +} + +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, 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 } : {}) } +} + +function parseOwner(value: unknown): NativeChatComposerDraftOwner | undefined { + if (!isRecord(value)) { + return undefined + } + const { workspaceId, executionHostId } = value + const host = typeof executionHostId === 'string' ? parseExecutionHostId(executionHostId) : null + return typeof workspaceId === 'string' && host + ? { workspaceId, executionHostId: host.id } + : undefined +} + +/** A stored value as a draft, or null when it isn't one (a newer or damaged record). */ +export function parseStoredNativeChatComposerDraft( + value: unknown +): StoredNativeChatComposerDraft | null { + if (!isRecord(value)) { + return null + } + const { text, document, images, savedAt } = value + if (typeof text !== 'string' || typeof savedAt !== 'number' || !Array.isArray(images)) { + return null + } + const owner = parseOwner(value.owner) + return { + text, + ...(isEditorDocument(document) ? { document } : {}), + images: images.flatMap((image) => parseImage(image) ?? []), + savedAt, + ...(owner ? { owner } : {}) + } +} + +const LEGACY_LOCAL_STORAGE_PREFIX = 'orca:nativeChatComposerDraft:v1:' + +/** Drafts this change kept in localStorage before it moved them; never released, so they go. */ +export function removeLegacyLocalStorageNativeChatComposerDrafts(): void { + try { + const keys: string[] = [] + for (let position = 0; position < localStorage.length; position += 1) { + const key = localStorage.key(position) + if (key?.startsWith(LEGACY_LOCAL_STORAGE_PREFIX)) { + keys.push(key) + } + } + for (const key of keys) { + localStorage.removeItem(key) + } + } catch { + // No localStorage: nothing was kept there either. + } +} 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..e0da93ddcd10 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -0,0 +1,901 @@ +// @vitest-environment happy-dom +import { afterEach, beforeAll, 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 * as ComposerAttachments from './use-native-chat-composer-attachments' +import { MAX_PROMPT_BYTES } from '../../../../shared/rpc-contract/structured-agent-session-params' +import { + createMemoryNativeChatComposerDraftStorage, + type NativeChatComposerDraftStorage +} from './native-chat-composer-draft-storage' + +type DraftModules = { + drafts: typeof DraftCache + attachments: typeof ComposerAttachments + 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'), + attachments: await import('./use-native-chat-composer-attachments'), + store: await import('./native-chat-composer-draft-store') + } + loaded.push(modules) + if (options.hydrate !== false) { + await modules.store.hydrateNativeChatComposerDrafts() + } + return modules +} + +function storedDraft(scopeKey: string): Record | null { + return storage.drafts.get(scopeKey) ?? null +} + +const IMAGES = [ + { id: 'a-1', path: '/repo/shot.png' }, + { id: 'a-2', path: '/remote/repo/diagram.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 () => { + storage = createMemoryNativeChatComposerDraftStorage() + await reload() +}, 300_000) + +beforeEach(async () => { + localStorage.clear() + storage = createMemoryNativeChatComposerDraftStorage() + modules = await reload() +}) + +afterEach(() => { + vi.useRealTimers() + // Each loaded renderer closes its channel, so it hears nothing from the next test. + for (const instance of loaded.splice(0)) { + instance.store.clearNativeChatComposerDraftsForTests() + } +}) + +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) + vi.useRealTimers() + + 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')) + vi.useRealTimers() + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typed just before reload') + }) + + it('replays a change storage had not confirmed when the window went away', async () => { + const uncommitted = createMemoryNativeChatComposerDraftStorage() + // A write the backend never finished: its promise never settles, and nothing lands. + uncommitted.write = () => new Promise(() => {}) + const lost = await reload({ using: uncommitted }) + lost.drafts.writeNativeChatDraftCache('tab-1:pane', 'typed, then Orca quit') + window.dispatchEvent(new Event('pagehide')) + expect(uncommitted.drafts.size).toBe(0) + + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typed, then Orca quit') + await reloaded.store.nativeChatComposerDraftWritesSettled() + expect(storedDraft('tab-1:pane')?.text).toBe('typed, then Orca quit') + 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('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(() => {}) } + 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. + 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) + vi.useRealTimers() + + 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('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') + modules.store.flushNativeChatComposerDrafts() + + expect(modules.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + pasted, + IMAGES[0] + ]) + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'p-1', path: '', unavailableName: 'orca-paste-1-0f.png' }, + IMAGES[0] + ]) + }) + + 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' } + ]) + + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual([ + { id: 'p-1', path: '', unavailableName: 'orca-paste-1-0f.png' } + ]) + }) + + 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.drafts.set('tab-1:pane', { + 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' }], + { 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.drafts.set('tab-1:pane', { + 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() + + 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(storage.drafts.size).toBe(0) + + vi.advanceTimersByTime(1_000) + expect(storage.drafts.size).toBe(0) + vi.useRealTimers() + 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(storage.drafts.size).toBe(0) + }) + + it('loses nothing past the old budget: 200 drafts and over 5M characters all come back', async () => { + for (let index = 0; index < 200; index += 1) { + modules.drafts.writeNativeChatDraftCache(`tab-${index}:pane`, `${index}`.padEnd(26_000, 'd')) + } + modules.store.flushNativeChatComposerDrafts() + const returned = 'r'.repeat(MAX_PROMPT_BYTES) + modules.drafts.appendNativeChatDraftCache('tab-0:pane', returned) + + const reloaded = await reload() + for (let index = 0; index < 200; index += 1) { + expect(reloaded.drafts.readNativeChatDraftCache(`tab-${index}:pane`)).toHaveLength( + index === 0 ? 26_000 + 2 + returned.length : 26_000 + ) + } + const total = [...storage.drafts.values()].reduce((sum, draft) => sum + draft.text.length, 0) + expect(total).toBeGreaterThan(5_000_000) + }) + + it('shows a refused draft as not saved, retries it on the next flush, and clears that once it lands', async () => { + modules.attachments.appendNativeChatAttachmentCache('tab-1:pane', IMAGES) + modules.store.flushNativeChatComposerDrafts() + await modules.store.nativeChatComposerDraftWritesSettled() + const listener = vi.fn() + modules.store.subscribeToNativeChatComposerDraft('tab-1:pane', listener) + + storage.refuseWrites = true + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'refused') + modules.store.flushNativeChatComposerDrafts() + await modules.store.nativeChatComposerDraftWritesSettled() + expect(modules.store.isNativeChatComposerDraftUnsaved('tab-1:pane')).toBe(true) + expect(listener).toHaveBeenCalled() + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('refused') + expect(storedDraft('tab-1:pane')?.text).toBe('') + + storage.refuseWrites = false + // The next flush, here from the window hiding, retries it with no new change. + window.dispatchEvent(new Event('pagehide')) + await modules.store.nativeChatComposerDraftWritesSettled() + expect(modules.store.isNativeChatComposerDraftUnsaved('tab-1:pane')).toBe(false) + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('refused') + expect(reloaded.attachments.readNativeChatAttachmentCache('tab-1:pane')).toEqual(IMAGES) + }) + + 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('writes nothing when a change leaves the draft as it was', () => { + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'same') + modules.store.flushNativeChatComposerDrafts() + const write = vi.spyOn(storage, 'write') + + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'same') + modules.store.updateNativeChatComposerDraft('tab-1:pane', { images: [] }, 'immediate') + modules.store.flushNativeChatComposerDrafts() + expect(write).not.toHaveBeenCalled() + }) + + it('drops a stored record that is not a draft', async () => { + storage.drafts.set('broken', JSON.parse('{"text":5}')) + storage.drafts.set('tab-1:pane', { text: 'fine', images: [], savedAt: 1 }) + + const reloaded = await reload() + await reloaded.store.nativeChatComposerDraftWritesSettled() + expect(storage.drafts.has('broken')).toBe(false) + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('fine') + }) + + it('keeps what was typed before a slow load lands, and fills in the rest when it does', async () => { + storage.drafts.set('tab-1:pane', { text: 'saved earlier', images: [], savedAt: 1 }) + storage.drafts.set('tab-2:pane', { text: 'other chat', images: [], savedAt: 1 }) + let land: () => void = () => {} + const slow = { + ...storage, + loadAll: () => + new Promise>((resolve) => { + land = () => resolve(new Map(storage.drafts)) + }) + } + const reloaded = await reload({ using: slow, hydrate: false }) + // Startup gives up waiting; the load is still running. + await reloaded.store.waitForNativeChatComposerDrafts(1) + reloaded.drafts.writeNativeChatDraftCache('tab-1:pane', 'typed before the load') + + land() + await reloaded.store.hydrateNativeChatComposerDrafts() + expect(reloaded.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('typed before the load') + 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('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: () => + 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: flaky, hydrate: false }) + await reloaded.store.waitForNativeChatComposerDrafts(1) + 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() + 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 () => { + 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 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') + modules.store.flushNativeChatComposerDrafts() + await modules.store.nativeChatComposerDraftWritesSettled() + window.dispatchEvent(new Event('pagehide')) + await modules.store.nativeChatComposerDraftWritesSettled() + 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 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') ?? '' + // 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() + }) + + 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') + await vi.waitFor(() => + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('from the other tab') + ) + + other.drafts.writeNativeChatDraftCache('tab-1:pane', '') + await vi.waitFor(() => expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('')) + + vi.useFakeTimers() + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'mine, not saved yet') + other.drafts.appendNativeChatDraftCache('tab-1:pane', 'theirs') + vi.useRealTimers() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(modules.drafts.readNativeChatDraftCache('tab-1:pane')).toBe('mine, not saved yet') + }) + + it('records the workspace a draft was written in, and deletes a closed chat’s draft with it', async () => { + const owner = { workspaceId: 'repo::/wt', executionHostId: 'local' as const } + modules.store.setNativeChatComposerDraftOwnerResolver((scopeKey) => + scopeKey === 'agent-session:closed' ? owner : undefined + ) + modules.drafts.writeNativeChatDraftCache('agent-session:closed', 'unsent') + modules.drafts.writeNativeChatDraftCache('agent-session:other', 'kept') + modules.store.flushNativeChatComposerDrafts() + expect(storedDraft('agent-session:closed')?.owner).toEqual(owner) + + const reloaded = await reload() + reloaded.store.deleteNativeChatComposerDraftsOwnedBy({ ...owner, executionHostId: 'ssh:box' }) + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:closed')).toBe('unsent') + reloaded.store.deleteNativeChatComposerDraftsOwnedBy(owner) + expect(reloaded.drafts.readNativeChatDraftCache('agent-session:closed')).toBe('') + expect(storedDraft('agent-session:closed')).toBeNull() + expect(storedDraft('agent-session:other')?.text).toBe('kept') + }) + + 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') + }) + + 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' + 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 new file mode 100644 index 000000000000..b8ca9d888b00 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -0,0 +1,301 @@ +// 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 { + 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, + hasLocalChange, + load, + nextSavedAt, + notifyScope, + records, + refusedScopes, + scopeListeners, + unverifiedScopes, + type DraftAppend, + type DraftRecord +} from './native-chat-composer-draft-memory' +import { + deleteLoadingNativeChatComposerDraftsWhere, + persistNativeChatComposerDraft, + resetNativeChatComposerDraftPersistenceForTests +} from './native-chat-composer-draft-persistence' +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 { + flushNativeChatComposerDrafts, + isKeptLocalPaste, + nativeChatComposerDraftWriteSettled, + nativeChatComposerDraftWritesSettled, + unavailableNativeChatComposerDraftImage +} from './native-chat-composer-draft-persistence' +export { + hydrateNativeChatComposerDrafts, + isNativeChatComposerDraftLoadPending, + 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. */ + document?: JSONContent + images?: readonly NativeChatComposerDraftImage[] + /** Text shown but never saved while the draft still holds exactly it. */ + unsavedText?: string +} + +const EMPTY_DRAFT: DraftRecord = { text: '', images: [], savedAt: 0 } + +let resolveOwner: ((scopeKey: string) => NativeChatComposerDraftOwner | undefined) | null = null + +/** Names the workspace a scope's chat belongs to, so a new draft can record its owner. */ +export function setNativeChatComposerDraftOwnerResolver( + resolver: (scopeKey: string) => NativeChatComposerDraftOwner | undefined +): void { + resolveOwner = resolver +} + +/** 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) + } + } +} + +/** A draft read back from storage this run whose image files have not been checked yet. */ +export function isNativeChatComposerDraftUnverified(scopeKey: string): boolean { + return unverifiedScopes.has(scopeKey) +} + +export function markNativeChatComposerDraftVerified(scopeKey: string): void { + if (unverifiedScopes.delete(scopeKey)) { + notifyScope(scopeKey) + } +} + +/** A draft storage refused to save: it is kept in memory only until a later write lands. */ +export function isNativeChatComposerDraftUnsaved(scopeKey: string): boolean { + return refusedScopes.has(scopeKey) && records.has(scopeKey) +} + +function isEmptyDraft(draft: NativeChatComposerDraft): boolean { + return draft.text === '' && draft.images.length === 0 +} + +export function readNativeChatComposerDraft(scopeKey: string): NativeChatComposerDraft { + return records.get(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', + append?: DraftAppend +): void { + const current = records.get(scopeKey) ?? EMPTY_DRAFT + 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 && + sameNativeChatComposerDraftDocument(document, current.document) && + unsavedText === current.unsavedText && + sameNativeChatComposerDraftImages(images, current.images) + ) { + if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { + persistNativeChatComposerDraft(scopeKey, 'immediate') + } + return + } + if (isEmptyDraft({ text, images })) { + records.delete(scopeKey) + notifyScope(scopeKey) + persistNativeChatComposerDraft(scopeKey, 'immediate', append) + return + } + // Why stamped once: a conversation never moves to another workspace, so its owner stays true. + const owner = current.owner ?? resolveOwner?.(scopeKey) + const record: DraftRecord = { + text, + ...(document ? { document } : {}), + images: [...images], + savedAt: nextSavedAt(), + ...(owner ? { owner } : {}), + ...(unsavedText === undefined ? {} : { unsavedText }) + } + 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() + } +} + +function withAddition( + draft: T, + addition: NativeChatComposerDraftAddition, + options: { once?: boolean } = {} +): T { + const next = withNativeChatComposerDraftAddition(draft, addition, options) + return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) } +} + +/** + * 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, 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, + addition: NativeChatComposerDraftAddition +): boolean { + const before = records.get(scopeKey) + const current = readNativeChatComposerDraft(scopeKey) + const { text, images } = withAddition(current, addition) + updateNativeChatComposerDraft( + scopeKey, + { text, images, ...(text === current.text ? {} : { document: undefined }) }, + 'immediate', + (loaded) => withAddition(loaded, addition, { once: true }) + ) + const after = records.get(scopeKey) + 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) +} + +/** + * 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 || + !sameNativeChatComposerDraftImages(current.images, sent.images) + ) { + return false + } + updateNativeChatComposerDraft(scopeKey, { text: '', images: [] }, 'immediate') + return true +} + +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}` +} + +function deleteDraftsWhere( + matches: (scopeKey: string, draft: StoredNativeChatComposerDraft | undefined) => boolean +): void { + deleteLoadingNativeChatComposerDraftsWhere(matches) + for (const scopeKey of new Set([...records.keys(), ...scopeListeners.keys()])) { + const draft = records.get(scopeKey) + if (matches(scopeKey, draft)) { + records.delete(scopeKey) + notifyScope(scopeKey) + if (draft) { + persistNativeChatComposerDraft(scopeKey, 'immediate') + } + } + } +} + +/** Drops one scope's draft, for an owner that is gone for good. */ +export function deleteNativeChatComposerDraft(scopeKey: string): void { + deleteDraftsWhere((key) => key === scopeKey) +} + +/** The conversation a structured chat's draft belongs to, or null for a pane's draft. */ +export function structuredAgentSessionIdOfDraftScope(scopeKey: string): string | null { + return scopeKey.startsWith(STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX) + ? scopeKey.slice(STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX.length) + : null +} + +/** A pane key is `:`; the leaf is a UUID, while a tab id may hold ':' itself. */ +export function nativeChatDraftScopeTabId(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) && + nativeChatDraftScopeTabId(scopeKey) === tabId + ) +} + +/** Drops every draft written in a workspace the user removed, open and closed chats alike. */ +export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerDraftOwner): void { + deleteDraftsWhere( + (_scopeKey, draft) => + draft?.owner?.workspaceId === owner.workspaceId && + draft.owner.executionHostId === owner.executionHostId + ) +} + +export function clearNativeChatComposerDraftsForTests(): void { + clearDraftMemoryForTests() + resetNativeChatComposerDraftPersistenceForTests() + resetNativeChatComposerDraftLoadForTests() + setNativeChatComposerDraftStorageForTests(null) + resolveOwner = null +} 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 f25d5d863810..90029987cf44 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