diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2957c8eaa6e..a862d0d9395 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -315,6 +315,7 @@ jobs: . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and + . != "tests/e2e/ssh-emptied-worktree-reactivation.spec.ts" and . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and @@ -386,6 +387,7 @@ jobs: contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') || diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 19810c2f402..3e04b8c46f2 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -48,9 +48,9 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'main', 'codex'] }, { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, - // Allowed absent until they land; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true }, - { directory: ['src', 'main', 'provider-process'], mayBeAbsent: true } + { directory: ['src', 'main', 'provider-process'] }, + // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. + { directory: ['src', 'main', 'acp'], mayBeAbsent: true } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 1f4f37c01a9..a8985f0c014 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -32,12 +32,13 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ and provider-process/ may be absent until they land. + // Every lane that must exist; acp/ may be absent until it lands. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', + 'src/main/provider-process/provider-process-teardown.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -89,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(5) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) } ) @@ -128,7 +129,7 @@ describe('the default entry points', () => { } }) - // Retires the temporary flag: the PR that adds acp/ or provider-process/ must make it required. + // Retires the temporary flag: the PR that adds acp/ must make it required. it('lets only directories that have not landed yet be absent', () => { for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { expect( diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 6126e753b5d..cb9d07aec25 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -8,6 +8,7 @@ export const DOCKER_SSH_E2E_SPECS = [ 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', 'tests/e2e/ssh-docker-half-open-link.spec.ts', diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 64709cdbb43..72fbbb9d44f 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -60,6 +60,7 @@ const result = spawnSync( 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', 'tests/e2e/ssh-docker-half-open-link.spec.ts', diff --git a/config/update-translation.txt b/config/update-translation.txt index d1a85bab724..566189544c4 100644 --- a/config/update-translation.txt +++ b/config/update-translation.txt @@ -65,3 +65,45 @@ Method (reproducible): ko 색인화. R3 sign-off clean on all locales (R2 fixes byte-verified, full placeholder parity 0 mismatches, 0 missing keys). +## Scan 2026-10-05 + +- HEAD scanned: d3943c6d81 (origin/main; branch rebased onto it). Base: a15a5c8c1a6. + Window: a15a5c8c1a6 .. HEAD (~1008 commits). Note: origin/main was still advancing + during the scan; the one en key added in the last 13 commits (browser markup + `...tool.eraser`) already shipped translated upstream in all five locales. +- Gates before fix: catalog verify PASS (all five locales missing the SAME 87 keys), + coverage --check PASS (13 allowlisted), extraction PASS, runtime-catalog PASS. + So no unkeyed strings and en.json in sync; the gap was target-locale coverage only. +- Delta since base: 87 new en keys (csv editor x34, provider credit balance x11, + activity x31, jira assignee x3, workspace-delete change checks x5, nested-worktree + removal x10) plus 18 changed en values (Gemini CLI legacy, structured-chat copy/scope, + markdown notes, unverifiable -> "no recent update", onboarding casing, arrow spacing). + es had fully caught up to fr/ja/ko/zh, so all five shared the same 87-key gap. +- **11 of the 87 are intentionally untranslated**: the provider credit-balance keys + (`auto.components.status.bar.{StatusBar.*,tooltip.*,provider.extra.usage.section.*}`) + are pinned as sparse-fallback by `provider-credit-balance-locales.test.ts` (#25408), + which asserts every target catalog OMITS them and falls back to English at runtime. + Translated the remaining 76 new keys (x5) and left the 11 balance keys absent. +- Changed-value review: 15 of the 18 already matched the new English upstream; only 3 + were stale (AccountsPane Gemini legacy description + label, onboarding arrow spacing). +- Applied 76 new keys x5 via apply-translation-delta.mjs (pipeline repaired ja + open->オープン, zh 智能体->代理 xN, ko refreshFailed spacing) + 3 stale updates x5 via + the same repairTranslatedValue gate. Strictly additive: +380 new keys, 0 removed. +- Gates after fix: catalog verify PASS (all five 15268/15279; the 11 omissions are the + pinned balance keys), coverage --check PASS (13 allowlisted), extraction PASS, + runtime-catalog PASS, locale vitest suites PASS (34 files, 288 tests). +- Reviews (3 of max 4 rounds; round 0 = 5 parallel translators, then 5 reviewers/round): + R1 applied 11 fixes (es structuredCopy "Off"->"Si está desactivado", structuredScope + semicolon lowercase, 5f02e6fb21 review->"de revisión"; fr nestedRemoval pronoun + agreement; ja f80603d293 compound-word space + csv.indexProgress->インデックス作成中; + ko f80603d293 agent->에이전트 + structuredScope fallback + structuredCopy 지원->기반; + zh f80603d293 切换->交接/注释->笔记 + threadListOptionsActive->个生效). + R1 rejected 2 as repo-convention conflicts: ja TaskPage half-width 例: (28 existing + occurrences) and ko showMoreAgents (pinned Latin "agents" by + config/scripts/locale-ko-key-overrides.json, so no locale change). + R2 applied 3 (es structuredScope unified "chat de terminal"; zh structuredCopy + ->基于终端的 + activity hideAutomation/CLI-created re-add 工作区 to match ja/ko); + ko re-flagged the pinned override, rejected again. + R3 sign-off clean on all five locales (0 errors, 0 warns; placeholders byte-identical, + 0 stale changed keys, 0 generic-term regressions). + diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index c7eb43bf96f..1eeb8186ad9 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -133,9 +133,9 @@ describe('the turn Codex answered a send into but has not opened', () => { it('is the turn the latest armed send was answered into', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') echoes.arm('client-2') - echoes.bindTurn('client-2', 'thread-1', 'turn-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start') expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2') }) @@ -143,7 +143,7 @@ describe('the turn Codex answered a send into but has not opened', () => { it('is none once Codex opened that turn', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull() }) @@ -151,7 +151,7 @@ describe('the turn Codex answered a send into but has not opened', () => { it('is none once that turn ended, even with its send still armed for an echo', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') // A completed end leaves its unechoed send armed. expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([]) @@ -161,9 +161,9 @@ describe('the turn Codex answered a send into but has not opened', () => { it('skips a turn a wait left unopened, and still names an earlier one', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') echoes.arm('client-2') - echoes.bindTurn('client-2', 'thread-1', 'turn-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start') echoes.leftUnopened('thread-1', 'turn-2') @@ -174,7 +174,7 @@ describe('the turn Codex answered a send into but has not opened', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') echoes.arm('client-2') - echoes.bindTurn('client-2', 'thread-2', 'turn-2') + echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start') expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 1eee3bc9a0a..4ad6d58faed 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -1,5 +1,8 @@ import type { ProviderDiagnostic } from '../../shared/agent-session-failure' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalTurnJoin +} from '../../shared/agent-session-journal-types' /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ @@ -35,10 +38,15 @@ export type CodexDispatchEchoes = { /** Drops an armed send whose write never reached the provider. */ disarm: (clientMessageId: string) => void /** - * Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is - * read after it; a send that end settles is no longer armed. + * Binds a send to the turn Codex answered it into, and how it joined that turn. Returns that + * turn's end when the answer is read after it; a send that end settles is no longer armed. */ - bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null + bindTurn: ( + clientMessageId: string, + threadId: string, + turnId: string, + via: AgentJournalTurnJoin + ) => CodexTurnEnd | null /** The turn the latest armed send was answered into that is neither in `openTurnIds`, ended, nor * left unopened through a wait: one Codex has picked for the send but not opened. */ answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null @@ -52,7 +60,11 @@ export type CodexDispatchEchoes = { * completed, which echoes its pending input first, so one it never echoed waits for recovery. An * interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history. */ - endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] + endTurn: ( + threadId: string, + turnId: string, + end: CodexTurnEnd + ) => { clientMessageId: string; via: AgentJournalTurnJoin }[] /** Submission origin for this exact send, retained until its echo settles it. */ requestOrigin: (clientMessageId: string) => CodexDispatchRequestOrigin | null /** Highest causal sequence assigned to a dispatch in this session. */ @@ -64,7 +76,11 @@ export type CodexDispatchEchoes = { export function createCodexDispatchEchoes(): CodexDispatchEchoes { const armed = new Map< string, - { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } + { + requestedAt: number | null + sequence: number + turn?: { threadId: string; turnId: string; via: AgentJournalTurnJoin } + } >() const endedTurns = new Map() const unopenedTurns = new Set() @@ -101,12 +117,12 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, settle: (clientMessageId) => armed.delete(clientMessageId), disarm: (clientMessageId) => void armed.delete(clientMessageId), - bindTurn: (clientMessageId, threadId, turnId) => { + bindTurn: (clientMessageId, threadId, turnId, via) => { const entry = armed.get(clientMessageId) if (!entry) { return null } - entry.turn = { threadId, turnId } + entry.turn = { threadId, turnId, via } const end = endedTurns.get(turnKey(threadId, turnId)) ?? null if (end && settles(end)) { armed.delete(clientMessageId) @@ -141,10 +157,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } const settled = [...armed].flatMap(([clientMessageId, entry]) => entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn - ? [clientMessageId] + ? [{ clientMessageId, via: entry.turn.via }] : [] ) - for (const clientMessageId of settled) { + for (const { clientMessageId } of settled) { armed.delete(clientMessageId) } return settled diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 288e08b9e34..1c5a034fd63 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -130,7 +130,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } if (event.type === 'notification') { // Only an admitted turn end settles; a refused one settles on the retry that lands. - settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => + settleCodexSendsInEndedTurn(session, event, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) ) // After the admission check, so a refused frame is observed by the child records diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index a96499846bb..7669895937d 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,5 +1,6 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { + AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' @@ -97,7 +98,10 @@ export type CodexStructuredSessionAdapterDeps = { onDispatchSettledLate?: ( input: { sessionId: string; clientMessageId: string } & ( | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ + state: 'rejected' + answeredInTurn: AgentJournalAnsweredTurnIdentity + } & AgentJournalDispatchRejection) ) ) => void /** Codex reported its thread not running with no turn open: a send whose diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 38cc648f79d..2b6542a4333 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { @@ -20,11 +24,17 @@ async function turnEndRig() { Object.assign(codex.routes, turns.routes) const settlements: LateSettlement[] = [] const bodies: AgentJournalItemBody[] = [] + const turnRecords: AgentJournalItemIdentity[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements, sink: { - appendItem: (_identity, body) => bodies.push(body), + appendItem: (identity, body) => { + bodies.push(body) + if (body.kind === 'turn') { + turnRecords.push(identity) + } + }, appendTombstone: () => {}, publish: () => {} } @@ -46,7 +56,18 @@ async function turnEndRig() { const settledIds = () => settlements.map(({ clientMessageId }) => clientMessageId) const categoryOf = (settlement: LateSettlement | undefined) => settlement && 'state' in settlement ? classifyDispatchRejection(settlement).category : null - return { codex, turns, adapter, send, sendAndOpen, settlements, settledIds, categoryOf, bodies } + return { + codex, + turns, + adapter, + send, + sendAndOpen, + settlements, + settledIds, + categoryOf, + bodies, + turnRecords + } } describe('a Codex send its turn ended without echoing', () => { @@ -286,13 +307,82 @@ describe('a Codex send its turn ended without echoing', () => { }) }) +describe('the turn a withdrawn Codex send names', () => { + it('is the record of the turn it opened, when that turn is interrupted', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + + rig.turns.end('interrupted') + + expect(rig.turnRecords.length).toBeGreaterThan(0) + expect(new Set(rig.turnRecords.map(agentJournalItemKey)).size).toBe(1) + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } + }) + ]) + }) + + it('is the running turn for a send steered into it', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + await rig.send('client-2') + + rig.turns.end('interrupted') + + expect(rig.settlements.map((settlement) => [settlement.clientMessageId, settlement])).toEqual([ + [ + 'client-1', + expect.objectContaining({ answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } }) + ], + [ + 'client-2', + expect.objectContaining({ answeredInTurn: { turn: rig.turnRecords[0], via: 'steer' } }) + ] + ]) + }) + + it('is the ended turn whose end was read before the answer', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('interrupted') + release() + + await expect(sending).resolves.toMatchObject({ + state: 'rejected', + answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } + }) + }) + + it('is not named when its turn completes without echoing it: the send stays pending', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('completed') + release() + + await expect(sending).resolves.toEqual({ state: 'admitted' }) + await rig.sendAndOpen('client-2') + rig.turns.end('completed') + expect(rig.settlements).toEqual([]) + }) +}) + describe('a send bound to a turn', () => { it('dies with the settlement its turn end makes', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') - expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual(['client-1']) + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual([ + { clientMessageId: 'client-1', via: 'start' } + ]) expect(echoes.size).toBe(0) expect(echoes.settle('client-1')).toBe(false) }) @@ -300,20 +390,20 @@ describe('a send bound to a turn', () => { it('dies with its child, which forgets recorded turn ends too', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') echoes.endTurn('thread-2', 'turn-2', { status: 'interrupted' }) echoes.clear() expect(echoes.size).toBe(0) echoes.arm('client-2') - expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2')).toBeNull() + expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start')).toBeNull() }) it('is matched by thread as well as turn id', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') expect(echoes.endTurn('thread-2', 'turn-1', { status: 'interrupted' })).toEqual([]) expect(echoes.size).toBe(1) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 65700644933..376d9adb6e1 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -17,7 +17,9 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { AgentJournalAnsweredTurnIdentity } from '../../shared/agent-session-journal-types' import type { CodexTurnEnd } from './codex-structured-dispatch-echo' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import type { CodexSession } from './codex-structured-session-state' import { readCodexThreadId, @@ -41,6 +43,8 @@ export function codexDispatchRejection( export type CodexTurnEndSettlement = { clientMessageId: string state: 'rejected' + /** The turn Codex answered the send into, whose end settled it, and how the send joined it. */ + answeredInTurn: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection function errorDetail(params: unknown): ProviderDiagnostic | undefined { @@ -80,19 +84,27 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( session: Pick, - method: string, - params: unknown, + frame: { sessionId: string; method: string; params: unknown }, settle: (settlement: CodexTurnEndSettlement) => void ): void { - const turnId = readCodexTurnId(params) - const end = readCodexTurnEnd(method, params) - if (!turnId || !end || (readCodexThreadId(params) ?? session.threadId) !== session.threadId) { + const turnId = readCodexTurnId(frame.params) + const end = readCodexTurnEnd(frame.method, frame.params) + if ( + !turnId || + !end || + (readCodexThreadId(frame.params) ?? session.threadId) !== session.threadId + ) { return } const rejection = codexTurnEndRejection(end) - for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) { + const turn = codexTurnLifecycleIdentity(frame.sessionId, turnId) + for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( + session.threadId, + turnId, + end + )) { if (rejection) { - settle({ clientMessageId, state: 'rejected', ...rejection }) + settle({ clientMessageId, state: 'rejected', answeredInTurn: { turn, via }, ...rejection }) } } } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 7f0a2ec0c23..271def87607 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,5 +1,8 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalTurnJoin +} from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { @@ -8,6 +11,7 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { readCodexTurnId } from './codex-structured-thread-facts' import { codexRunningOrOpeningTurn, @@ -107,7 +111,7 @@ async function steerCodexTurn( host: CodexTurnHost, expectedTurnId: string, input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } -): Promise<{ turnId: string } | null> { +): Promise<{ turnId: string; via: 'steer' } | null> { try { const answer = await host.connection.request( 'turn/steer', @@ -119,7 +123,7 @@ async function steerCodexTurn( }, { timeoutMs: input.timeoutMs } ) - return { turnId: readCodexTurnId(answer) ?? expectedTurnId } + return { turnId: readCodexTurnId(answer) ?? expectedTurnId, via: 'steer' } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { return null @@ -141,7 +145,7 @@ export async function startCodexTurn( requestedAt?: number timeoutMs?: number } -): Promise<{ turnId: string | null } | false> { +): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> { // Armed before the write: the echo and `turn/started` can both land while the // response is in flight, and the start must snapshot this send in its frontier. if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { @@ -167,7 +171,7 @@ export async function startCodexTurn( }, { timeoutMs: input.timeoutMs } ) - return { turnId: readCodexTurnId(answer) } + return { turnId: readCodexTurnId(answer), via: 'start' } } /** @@ -178,10 +182,15 @@ export async function startCodexTurn( */ export async function dispatchCodexTurn( session: CodexTurnHost, - input: { clientMessageId: string; body: AgentJournalMessageItem; requestedAt?: number }, + input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + requestedAt?: number + }, timeoutMs: number | undefined ): Promise { - let answer: { turnId: string | null } | false + let answer: { turnId: string | null; via: AgentJournalTurnJoin } | false try { answer = await startCodexTurn(session, { ...input, timeoutMs }) } catch (error) { @@ -205,8 +214,22 @@ export async function dispatchCodexTurn( } // An answer read after the turn it names already ended is settled by that end. const endedFirst = answer.turnId - ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) + ? session.dispatchEchoes.bindTurn( + input.clientMessageId, + session.threadId, + answer.turnId, + answer.via + ) : null const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null - return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' } + return rejection && answer.turnId + ? { + state: 'rejected', + answeredInTurn: { + turn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId), + via: answer.via + }, + ...rejection + } + : { state: 'admitted' } } diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 8a7421b35c1..545732da4ff 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(39) + expect(PROTOCOL_VERSION).toBe(40) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 15b7cb8068c..1b77b8c20b5 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -22,7 +22,7 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(39) + expect(PROTOCOL_VERSION).toBe(40) expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38) expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) @@ -38,7 +38,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 38 }, (_, index) => index + 1) + Array.from({ length: 39 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 7f9f911c647..9a0cf8c6a6d 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable. -export const PROTOCOL_VERSION = 39 +// v40 rolls the #25130/#24636 shell-wrapper changes and the wider agent list (jcode, qoder-cn, +// dsb; resume claims for qoder-cn/qwen-code/cursor/jcode) into a fresh daemon; older owners stay attachable. +export const PROTOCOL_VERSION = 40 export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 @@ -34,7 +35,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index 4b72070bb42..3cffff8dec2 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -52,7 +52,7 @@ describe('DaemonPtyAdapter startup delivery', () => { await vi.advanceTimersByTimeAsync(299) expect(lastSubprocess.write).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) - expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n') + expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\r') expect(lastSpawnOpts).not.toEqual( expect.objectContaining({ startupCommandDelivery: 'shell-ready' }) ) @@ -82,7 +82,7 @@ describe('DaemonPtyAdapter startup delivery', () => { ) lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ') await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) - expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n') + expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\r') } ) @@ -102,6 +102,6 @@ describe('DaemonPtyAdapter startup delivery', () => { lastSubprocess._simulateData('\r\nuser@host $ ') await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) - expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\n`) + expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\r`) }) }) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 15e86a70a72..f490c5d5722 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -197,11 +197,9 @@ async function spawnAndPublishSession( // Diagnostics must never turn a live PTY into a failed create. } if (startupCommandWritten && opts.command) { - const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. session.write( buildStartupCommandSubmission(opts.command, { - submit, bracketedPasteSafe: shellReadySupported }) ) diff --git a/src/main/daemon/terminal-host-startup.test.ts b/src/main/daemon/terminal-host-startup.test.ts index c6817b1e758..830bf0c40bd 100644 --- a/src/main/daemon/terminal-host-startup.test.ts +++ b/src/main/daemon/terminal-host-startup.test.ts @@ -19,11 +19,8 @@ function mockSubprocess(): SubprocessHandle { } as SubprocessHandle } -// Why: Windows shells (PowerShell/cmd.exe) submit on CR, not LF. Without CR -// the startup command sits typed at the prompt but unexecuted — forcing the -// user to press Enter after "claude" (or a setup script) is injected. -// POSIX shells (bash/zsh) keep the LF behaviour. A caller-supplied terminator -// must not be doubled. +// Why: without CR (Enter) the startup command sits typed at the prompt but +// unexecuted (#23250). A caller-supplied terminator is replaced, not doubled. describe('TerminalHost startup command terminator', () => { const origPlatform = process.platform afterEach(() => { @@ -39,9 +36,11 @@ describe('TerminalHost startup command terminator', () => { it.each([ ['win32', 'claude', 'claude\r'], - ['darwin', 'claude', 'claude\n'], + ['darwin', 'claude', 'claude\r'], + ['linux', 'claude', 'claude\r'], ['win32', 'claude\r', 'claude\r'], - ['darwin', 'claude\n', 'claude\n'] + ['darwin', 'claude\n', 'claude\r'], + ['win32', 'claude\r\n', 'claude\r'] ])('submits startup with correct terminator on %s', async (platform, cmd, sent) => { Object.defineProperty(process, 'platform', { value: platform }) await host.createOrAttach({ @@ -128,6 +127,6 @@ describe('TerminalHost startup command delivery logging', () => { streamClient: { onData: vi.fn(), onExit: vi.fn() } }) ).resolves.toMatchObject({ isNew: true }) - expect(sub.write).toHaveBeenCalledWith(`codex${process.platform === 'win32' ? '\r' : '\n'}`) + expect(sub.write).toHaveBeenCalledWith('codex\r') }) }) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 7ffaadec06d..f7330b3525c 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -189,9 +189,7 @@ describe('TerminalHost', () => { lastSubprocess._onDataCb?.('\r\nuser@host $ ') await new Promise((r) => setTimeout(r, 40)) - expect(lastSubprocess.write).toHaveBeenCalledWith( - process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' - ) + expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\r') }) it('uses the short daemon settle path when marker and prompt arrive together', async () => { @@ -211,9 +209,7 @@ describe('TerminalHost', () => { expect(lastSubprocess.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) - expect(lastSubprocess.write).toHaveBeenCalledWith( - process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' - ) + expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\r') } finally { vi.useRealTimers() } @@ -242,9 +238,7 @@ describe('TerminalHost', () => { streamClient: { onData: vi.fn(), onExit: vi.fn() } }) - expect(lastSubprocess.write).toHaveBeenCalledWith( - process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' - ) + expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\r') }) it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { @@ -272,7 +266,8 @@ describe('TerminalHost', () => { const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] expect(written).not.toContain('\x1b[200~') - expect(written).toContain('line one\nline two') + // Why CR between the lines: without bracketed paste each break submits its own line. + expect(written).toContain('line one\rline two') }) it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts index 7ccb0386b9f..a89aca38ea5 100644 --- a/src/main/ipc/pty-login-shell-startup-commands.test.ts +++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts @@ -233,7 +233,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('\x1b]133;A\x07% ') await Promise.resolve() vi.runAllTimers() - expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n') + expect(mockProc.proc.write).toHaveBeenCalledWith('claude\r') } finally { vi.useRealTimers() } @@ -396,7 +396,7 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) await Promise.resolve() vi.runAllTimers() - expect(mockProc.proc.write).toHaveBeenCalledWith('printf "hello"\n') + expect(mockProc.proc.write).toHaveBeenCalledWith('printf "hello"\r') } finally { vi.useRealTimers() } diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index a2a6153e99a..f3b30321c55 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -278,7 +278,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.runAllTimers() - expect(mockProc.proc.write).toHaveBeenCalledWith(`${command}\n`) + expect(mockProc.proc.write).toHaveBeenCalledWith(`${command}\r`) expect(spawned.agentResumeUnavailable).toBeUndefined() } finally { vi.useRealTimers() diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts new file mode 100644 index 00000000000..bba80293967 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts @@ -0,0 +1,78 @@ +// How a stored dispatch row's answered turn reads onto its submission: a named turn, none stated, +// or absent on a row written before the field existed. + +import { describe, expect, it } from 'vitest' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +/** The submission after a send and one dispatch row, stored with the given extra keys. */ +function readAfter(dispatch: Partial>) { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + }) + applyJournalRow(state, { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'This message was withdrawn before the agent started it.', + ...base(2), + ...dispatch + }) + return state.submissions.get('cm_1') +} + +describe("a rejected dispatch row's answered turn", () => { + it('is the turn it names', () => { + expect(readAfter({ answeredInTurn: { turnItemId: 'turn-1', via: 'steer' } })).toMatchObject({ + answeredInTurn: { turnItemId: 'turn-1', via: 'steer' } + }) + }) + + it('is none when the row states none', () => { + expect(readAfter({ answeredInTurn: null })).toMatchObject({ answeredInTurn: null }) + }) + + it('is absent on a row written before the field existed', () => { + const submission = readAfter({}) + + expect(submission).toMatchObject({ dispatchState: 'rejected' }) + expect(submission).not.toHaveProperty('answeredInTurn') + }) + + it('reads as none, keeping the row, when it names a way of joining this build does not know', () => { + expect(readAfter({ answeredInTurn: { turnItemId: 'turn-1', via: 'resume' } })).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + }) + + it('reads as none, keeping the row, when it names no turn id', () => { + expect(readAfter({ answeredInTurn: { turnItemId: '', via: 'start' } })).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + }) + + it('is absent on any other dispatch state', () => { + const submission = readAfter({ + state: 'unknown', + answeredInTurn: { turnItemId: 'turn-1', via: 'start' } + }) + + expect(submission).toMatchObject({ dispatchState: 'unknown' }) + expect(submission).not.toHaveProperty('answeredInTurn') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index c7c2a671654..3625982a24f 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -6,6 +6,7 @@ import { type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalAnsweredTurn } from '../../../shared/agent-session-journal-types' import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import { @@ -34,6 +35,11 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if (row.state === 'rejected' && row.answeredInTurn !== undefined) { + submission.answeredInTurn = readAnsweredTurn(row.answeredInTurn) + } else { + delete submission.answeredInTurn + } submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts @@ -61,6 +67,19 @@ export function applyJournalDispatchRow( }) } +/** A stored answered turn. One malformed, or naming a way of joining this build does not know, is + * read as no turn: it was written knowing the field, so it is not an older row. */ +function readAnsweredTurn(value: unknown): AgentJournalAnsweredTurn | null { + if (typeof value !== 'object' || value === null) { + return null + } + const turnItemId = 'turnItemId' in value ? value.turnItemId : undefined + const via = 'via' in value ? value.via : undefined + return typeof turnItemId === 'string' && turnItemId && (via === 'start' || via === 'steer') + ? { turnItemId, via } + : null +} + /** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as * written, so the classifier still knows a fact was there without this build claiming what it * says. Shared with the queued-draft table, whose returned card mirrors its submission. */ diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 0828740ca6f..8c190196bdf 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -33,6 +33,7 @@ import { type QueuedMessageHoldReason, type QueuedMessageRow } from './queued-message-table' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { @@ -110,6 +111,7 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string + source: AgentSessionMessageSource }, receipt?: JournalOperationReceipt ): Promise { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index ed40d490951..36a7d7262e8 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -112,6 +112,17 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + // Every rejection states its turn, null for none, so a reader tells it from an older row. + ...(input.state === 'rejected' + ? { + answeredInTurn: input.answeredInTurn + ? { + turnItemId: agentJournalItemKey(input.answeredInTurn.turn), + via: input.answeredInTurn.via + } + : null + } + : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}), ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 847c99734a2..9d436e1eb54 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -160,6 +160,11 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact + /** On `rejected`: the turn a Codex send was answered into, and how it joined it, when that + * turn's end settled the send; null on every other rejection. Absent on other rows and on rows + * written before it. `via` stays a string: a newer build may write another. Older readers keep + * the key and ignore it; one this build cannot read is read as null, never dropping the row. */ + answeredInTurn?: { turnItemId: string; via: string } | null } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index cb86a7a8e5d..4232e805bbe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -1,5 +1,6 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { + AgentJournalAnsweredTurnIdentity, AgentJournalCursor, AgentJournalItemBody, AgentJournalItemIdentity, @@ -40,7 +41,10 @@ export type ResolveDispatchInput = { | { state: 'pending'; turnScope: AgentJournalTurnScope } /** `reason` is what released clients print, `rejection` what newer ones read: both from * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ + state: 'rejected' + answeredInTurn?: AgentJournalAnsweredTurnIdentity + } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } ) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 80e4e2c93e9..b00bdd36de2 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -25,6 +25,7 @@ export function applyJournalSubmission( reason: null, submittedAt: row.ts, resolvedAt: null, + submittedSequence: row.seq, ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), // A malformed stored link is dropped, never the row. ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts new file mode 100644 index 00000000000..37416378605 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -0,0 +1,171 @@ +// Each submission carries where the journal wrote its row. A rejected send's own row moves to its +// rejection, so this is the only journal-order record of where it was sent. Derived on every fold: a +// replay of the same rows gives the same value, and a history page carries it. A rejection a turn's +// end made also names that turn. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'look around' }] +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +/** A send accepted for later handover, handed over, then taken back. */ +async function sendHandedOverThenWithdrawn() { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const submitted = await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: BODY, + fence: 1, + handoverRecorded: true + }) + const handedOver = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const pending = { ...journal.submission('send-1') } + const withdrawn = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 1, + recovered: true + }) + return { journal, submitted, handedOver, pending, withdrawn } +} + +const TURN = { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' +} as const + +/** A send its turn ended without taking: the rejection names that turn. */ +async function sendWithdrawnByItsTurnEnd() { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: BODY, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + answeredInTurn: { turn: TURN, via: 'start' }, + fence: 1 + }) + return journal +} + +describe('the turn a rejected submission was answered into', () => { + it('is the turn record its rejection named, on the snapshot, a replay, and the page', async () => { + const journal = await sendWithdrawnByItsTurnEnd() + const turnItemId = agentJournalItemKey(TURN) + + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: { turnItemId, via: 'start' } + }) + expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ + expect.objectContaining({ answeredInTurn: { turnItemId, via: 'start' } }) + ]) + await journals.closeAll() + const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) + expect(replayed.submission('send-1')).toMatchObject({ + answeredInTurn: { turnItemId, via: 'start' } + }) + }) + + it('is stated as none on a take-back that names no turn, so it reads apart from an older row', async () => { + const { journal } = await sendHandedOverThenWithdrawn() + + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + expect(readAgentSessionHydrationPage(journal).submissions[0]).toMatchObject({ + answeredInTurn: null + }) + }) +}) + +describe("a submission's journal position", () => { + it('is its own row, which a take-back does not move', async () => { + const { journal, submitted, handedOver, pending, withdrawn } = + await sendHandedOverThenWithdrawn() + + expect(pending).toMatchObject({ submittedSequence: submitted.sequence }) + expect(handedOver.sequence).toBeGreaterThan(submitted.sequence) + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + submittedSequence: submitted.sequence + }) + // Anti-vacuous: the send's own row moved to its rejection, so only this field says where it was sent. + expect( + journal.snapshot().items.find((item) => item.itemId === agentJournalSubmissionKey('send-1')) + ?.sequence + ).toBe(withdrawn.sequence) + }) + + it('comes back the same from a replay of the stored rows', async () => { + const { journal, submitted } = await sendHandedOverThenWithdrawn() + await journals.closeAll() + + const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) + + expect(replayed.submission('send-1')).toMatchObject({ submittedSequence: submitted.sequence }) + expect(journal).not.toBe(replayed) + }) + + it('reaches a client on the history page', async () => { + const { journal, submitted } = await sendHandedOverThenWithdrawn() + + expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ + expect.objectContaining({ clientMessageId: 'send-1', submittedSequence: submitted.sequence }) + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index f9588b71ba3..d0306fa9467 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -51,7 +51,8 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p' + hostInstance: 'p', + source: { kind: 'user' } }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -100,7 +101,8 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p' + hostInstance: 'p', + source: { kind: 'user' } }) await expect( journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 5f9a95a53d2..60b613ea307 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -65,7 +65,8 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' + hostInstance: 'proc-1', + source: { kind: 'user' } }) await journal.appendSubmission( { @@ -172,7 +173,8 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' + hostInstance: 'proc-1', + source: { kind: 'user' } }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index a12630d6566..88caf16949a 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -77,7 +77,8 @@ async function handOffAndReject( messageId: 'draft-1', body, fingerprint, - hostInstance: 'p' + hostInstance: 'p', + source: { kind: 'user' } }) await journal.appendSubmission( { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index a2fa19a6a5c..5989a10e967 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -63,6 +63,7 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, + source: { kind: 'user' }, ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -495,7 +496,8 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' + hostInstance: 'proc-0', + source: { kind: 'user' } }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 3f9d0122fda..d19c2e09a2d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,7 +12,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'] + ['queued_sequence', 'INTEGER'], + ['source_json', 'TEXT'] ] /** @@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, + source_json TEXT, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index abad56fa6dd..a9c0ef7c70c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -22,6 +22,7 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { closeTestJournalHostDatabases, createTrackedJournalOpener @@ -81,7 +82,8 @@ async function queueDraft(journal: AgentSessionJournal, messageId: string, text messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' + hostInstance: 'proc-1', + source: { kind: 'user' } }) } @@ -174,6 +176,53 @@ describe('draft rows', () => { expect(journal.queuedMessages.list()).toHaveLength(1) }) + it("keeps who queued a card across reopen; a card with no readable sender is the person's", async () => { + const agent: AgentSessionMessageSource = { + kind: 'agent', + senders: [ + { + party: { + address: 'structworker_1', + terminalHandle: 'structworker_1', + orcaSessionId: null + } + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: 'd1', + messages: [{ messageId: 'm1', runId: 'r1', from: 'structworker_1' }] + } + } + const first = await open() + await first.queuedMessages.insert({ + messageId: 'agent-card', + body: message('You have 1 orchestration message. Run `orca orchestration check --run r1`.'), + fingerprint: 'fp-agent-card', + hostInstance: 'proc-1', + source: agent + }) + await queueDraft(first, 'before-the-column') + await queueDraft(first, 'unreadable') + await first.close() + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.prepare('UPDATE queued_messages SET source_json = NULL WHERE message_id = ?').run( + 'before-the-column' + ) + db.prepare('UPDATE queued_messages SET source_json = \'{"v":9}\' WHERE message_id = ?').run( + 'unreadable' + ) + db.close() + const reopened = await open() + expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.source])).toEqual([ + ['agent-card', agent], + ['before-the-column', { kind: 'user' }], + ['unreadable', { kind: 'user' }] + ]) + }) + it('drafts survive epoch replacement, which deletes only journal rows', async () => { const journal = await open() await queueDraft(journal, 'draft-1') diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index a2bd9c3e05a..1a82925f1f9 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -13,6 +13,11 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + readAgentSessionMessageSource, + serializeAgentSessionMessageSource, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredRejectionFact } from './journal-dispatch-reducer' @@ -59,10 +64,12 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null + /** Who it is from: the person, or another agent through Orca. */ + source: AgentSessionMessageSource } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, source_json' export function insertQueuedMessage( db: Database.Database, @@ -74,6 +81,7 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor + source: AgentSessionMessageSource now: number } ): QueuedMessageRow { @@ -84,7 +92,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -95,7 +103,8 @@ export function insertQueuedMessage( input.hostInstance, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence + input.queuedAt.sequence, + serializeAgentSessionMessageSource(input.source) ) return { sessionId: input.sessionId, @@ -113,7 +122,8 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt + queuedAt: input.queuedAt, + source: input.source } } @@ -295,6 +305,7 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { carried_from: string | null queued_epoch: string | null queued_sequence: number | null + source_json: string | null } let body: AgentJournalMessageItem try { @@ -333,10 +344,21 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null + : null, + source: storedSource(record.source_json) } } +function storedSource(json: string | null): AgentSessionMessageSource { + let stored: unknown = null + try { + stored = json === null ? null : JSON.parse(json) + } catch { + // An unreadable value is read as no value; the source reader decides what that means. + } + return readAgentSessionMessageSource(stored) +} + function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9c34c184104..9975701e704 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -12,6 +12,7 @@ import type { import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' import type { + AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, AgentJournalItemBody, AgentJournalMessageItem, @@ -176,8 +177,12 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - /** Words from `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + /** Words from `agentSessionFailureWords`, never written by hand. `answeredInTurn`: the turn the + * provider answered the send into, which ended before the answer was read. */ + | ({ + state: 'rejected' + answeredInTurn?: AgentJournalAnsweredTurnIdentity + } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1133342270d..bceb95e4dbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,6 +37,7 @@ import { setOptionPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' @@ -60,6 +61,9 @@ export function sendStructuredAgentSessionTurn( * Orchestration mail, a restart continuation and `agent.launch`'s host-sent * prompt never set it. */ userSend?: true + /** Host-local, never on the wire: who a host-side `queue-if-active` send queues for, recorded + * on its card. A client's send is always its person's (`userSend`). */ + source?: AgentMessageSource beforeRun?: () => void }, arrival?: Parameters[2] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts index ba0ab923126..3d38494fea9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -1,4 +1,7 @@ -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalAnsweredTurnIdentity, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -12,7 +15,10 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: string } & ( | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ + state: 'rejected' + answeredInTurn?: AgentJournalAnsweredTurnIdentity + } & AgentJournalDispatchRejection) | { state: 'unknown'; reason: string } ) ): Promise { @@ -43,6 +49,7 @@ export async function settleStructuredAgentSessionLateDispatch( state: 'rejected', reason: input.reason, rejection: input.rejection, + ...(input.answeredInTurn ? { answeredInTurn: input.answeredInTurn } : {}), fence } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f1271f4e339..00319720942 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -10,6 +10,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -30,6 +31,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } +type RigSendOptions = { internal?: true; source?: AgentMessageSource } export function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -140,16 +142,16 @@ export async function createQueuedMessageTestRig( } /** A client's send, as the `agentSession.send` RPC hands it to the host; - * `internal` is a host-side sender (orchestration mail, a restart continuation). */ - function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) { + * `internal` is a host-side sender (orchestration mail, a restart continuation), and `source` + * who it is from. */ + function send(text: string, delivery?: 'queue-if-active', options?: RigSendOptions) { const body = hostTestMessage(text) const clientOperationId = hostTestOperationId() const fields = { body, ...(delivery ? { delivery } : {}) } const result = host.send(QUEUED_RIG_CALLER, { envelope: envelope(fields, 'agentSession.send', clientOperationId), - body, - ...(delivery ? { delivery } : {}), - ...(options?.internal ? {} : { userSend: true as const }) + ...fields, + ...(options?.internal ? { source: options.source } : { userSend: true as const }) }) return { id: clientOperationId, result } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 8a7c5c4c3d3..eefd106b3ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -12,6 +12,7 @@ import { type AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { @@ -636,6 +637,31 @@ describe('/clear', () => { }) }) + it('carries who each card is from', async () => { + const notice = { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [] + } as const + const source: AgentMessageSource = { kind: 'agent', senders: [], orchestration: notice } + const working = await workingSend() + await send('pointer', 'queue-if-active', { internal: true, source }).result + await send('typed', 'queue-if-active').result + await stop() + await settleAccepted(working, 'a') + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(journal?.queuedMessages.list().map((row) => row.source)).toEqual([ + source, + { kind: 'user' } + ]) + }) + it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { const [firstId] = await pausedDrafts() const cleared = await clear(hostTestOperationId()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 248534097b8..d4c7c470f12 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -8,6 +8,10 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + USER_MESSAGE_SOURCE, + type AgentMessageSource +} from '../../../shared/agent-session-message-source' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionSendResult, @@ -191,6 +195,10 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + /** A person's send at a chat surface; it outranks any `source`. */ + userSend?: true + /** Who a host-side send is from. */ + source?: AgentMessageSource } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -231,7 +239,8 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredAgentSessionHostInstance(), + source: params.userSend ? USER_MESSAGE_SOURCE : (params.source ?? USER_MESSAGE_SOURCE) }, ctx.operationReceipt ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index ee32b3648a9..8554bb08154 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -117,7 +117,8 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId + carriedFrom: ctx.sessionId, + source: row.source }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index d411efc4239..9abdc713f0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -4,6 +4,7 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -16,6 +17,7 @@ export async function runQueueableStructuredAgentSessionSend( body: AgentJournalMessageItem delivery?: 'queue-if-active' userSend?: true + source?: AgentMessageSource }, immediate: () => Promise> ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 20cd3b298d5..a1bb644d84e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -519,7 +519,8 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' + hostInstance: 'proc-1', + source: { kind: 'user' } }) expect(journal.importPending).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 629d7e71790..a291943fc0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -231,6 +231,7 @@ export async function handOverSubmission( state: 'rejected', reason: outcome.reason, rejection: outcome.rejection, + ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), fence: ctx.fence } : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 10bea531533..4324e61cdf5 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -92,6 +92,19 @@ describe('Store', () => { expect(ui.dismissedUpdateVersion).toBeNull() }) + it.each([false, true])( + 'restores sidebarOpen=%s from disk without changing the right sidebar', + async (sidebarOpen) => { + const store = await createStore() + store.updateUI({ sidebarOpen, rightSidebarOpen: false }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getUI().sidebarOpen).toBe(sidebarOpen) + expect(reloaded.getUI().rightSidebarOpen).toBe(false) + } + ) + it('round-trips and normalizes the host-qualified manual repo order', async () => { const store = await createStore() store.updateUI({ diff --git a/src/main/pi/agent-status-extension-async-subagents.test.ts b/src/main/pi/agent-status-extension-async-subagents.test.ts index a9145cce874..89d5eebcf72 100644 --- a/src/main/pi/agent-status-extension-async-subagents.test.ts +++ b/src/main/pi/agent-status-extension-async-subagents.test.ts @@ -60,6 +60,32 @@ describe('Pi async subagent roster', () => { vi.useRealTimers() }) + it('settles again when an async child starts after the turn settled', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + await endTurn(harness) + startChild(harness, 'late-child', 'tool-call-1') + complete(harness, 'late-child') + await vi.advanceTimersByTimeAsync(0) + + expect(agentEndCount(harness)).toBe(2) + }) + + it("does not spend a run's done on a child that starts before it is posted", async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + let idle = false + const context = { isIdle: () => idle } + await harness.callHook('agent_start') + await harness.callHook('agent_end', {}, context) + startChild(harness, 'quick-child', 'tool-call-1') + complete(harness, 'quick-child') + await harness.callHook('tool_execution_start', { toolName: 'bash' }, context) + idle = true + await vi.advanceTimersByTimeAsync(1_000) + + expect(postedHookNames(harness).at(-1)).toBe('agent_end') + }) + it('settles after an async workflow whose awaited children never report completion', async () => { const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) await harness.callHook('agent_start') diff --git a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts index 5d89d31aec1..03408a3ba6b 100644 --- a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts +++ b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' +import { + createAgentStatusExtensionHarness, + type AgentStatusExtensionHarness +} from './agent-status-extension-test-harness' function postedHookNames(fetchMock: ReturnType): string[] { return fetchMock.mock.calls.map( @@ -152,3 +155,143 @@ describe('OMP agent_end contract', () => { } }) }) + +function ompSession(id: string, parentSession?: string) { + return { + sessionManager: { + getSessionId: () => id, + getSessionFile: () => `/sessions/${id}.jsonl`, + getHeader: () => ({ parentSession }) + } + } +} + +describe('OMP subagent settlement', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + async function lifecycle(harness: AgentStatusExtensionHarness, id: string, status: string) { + harness.emitPiEvent('task:subagent:lifecycle', { id, status }) + await vi.advanceTimersByTimeAsync(0) + } + + async function hook(harness: AgentStatusExtensionHarness, name: string) { + await harness.callHook(name) + await vi.advanceTimersByTimeAsync(0) + } + + it.each(OMP_RUNTIME_CASES)( + 'settles %s again when a child starts after the run ended', + async (_name, args) => { + const harness = createAgentStatusExtensionHarness(args) + + await hook(harness, 'agent_start') + await hook(harness, 'agent_end') + await lifecycle(harness, 'wake-1', 'started') + await lifecycle(harness, 'wake-1', 'completed') + + expect(postedHookNames(harness.fetchMock)).toEqual([ + 'agent_start', + 'agent_end', + 'agent_start', + 'agent_end' + ]) + } + ) + + it.each(OMP_RUNTIME_CASES)( + 'keeps %s working while a child outlives the next run', + async (_name, args) => { + const harness = createAgentStatusExtensionHarness(args) + + await hook(harness, 'agent_start') + await lifecycle(harness, 'helper', 'started') + await hook(harness, 'agent_end') + await hook(harness, 'agent_start') + await hook(harness, 'agent_end') + expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end') + + await lifecycle(harness, 'helper', 'completed') + expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end') + } + ) + + it.each(OMP_RUNTIME_CASES)( + 'keeps %s working while a late child outlives the next run', + async (_name, args) => { + const harness = createAgentStatusExtensionHarness(args) + + await hook(harness, 'agent_start') + await hook(harness, 'agent_end') + await lifecycle(harness, 'wake-1', 'started') + await hook(harness, 'agent_start') + await hook(harness, 'agent_end') + expect(postedHookNames(harness.fetchMock).at(-1)).not.toBe('agent_end') + + await lifecycle(harness, 'wake-1', 'completed') + expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end') + } + ) + + it('does not settle OMP when a child finishes mid-run', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + + await hook(harness, 'agent_start') + await lifecycle(harness, 'child-1', 'started') + await lifecycle(harness, 'child-1', 'completed') + + expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end') + }) + + it('settles a child woken before the resumed root has run a turn', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + + await harness.callHook('session_start', {}, ompSession('root')) + await lifecycle(harness, 'revived', 'started') + await lifecycle(harness, 'revived', 'completed') + + expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end']) + }) + + it("ignores children seen by an OMP task session's copy of the extension", async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + + await harness.callHook('agent_start', {}, ompSession('child', '/sessions/root.jsonl')) + await lifecycle(harness, 'grandchild', 'started') + await lifecycle(harness, 'grandchild', 'completed') + + expect(harness.fetchMock).not.toHaveBeenCalled() + }) + + it('does not settle a reload that lands while the root run is still in flight', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('session_start', {}, ompSession('root')) + await hook(harness, 'agent_start') + harness.reload() + + await lifecycle(harness, 'child-1', 'started') + await lifecycle(harness, 'child-1', 'completed') + expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end') + + await hook(harness, 'agent_end') + expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end') + }) + + it('keeps OMP pane ownership across an extension reload', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('session_start', {}, ompSession('root')) + await hook(harness, 'agent_start') + await lifecycle(harness, 'child-1', 'started') + await hook(harness, 'agent_end') + harness.reload() + expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end') + + await lifecycle(harness, 'child-1', 'completed') + + expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end') + }) +}) diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index a88490cf6ae..76eff628731 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -136,7 +136,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ...getPiSubagentRosterSetupSourceLines(), ...(kind !== 'pi' ? [ - " pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; resetPostQueue(); clearPendingAgentEndCheck() })" + " pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; lifecycleState.rootRunInFlight = false; resetPostQueue(); clearPendingAgentEndCheck() })" ] : []), ...(kind !== 'prime-agent' @@ -146,6 +146,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' lifecycleState.active.clear()', ' lifecycleState.exited?.clear()', ' lifecycleState.waiting = false', + ' lifecycleState.rootRunInFlight = false', ' resetPostQueue()', ' clearPendingAgentEndCheck()', ' updateRuntimeOmpSessionMetadata(ctx)', @@ -165,6 +166,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ...captureSessionMetadata, ' clearPendingAgentEndCheck()', ' lifecycleState.waiting = false', + ' lifecycleState.rootRunInFlight = true', ' runGeneration += 1', // Why: a turn cannot begin under a dialog holding input focus, so this is the one // boundary that can recover a modal whose close never arrived. @@ -287,6 +289,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' clearPendingAgentEndCheck()', ' return', ' }', + ' lifecycleState.rootRunInFlight = false', ' endedRunGeneration = runGeneration', ' if (isOmpRuntime()) {', ' postAgentEndOnce()', diff --git a/src/main/pi/agent-status-subagent-roster-source.ts b/src/main/pi/agent-status-subagent-roster-source.ts index 2901e5fe9c8..106d49a7533 100644 --- a/src/main/pi/agent-status-subagent-roster-source.ts +++ b/src/main/pi/agent-status-subagent-roster-source.ts @@ -5,7 +5,7 @@ export function getPiSubagentRosterSetupSourceLines(): string[] { return [ ' const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events', - ' const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set; exited?: Set; waiting: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set(), waiting: false }', + ' const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set; exited?: Set; waiting: boolean; ownsPane?: boolean; rootRunInFlight?: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set(), waiting: false }', ' if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState', ' if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) {', ' const listener = (event: unknown) => lifecycleState.onEvent?.(event)', @@ -23,8 +23,8 @@ export function getPiSubagentRosterSetupSourceLines(): string[] { ] } -// Expects post() and postAgentEndOnce() from the handler scope; the latter prunes -// exited runners before deciding whether children still hold the pane. +// Expects post(), the run generations and postAgentEndOnce() from the handler scope; +// the latter prunes exited runners before deciding whether children still hold the pane. export function getPiSubagentRosterEventSourceLines(): string[] { return [ // Why: a run that reports its own completion does so ~150ms after its runner exits; @@ -37,7 +37,20 @@ export function getPiSubagentRosterEventSourceLines(): string[] { " const id = typeof record.id === 'string' && record.id ? record.id : typeof record.runId === 'string' ? record.runId : ''", ' const status = forcedStatus ?? (event as { status?: unknown }).status', ' if (!id) return', - " if (status === 'started') { lifecycleState.active.add(id); post('agent_start'); return }", + // Why: each OMP task session runs its own copy on its own bus; only the pane's bus tracks children. + ' if (isOmpRuntime() && !lifecycleState.ownsPane) return', + " if (status === 'started') {", + ' lifecycleState.active.add(id)', + // Why: a child starting after the run's done owes a fresh done. Under OMP the same holds + // before the first turn of this factory run (a resumed root, or a reload), but only when no + // root run is in flight -- a reload mid-turn resets these counters while the root still works. + ' if (completionPostedGeneration === runGeneration || (isOmpRuntime() && runGeneration === 0 && !lifecycleState.rootRunInFlight)) {', + ' lifecycleState.waiting = true', + ' completionPostedGeneration = -1', + ' }', + " post('agent_start')", + ' return', + ' }', " if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return", ' lifecycleState.active.delete(id)', ' lifecycleState.exited?.delete(id)', diff --git a/src/main/pi/omp-session-status-owner-source.ts b/src/main/pi/omp-session-status-owner-source.ts index 9e29126f014..daf1a4078bf 100644 --- a/src/main/pi/omp-session-status-owner-source.ts +++ b/src/main/pi/omp-session-status-owner-source.ts @@ -55,6 +55,7 @@ export function getOmpSessionOwnerHandlerSourceLines(): string[] { ' function onStatus(name, handler): void {', ' pi.on(name, (event, ctx) => {', ' if (!ownsSessionStatus(ctx)) return', + ' lifecycleState.ownsPane = true', ' return handler(event, ctx)', ' })', ' }', diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 6a46261e793..e33a681ed8c 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -217,7 +217,7 @@ describe('LocalPtyProvider', () => { expect(mockProc.write).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(200) - expect(mockProc.write).toHaveBeenCalledWith(`${command}\n`) + expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`) } finally { vi.useRealTimers() } @@ -269,7 +269,7 @@ describe('LocalPtyProvider', () => { vi.advanceTimersByTime(1) await Promise.resolve() - expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\n") + expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\r") } finally { vi.useRealTimers() } @@ -298,7 +298,7 @@ describe('LocalPtyProvider', () => { vi.advanceTimersByTime(200) await Promise.resolve() - expect(mockProc.write).toHaveBeenCalledWith('printf ready\n') + expect(mockProc.write).toHaveBeenCalledWith('printf ready\r') } finally { vi.useRealTimers() } diff --git a/src/main/providers/local-pty-shell-ready-startup-command.test.ts b/src/main/providers/local-pty-shell-ready-startup-command.test.ts index da843c6e42a..9b7c50ef521 100644 --- a/src/main/providers/local-pty-shell-ready-startup-command.test.ts +++ b/src/main/providers/local-pty-shell-ready-startup-command.test.ts @@ -58,8 +58,8 @@ describe('writeStartupCommandWhenShellReady', () => { Object.defineProperty(process, 'platform', { value: origPlatform }) }) - it('appends LF on POSIX so bash/zsh submit the line', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) + it.each(['darwin', 'linux', 'win32'])('submits with CR on %s', async (platform) => { + Object.defineProperty(process, 'platform', { value: platform }) const proc = createMockProc() const ready = Promise.resolve() writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}) @@ -69,39 +69,23 @@ describe('writeStartupCommandWhenShellReady', () => { vi.advanceTimersByTime(30) await Promise.resolve() - expect(proc._writes).toEqual(['claude\n']) + expect(proc._writes).toEqual(['claude\r']) }) - it('appends CR on Windows so PowerShell/cmd.exe submit the line', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) + it('replaces a caller-supplied LF terminator with CR', async () => { const proc = createMockProc() const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}) + writeStartupCommandWhenShellReady(ready, proc, 'claude\n', () => {}) await ready - proc._emitData('\r\nPS> ') + proc._emitData('\r\nuser@host % ') vi.advanceTimersByTime(30) await Promise.resolve() expect(proc._writes).toEqual(['claude\r']) }) - it('does not re-append a submit byte if the command already ends in CR or LF', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - const proc = createMockProc() - const ready = Promise.resolve() - writeStartupCommandWhenShellReady(ready, proc, 'claude\n', () => {}) - - await ready - proc._emitData('\r\nPS> ') - vi.advanceTimersByTime(30) - await Promise.resolve() - - expect(proc._writes).toEqual(['claude\n']) - }) - it('keeps the no-prompt fallback conservative to avoid duplicate shell echo', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) const proc = createMockProc() const ready = Promise.resolve() writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {}) @@ -115,11 +99,10 @@ describe('writeStartupCommandWhenShellReady', () => { vi.advanceTimersByTime(150) await Promise.resolve() - expect(proc._writes).toEqual(['codex\n']) + expect(proc._writes).toEqual(['codex\r']) }) it('uses the short settle delay when marker scan already observed post-marker bytes', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) const proc = createMockProc() const ready = Promise.resolve({ postMarkerBytesObserved: true }) writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {}) @@ -131,12 +114,11 @@ describe('writeStartupCommandWhenShellReady', () => { vi.advanceTimersByTime(1) await Promise.resolve() - expect(proc._writes).toEqual(['codex\n']) + expect(proc._writes).toEqual(['codex\r']) }) // Why: multiline startup commands must be bracketed-paste wrapped (ESC[200~ … ESC[201~) so shells insert them literally instead of treating each LF as Enter. it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) const proc = createMockProc() const ready = Promise.resolve() const command = "claude '--dangerously-skip-permissions' 'line one\nline two'" @@ -149,11 +131,10 @@ describe('writeStartupCommandWhenShellReady', () => { vi.advanceTimersByTime(30) await Promise.resolve() - expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\n`]) + expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\r`]) }) it('leaves a single-line command on the raw submit path even when bracketed paste is safe', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) const proc = createMockProc() const ready = Promise.resolve() writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}, { @@ -165,14 +146,14 @@ describe('writeStartupCommandWhenShellReady', () => { vi.advanceTimersByTime(30) await Promise.resolve() - expect(proc._writes).toEqual(['claude\n']) + expect(proc._writes).toEqual(['claude\r']) }) it('does not bracket-wrap a multiline command when the shell lacks bracketed paste', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin' }) const proc = createMockProc() const ready = Promise.resolve() const command = 'echo one\necho two' + // Why CR between the lines: without bracketed paste each break submits its own line. // Why: bracketedPasteSafe defaults false, so keep the raw path to avoid echoing ESC[200~ on shells without bracketed paste. writeStartupCommandWhenShellReady(ready, proc, command, () => {}) @@ -181,6 +162,6 @@ describe('writeStartupCommandWhenShellReady', () => { vi.advanceTimersByTime(30) await Promise.resolve() - expect(proc._writes).toEqual([`${command}\n`]) + expect(proc._writes).toEqual(['echo one\recho two\r']) }) }) diff --git a/src/main/providers/local-pty-shell-ready-startup-command.ts b/src/main/providers/local-pty-shell-ready-startup-command.ts index bf7f73c19d7..5fefb5db5fa 100644 --- a/src/main/providers/local-pty-shell-ready-startup-command.ts +++ b/src/main/providers/local-pty-shell-ready-startup-command.ts @@ -46,12 +46,9 @@ export function writeStartupCommandWhenShellReady( postReadyTimer = null } // Why: run in the same interactive shell (not `shell -c`) so the session survives after the agent exits. - // Why CR on Windows: PSReadLine/cmd.exe submit on `\r`, not LF; POSIX treats either as Enter under ICRNL. - const submit = process.platform === 'win32' ? '\r' : '\n' // Why: single write after the ready barrier avoids incremental-paste char drops; multiline is bracketed-paste wrapped so newlines don't submit early. proc.write( buildStartupCommandSubmission(startupCommand, { - submit, bracketedPasteSafe: options.bracketedPasteSafe === true }) ) diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index f682dfc7ce2..ac765825e69 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -113,7 +113,7 @@ async function renderPreamble(worker: 'chat' | 'terminal'): Promise { /** The turn text the structured lane sends a chat for one message on `mailbox`. */ async function renderChatPointer(mailbox: string): Promise { const texts: string[] = [] - db.insertMessage({ from: 'term_peer', to: mailbox, subject: 'hi' }) + const message = db.insertMessage({ from: 'term_peer', to: mailbox, subject: 'hi' }) const delivery = new OrchestrationStructuredMailboxPointerDelivery({ getDb: () => db, getMessageWaiters: () => undefined, @@ -121,7 +121,7 @@ async function renderChatPointer(mailbox: string): Promise { // The runtime's wiring of the structured lane. getCliCommand: localOrchestrationCliCommand, host: { - readGateFacts: async () => ({ turnRunning: false, awaitingHuman: false, submissions: [] }), + readSessionFacts: async () => ({ submissions: [] }), currentFence: () => 1, send: async (input) => { for (const block of input.body.blocks) { @@ -133,6 +133,8 @@ async function renderChatPointer(mailbox: string): Promise { }) delivery.deliverForHandle(mailbox) await vi.waitFor(() => expect(texts).toHaveLength(1)) + // Read, as the agent's `check` reads it, so this mailbox's next mail is pointed too. + db.markAsRead([message.id]) return texts[0]! } diff --git a/src/main/runtime/orchestration/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts index 9e34634ff3e..6909ac7cf65 100644 --- a/src/main/runtime/orchestration/orchestration-caller-identity.ts +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -2,23 +2,10 @@ import type { RunRow } from './types' import { isEquivalentPaneKey } from './db/pane-key-match' import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' +import type { OrchestrationPartyIdentity } from '../../../shared/orchestration-party-identity' -/** - * Who an orchestration caller is, as Run binding and mail routing match it. - * - * A PTY agent is its terminal: a handle and a pane key, no Orca session id. An agent that is a - * structured session is its Orca session id, addressed as `orca_session_id:`; a structured worker also - * has the handle and pane key it was minted, and an ordinary chat has neither. Methods pass this - * through whole and never branch on which fields are set; the lookups below own that. - */ -export type OrchestrationCallerIdentity = Readonly<{ - /** Mailbox address the caller sends from and reads: its terminal handle, else its session address. */ - address: string - terminalHandle: string | null - paneKey: string | null - /** The bare Orca session id the caller is addressed by; mail spells it `orca_session_id:`. */ - orcaSessionId: OrcaSessionId | null -}> +/** Who an orchestration caller is; shared so a queued message can name its sender the same way. */ +export type OrchestrationCallerIdentity = OrchestrationPartyIdentity /** The part of a caller a Run binding stores and matches. */ export type OrchestrationCoordinatorKey = Pick< diff --git a/src/main/runtime/orchestration/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index 5eb76ee1a55..0f92d95cee8 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -2,6 +2,7 @@ // host's own admission: a fingerprint over other fields than the send carries is refused there. import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { createQueuedMessageTestRig, eventually, @@ -20,6 +21,25 @@ import { let rig: QueuedMessageTestRig +const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { + address: 'term_peer', + terminalHandle: 'term_peer', + orcaSessionId: null + } + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'dispatch:d1', + dispatchId: 'd1', + messages: [{ messageId: 'm1', runId: 'r1', from: 'term_peer' }] + } +} + beforeEach(async () => { rig = await createQueuedMessageTestRig() }) @@ -36,7 +56,12 @@ function sendTurn( host, sessionId: SESSION, callerKey: 'trusted-local:orchestration:d1', - turn: { body: hostTestMessage('mail'), delivery, operationId, expectedRuntimeFence: 1 } + turn: { + body: hostTestMessage('mail'), + operationId, + expectedRuntimeFence: 1, + ...(delivery === 'queue' ? { delivery, source: MAIL_SOURCE } : { delivery }) + } }) } @@ -47,6 +72,15 @@ describe('sendAgentTurn through the real host', () => { kind: 'queued', queued: { position: 1, state: 'waiting' } }) + // Stored with the card, read back whole: who it is from survives the round trip. + expect( + rig.host + .collaboratorsForTests() + .sessions.get(SESSION) + ?.journal.queuedMessages.list() + .map(({ state, source }) => ({ state, source })) + ).toEqual([{ state: 'waiting', source: MAIL_SOURCE }]) + // Shown in the chat's queue like the person's own card. expect(await rig.drafts()).toMatchObject([{ state: 'waiting' }]) }) diff --git a/src/main/runtime/orchestration/send-agent-turn.test.ts b/src/main/runtime/orchestration/send-agent-turn.test.ts index c018f27d28b..599ef009ae5 100644 --- a/src/main/runtime/orchestration/send-agent-turn.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' import { dispatchPreambleSendOptions } from './preamble' import { @@ -52,6 +53,17 @@ function structuredHost(answer: HostSendAnswer, settled?: AgentJournalSubmission return { host, send, waitForSendSettlement } } +const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [], + orchestration: { + message: 'mail-notice', + mailbox: 'dispatch:d1', + dispatchId: 'd1', + messages: [{ messageId: 'm1', runId: 'r1', from: 'term_peer' }] + } +} + const turn: StructuredSessionTurn = { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, delivery: 'now', @@ -142,7 +154,7 @@ describe('sendAgentTurn to a structured session', () => { }) ) await expect( - sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue' })) + sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE })) ).resolves.toEqual({ kind: 'queued', clientMessageId: 'op-1', @@ -158,7 +170,9 @@ describe('sendAgentTurn to a structured session', () => { payloadFingerprint: hostFingerprint({ body: turn.body, delivery: 'queue-if-active' }) }, body: turn.body, - delivery: 'queue-if-active' + delivery: 'queue-if-active', + // Host-local: who the card is from rides beside the envelope, outside its fingerprint. + source: MAIL_SOURCE } ) expect(fake.waitForSendSettlement).not.toHaveBeenCalled() @@ -172,7 +186,7 @@ describe('sendAgentTurn to a structured session', () => { }) ) await expect( - sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue' })) + sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE })) ).resolves.toMatchObject({ kind: 'queued', queued: { state: 'returned' } }) }) }) diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index 0a1921b96ba..340bcc82e87 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -17,6 +17,7 @@ import { type AgentSessionQueuedSendReceipt } from '../../../shared/agent-session-wire' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' import { structuredAgentSessionMessageSendMutation } from '../../../shared/structured-agent-session-send-mutation' import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' @@ -36,11 +37,14 @@ export type StructuredAgentTurnHost = Pick< export type StructuredSessionTurn = { body: AgentJournalMessageItem - delivery: AgentTurnDelivery /** Reused on a retry, so the host replays its recorded answer instead of sending twice. */ operationId: string expectedRuntimeFence: number -} +} & ( + | { delivery: 'now' } + /** A queued card records who it is from. */ + | { delivery: 'queue'; source: AgentMessageSource } +) export type StructuredSessionTurnSend = { kind: 'structured-session' @@ -123,15 +127,17 @@ async function sendStructuredSessionTurn( send: StructuredSessionTurnSend ): Promise { const { turn } = send + const message = structuredAgentSessionMessageSendMutation({ + sessionId: send.sessionId, + clientOperationId: turn.operationId, + expectedRuntimeFence: turn.expectedRuntimeFence, + body: turn.body, + delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined + }) const result = await send.host.send( { callerKey: send.callerKey }, - structuredAgentSessionMessageSendMutation({ - sessionId: send.sessionId, - clientOperationId: turn.operationId, - expectedRuntimeFence: turn.expectedRuntimeFence, - body: turn.body, - delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined - }) + // The source is host-local and outside the fingerprint: a retry under the same id replays. + turn.delivery === 'queue' ? { ...message, source: turn.source } : message ) if (!result.ok) { return { kind: 'refused', refusal: result.refusal } diff --git a/src/main/runtime/orchestration/structured-mail-source.test.ts b/src/main/runtime/orchestration/structured-mail-source.test.ts new file mode 100644 index 00000000000..f6f394d722e --- /dev/null +++ b/src/main/runtime/orchestration/structured-mail-source.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { structuredMailSource } from './structured-mail-source' + +const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' + +describe('who delivered mail is from', () => { + it("names each sender once, without the pane key that would open its mailbox, and each message's own sender", () => { + // No database: a terminal handle and a session address name their party by themselves. + const source = structuredMailSource({ + db: null, + mailboxHandle: 'run:r1', + dispatchId: null, + batch: [ + { id: 'm1', from_handle: 'term_a', run_id: 'r1' }, + { id: 'm2', from_handle: `orca_session_id:${SESSION}`, run_id: 'r2' }, + { id: 'm3', from_handle: 'term_a', run_id: 'r1' } + ] + }) + expect(source).toEqual({ + kind: 'agent', + senders: [ + { party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } }, + { + party: { + address: `orca_session_id:${SESSION}`, + terminalHandle: null, + orcaSessionId: SESSION + } + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [ + { messageId: 'm1', runId: 'r1', from: 'term_a' }, + { messageId: 'm2', runId: 'r2', from: `orca_session_id:${SESSION}` }, + { messageId: 'm3', runId: 'r1', from: 'term_a' } + ] + } + }) + }) +}) diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts new file mode 100644 index 00000000000..ad26c3b8be0 --- /dev/null +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -0,0 +1,53 @@ +/** + * Who the mail a chat is pointed at is from: every distinct sender, named the + * way orchestration names a party, and each message's own sender and records. The run, dispatch + * and message ids join back to orchestration's own rows while those exist. + */ + +import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import type { + AgentMessageSource, + AgentMessageSender +} from '../../../shared/agent-session-message-source' +import type { MessageRow, OrchestrationDb } from './db' +import { resolveOrchestrationParty } from './orchestration-party' + +export type MailSourceMessage = Pick + +export function structuredMailSource(input: { + db: OrchestrationDb | null + mailboxHandle: string + dispatchId: string | null + batch: readonly MailSourceMessage[] +}): AgentMessageSource { + const senders = new Map() + for (const { from_handle: address } of input.batch) { + if (!senders.has(address)) { + senders.set(address, { party: senderParty(address, input.db) }) + } + } + return { + kind: 'agent', + senders: [...senders.values()], + orchestration: { + message: 'mail-notice', + mailbox: input.mailboxHandle, + dispatchId: input.dispatchId, + messages: input.batch.map((message) => ({ + messageId: message.id, + runId: message.run_id, + from: message.from_handle + })) + } + } +} + +function senderParty(address: string, db: OrchestrationDb | null): AgentMessageSender['party'] { + try { + const { paneKey: _credential, ...party } = resolveOrchestrationParty(address, db) + return party + } catch { + // A worker this host lost the identity of: what the address itself says. + return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) } + } +} diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 9b40bc08b0d..46090094c0d 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { OrchestrationStructuredMailboxPointerDelivery, type StructuredMailboxPointerHost @@ -9,7 +8,6 @@ import { structuredPointerBatchFingerprint, type StructuredPointerSubmission } from './structured-pointer-operation-id' -import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' const IDENTITY: StructuredWorkerIdentity = { @@ -22,63 +20,12 @@ const IDENTITY: StructuredWorkerIdentity = { hostScope: { kind: 'local', hostId: 'local' } } -function idleJournal(): AgentJournalRenderItem[] { - return [ - { - itemId: 'i1', - observedAt: 1, - body: { kind: 'status', text: 'done', turnLifecycle: { state: 'completed', turnId: 't1' } } - } as unknown as AgentJournalRenderItem - ] -} - -function runningJournal(): AgentJournalRenderItem[] { - return [ - { - itemId: 'i1', - observedAt: 1, - body: { kind: 'status', text: 'working', turnLifecycle: { state: 'running', turnId: 't1' } } - } as unknown as AgentJournalRenderItem - ] -} - -/** What a worker's journal looks like once it has finished a substantial turn: history, and no - * turnLifecycle row anywhere, because settlement tombstones it. */ -function settledLongJournal(): AgentJournalRenderItem[] { - return Array.from( - { length: 120 }, - (_unused, index) => - ({ - itemId: `tool-${index}`, - observedAt: index, - body: { kind: 'tool-call', name: 'Bash', input: {}, state: 'completed' } - }) as unknown as AgentJournalRenderItem - ) -} - -/** A prompt raised at the very start of a long turn, far outside any bounded tail window. */ -function staleAttentionJournal(): AgentJournalRenderItem[] { - return [...attentionJournal(), ...settledLongJournal()] -} - -function attentionJournal(): AgentJournalRenderItem[] { - return [ - { - itemId: 'i1', - observedAt: 1, - body: { - kind: 'question', - question: 'which?', - options: [], - resolution: { state: 'pending' } - } - } as unknown as AgentJournalRenderItem - ] -} - function harness(options: { - journal: AgentJournalRenderItem[] | null + /** False: the session cannot be read (not attached). */ + attached?: boolean dispatchState?: 'accepted' | 'rejected' | 'unknown' + /** The chat was busy: its queue holds the pointer as a card. */ + queued?: true /** The coordinator of this worker's Run is mid-batch: it checked and has not acked yet. */ outstandingRunDelivery?: boolean outstandingOwnDelivery?: boolean @@ -88,14 +35,24 @@ function harness(options: { }) { const mailbox = options.mailbox ?? 'dispatch:d1' const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId - let journal = options.journal + let attached = options.attached ?? true // The session's recorded sends, as its journal reports them. let submissions: StructuredPointerSubmission[] = [] - const markAsDelivered = vi.fn() - const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => ({ - kind: 'sent' as const, - state: options.dispatchState ?? ('accepted' as const) - })) + // The mailbox's unread mail; a pointed message is no longer selected for a pointer. + const mail = [ + { id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' } + ] + const pointed = new Set() + const markAsDelivered = vi.fn((ids: string[]) => { + for (const id of ids) { + pointed.add(id) + } + }) + const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => + options.queued + ? { kind: 'queued' as const } + : { kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) } + ) const sendMock = vi.mocked(send) const stored = new Map() const db = { @@ -103,7 +60,7 @@ function harness(options: { hasOutstandingMailboxDelivery: (handle: string) => ((options.outstandingRunDelivery ?? false) && handle.startsWith('run:')) || ((options.outstandingOwnDelivery ?? false) && !handle.startsWith('run:')), - getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], + getUndeliveredUnreadMessages: () => mail.filter((message) => !pointed.has(message.id)), markAsDelivered, getStructuredPointerOperation: (key: string) => stored.get(key), putStructuredPointerOperation: (row: StructuredPointerOperationRow) => @@ -117,8 +74,7 @@ function harness(options: { mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, getCliCommand: () => 'orca-dev', host: { - readGateFacts: async () => - journal === null ? null : { ...structuredSessionGateFacts(journal), submissions }, + readSessionFacts: async () => (attached ? { submissions } : null), currentFence: () => 4, send } @@ -128,8 +84,11 @@ function harness(options: { markAsDelivered, send: sendMock, stored, - setJournal: (next: AgentJournalRenderItem[] | null) => { - journal = next + setAttached: (next: boolean) => { + attached = next + }, + receive: (id: string, sequence: number) => { + mail.push({ id, type: 'status', sequence, from_handle: 'term_coord', run_id: 'run_1' }) }, setSubmissions: (next: StructuredPointerSubmission[]) => { submissions = next @@ -141,13 +100,13 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) describe('structured mailbox pointer delivery', () => { it('claims only mailboxes whose assignee is a structured worker', () => { - const { delivery } = harness({ journal: idleJournal() }) + const { delivery } = harness({}) expect(delivery.deliverForHandle('dispatch:d1')).toBe(true) expect(delivery.deliverForHandle('run:run_1')).toBe(false) }) it('sends the pointer as a turn and consumes mail on an accepted dispatch', async () => { - const { delivery, markAsDelivered, send } = harness({ journal: idleJournal() }) + const { delivery, markAsDelivered, send } = harness({}) delivery.deliverForHandle('dispatch:d1') await flush() expect(send).toHaveBeenCalledTimes(1) @@ -157,7 +116,6 @@ describe('structured mailbox pointer delivery', () => { it('nudges through the worker`s own handle for direct peer mail outside a dispatch', async () => { const { delivery, send, markAsDelivered } = harness({ - journal: idleJournal(), mailbox: IDENTITY.handle, dispatchId: null }) @@ -176,7 +134,6 @@ describe('structured mailbox pointer delivery', () => { it('retains mail when the dispatch settles unknown', async () => { const { delivery, markAsDelivered } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) delivery.deliverForHandle('dispatch:d1') @@ -184,40 +141,8 @@ describe('structured mailbox pointer delivery', () => { expect(markAsDelivered).not.toHaveBeenCalled() }) - it('retains mail while a turn is running', async () => { - const { delivery, send, markAsDelivered } = harness({ journal: runningJournal() }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).not.toHaveBeenCalled() - expect(markAsDelivered).not.toHaveBeenCalled() - }) - - it('retains mail while a prompt is waiting for a human', async () => { - const { delivery, send } = harness({ journal: attentionJournal() }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).not.toHaveBeenCalled() - }) - - it('delivers to a worker whose finished turn left a long history and no lifecycle row', async () => { - // The steady state after a worker's first substantial turn. Gating on a bounded tail page read - // this as permanently busy, so every later nudge parked forever and the worker went unnudged. - const { delivery, send, markAsDelivered } = harness({ journal: settledLongJournal() }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).toHaveBeenCalledTimes(1) - expect(markAsDelivered).toHaveBeenCalledWith(['m1']) - }) - - it('retains mail for a prompt that scrolled out of the tail window', async () => { - const { delivery, send } = harness({ journal: staleAttentionJournal() }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).not.toHaveBeenCalled() - }) - it('retains mail when the session is not attached', async () => { - const { delivery, send } = harness({ journal: null }) + const { delivery, send } = harness({ attached: false }) delivery.deliverForHandle('dispatch:d1') await flush() expect(send).not.toHaveBeenCalled() @@ -226,27 +151,54 @@ describe('structured mailbox pointer delivery', () => { it('redrives a detached session when the journal replays on re-attach', async () => { // A transient detach parks nothing to be woken unless `session-not-attached` waits for the // journal edge, and the dispatch preamble tells the worker not to poll. - const { delivery, send, setJournal, markAsDelivered } = harness({ journal: null }) + const { delivery, send, setAttached, markAsDelivered } = harness({ attached: false }) delivery.deliverForHandle('dispatch:d1') await flush() expect(send).not.toHaveBeenCalled() - setJournal(idleJournal()) + setAttached(true) delivery.onJournalActivity('session-1') await flush() expect(send).toHaveBeenCalledTimes(1) expect(markAsDelivered).toHaveBeenCalledWith(['m1']) }) - it('retries a parked pointer when the journal moves', async () => { - const { delivery, send, setJournal, markAsDelivered } = harness({ journal: runningJournal() }) + it('sends the pointer through the chat, with who it is from, and counts it pointed once queued', async () => { + const { delivery, send, markAsDelivered, stored } = harness({ queued: true }) delivery.deliverForHandle('dispatch:d1') await flush() - expect(send).not.toHaveBeenCalled() - setJournal(idleJournal()) - delivery.onJournalActivity('session-1') + expect(send).toHaveBeenCalledTimes(1) + expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({ + text: expect.stringContaining('orca-dev orchestration check') + }) + expect(send.mock.calls[0]![0].source).toMatchObject({ + kind: 'agent', + senders: [{ party: { address: 'term_coord' } }], + orchestration: { + message: 'mail-notice', + mailbox: 'dispatch:d1', + messages: [{ messageId: 'm1', runId: 'run_1', from: 'term_coord' }] + } + }) + // The chat's queue holds it now, as it holds the person's: the same mail is not pointed again. + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + expect(stored.has('dispatch:d1')).toBe(false) + }) + + it('points mail that arrives while earlier pointed mail is still unread, counting only the new mail', async () => { + const { delivery, send, receive } = harness({}) + delivery.deliverForHandle('dispatch:d1') await flush() expect(send).toHaveBeenCalledTimes(1) - expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + receive('m2', 4) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[1]![0].body.blocks[0]).toMatchObject({ + text: expect.stringContaining('You have 1 orchestration message.') + }) }) it('nudges the worker while its coordinator holds an unacked Run delivery', async () => { @@ -255,7 +207,6 @@ describe('structured mailbox pointer delivery', () => { // coordinator's `run:` delivery is invisible here — gating the WORKER's dispatch mailbox on it // dropped the nudge with nothing parked, and the worker sat idle on mail it was never told of. const { delivery, send, markAsDelivered } = harness({ - journal: idleJournal(), outstandingRunDelivery: true }) delivery.deliverForHandle('dispatch:d1') @@ -267,7 +218,7 @@ describe('structured mailbox pointer delivery', () => { it('does not re-nudge a mailbox still holding its own unacked batch', async () => { // The other half of the same gate: the consumer already has this batch, so a second nudge // spends a whole provider turn telling it something it was told. - const { delivery, send } = harness({ journal: idleJournal(), outstandingOwnDelivery: true }) + const { delivery, send } = harness({ outstandingOwnDelivery: true }) delivery.deliverForHandle('dispatch:d1') await flush() expect(send).not.toHaveBeenCalled() @@ -278,7 +229,6 @@ describe('structured mailbox pointer delivery', () => { // stranded the worker until unrelated mail happened to arrive. The retry keeps the id: the host // replays a recorded refusal rather than starting the agent again. const { delivery, send, markAsDelivered } = harness({ - journal: idleJournal(), dispatchState: 'rejected' }) delivery.deliverForHandle('dispatch:d1') @@ -294,7 +244,6 @@ describe('structured mailbox pointer delivery', () => { it('points again under a new id once a later send ran', async () => { const { delivery, send, setSubmissions } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) delivery.deliverForHandle('dispatch:d1') @@ -312,7 +261,6 @@ describe('structured mailbox pointer delivery', () => { it('points once more under a new id for a send an earlier process left in doubt', async () => { const { delivery, send, stored, setSubmissions } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) stored.set('dispatch:d1', { @@ -344,7 +292,6 @@ describe('structured mailbox pointer delivery', () => { vi.useFakeTimers({ toFake: ['Date'] }) try { const { delivery, send, setSubmissions } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) // The wall clock steps back an hour after the lane started: its own row is still its own. @@ -374,7 +321,6 @@ describe('structured mailbox pointer delivery', () => { vi.useFakeTimers({ toFake: ['Date'] }) try { const { delivery, send, setSubmissions } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) const personTurn = { @@ -408,7 +354,6 @@ describe('structured mailbox pointer delivery', () => { it('stamps a pointer whose echo arrived after the lane stopped waiting, sending nothing more', async () => { const { delivery, send, markAsDelivered, stored, setSubmissions } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) delivery.deliverForHandle('dispatch:d1') @@ -428,7 +373,6 @@ describe('structured mailbox pointer delivery', () => { it('reuses one operation id for the same batch and re-mints when it grows', async () => { const { delivery, send, stored } = harness({ - journal: idleJournal(), dispatchState: 'unknown' }) delivery.deliverForHandle('dispatch:d1') @@ -445,10 +389,10 @@ describe('structured mailbox pointer delivery', () => { }) describe('forgetting one settled worker', () => { - /** Two workers, each mid-turn and so each parked on its OWN session's journal edge. */ + /** Two workers, each detached and so each parked on its OWN session's journal edge. */ function twoWorkerHarness() { let resolves = true - let journal = runningJournal() + let attached = false const sessionByMailbox: Record = { 'dispatch:d1': 'session-1', 'dispatch:d2': 'session-2' @@ -460,7 +404,9 @@ describe('forgetting one settled worker', () => { const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), hasOutstandingMailboxDelivery: () => false, - getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], + getUndeliveredUnreadMessages: () => [ + { id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' } + ], markAsDelivered: vi.fn(), getStructuredPointerOperation: () => undefined, putStructuredPointerOperation: () => {}, @@ -477,7 +423,7 @@ describe('forgetting one settled worker', () => { }, getCliCommand: () => 'orca', host: { - readGateFacts: async () => ({ ...structuredSessionGateFacts(journal), submissions: [] }), + readSessionFacts: async () => (attached ? { submissions: [] } : null), currentFence: () => 4, send } @@ -485,8 +431,8 @@ describe('forgetting one settled worker', () => { return { delivery, send: vi.mocked(send), - goIdle: () => { - journal = idleJournal() + attach: () => { + attached = true }, stopResolving: () => { resolves = false @@ -501,7 +447,7 @@ describe('forgetting one settled worker', () => { // The bug: `forgetSession` re-resolved every parked mailbox and pruned the ones that answered // null. A momentarily null DB reference or a session mid-teardown made that EVERY worker, so // the sibling's mail stayed durable but lost the edge that would have woken it. - const { delivery, send, goIdle, stopResolving, resumeResolving } = twoWorkerHarness() + const { delivery, send, attach, stopResolving, resumeResolving } = twoWorkerHarness() delivery.deliverForHandle('dispatch:d1') delivery.deliverForHandle('dispatch:d2') await flush() @@ -511,7 +457,7 @@ describe('forgetting one settled worker', () => { delivery.forgetSession('session-1') resumeResolving() - goIdle() + attach() delivery.onJournalActivity('session-2') await flush() expect(send).toHaveBeenCalledTimes(1) @@ -519,7 +465,7 @@ describe('forgetting one settled worker', () => { }) it('still drops what the settled worker itself had parked', async () => { - const { delivery, send, goIdle, stopResolving } = twoWorkerHarness() + const { delivery, send, attach, stopResolving } = twoWorkerHarness() delivery.deliverForHandle('dispatch:d1') await flush() expect(send).not.toHaveBeenCalled() @@ -529,7 +475,7 @@ describe('forgetting one settled worker', () => { stopResolving() delivery.forgetSession('session-1') - goIdle() + attach() delivery.onJournalActivity('session-1') await flush() expect(send).not.toHaveBeenCalled() diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index e84aa8e30e9..f442c520d4b 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -3,9 +3,9 @@ * * The PTY lane types the nudge into a live pane and reads the idle edge off the terminal title. * Neither exists here, so this is a sibling of `OrchestrationMailboxPointerDelivery` rather than a - * branch inside it: batch selection is literally shared (`selectOrchestrationPointerBatch`), and - * everything below it is different — the nudge is a session turn, the idle edge is the journal, - * and only an `accepted` dispatch may consume mail. + * branch inside it: batch selection and the pointer text are literally shared, and everything + * below it is different — the nudge goes through the chat's own send, as a person's message does, + * and the retry edge is the journal. * * Coordinators are in scope here, unlike the PTY lane's reasoning: a PTY coordinator blocks in * `check --wait`, where a waiter preempts pointer delivery, but a structured coordinator is a chat @@ -13,7 +13,8 @@ */ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { OrchestrationDb } from './db' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import type { MessageRow, OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' import type { OrchestrationCliCommand } from './cli-command' import { @@ -24,13 +25,12 @@ import { resolveStructuredPointerOperation, type StructuredPointerSubmission } from './structured-pointer-operation-id' +import { structuredMailSource } from './structured-mail-source' import { - decideStructuredSessionPointerDelivery, retainReasonForDispatch, structuredDispatchDelivered, type StructuredDispatchState, - type StructuredPointerRetainReason, - type StructuredSessionGateFacts + type StructuredPointerRetainReason } from './structured-session-pointer-delivery' export type StructuredPointerTarget = { @@ -50,22 +50,25 @@ type ParkedPointerDelivery = { export type StructuredPointerSendOutcome = | { kind: 'sent'; state: StructuredDispatchState } + /** The chat's queue took it, as it takes a person's message. */ + | { kind: 'queued' } | { kind: 'unattached' } -export type StructuredPointerGateFacts = StructuredSessionGateFacts & { +export type StructuredPointerSessionFacts = { /** Every send the session recorded, oldest first: what the lane's own sends settled as. */ submissions: readonly StructuredPointerSubmission[] } export type StructuredMailboxPointerHost = { - /** The idle gate, read off the session's full reduced timeline; `null` when it cannot be read. */ - readGateFacts: (sessionId: string) => Promise + /** `null` when the session cannot be read. */ + readSessionFacts: (sessionId: string) => Promise send: (input: { sessionId: string dispatchId: string | null operationId: string expectedRuntimeFence: number body: AgentJournalMessageItem + source: AgentMessageSource }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null @@ -194,14 +197,13 @@ export class OrchestrationStructuredMailboxPointerDelivery< db: OrchestrationDb, mailboxHandle: string, target: StructuredPointerTarget, - unread: readonly { id: string; type: string; sequence: number }[], + unread: readonly MessageRow[], reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId - const session = await this.deps.host.readGateFacts(sessionId) - const decision = decideStructuredSessionPointerDelivery({ session }) - if (!decision.deliver) { - this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes) + const session = await this.deps.host.readSessionFacts(sessionId) + if (!session) { + this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) return } const fence = this.deps.host.currentFence(sessionId) @@ -225,7 +227,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle, sessionId, messageIds: staged, - submissions: session?.submissions ?? [], + submissions: session.submissions, sentByThisProcess: this.sentOperationIds.get(mailboxHandle) }) if (operation.kind === 'stamp') { @@ -245,13 +247,20 @@ export class OrchestrationStructuredMailboxPointerDelivery< dispatchId: target.dispatchId, operationId: operation.operationId, expectedRuntimeFence: fence, - body + body, + source: structuredMailSource({ + db, + mailboxHandle, + dispatchId: target.dispatchId, + batch: unread + }) }) if (outcome.kind === 'unattached') { this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) return } - if (!structuredDispatchDelivered(outcome.state)) { + // A queued pointer is the chat's queue's to send, as a person's queued message is. + if (outcome.kind === 'sent' && !structuredDispatchDelivered(outcome.state)) { // The row stays: resending under its id replays this verdict and starts nothing. this.retain(mailboxHandle, sessionId, retainReasonForDispatch(outcome.state), reservedTypes) return @@ -264,7 +273,8 @@ export class OrchestrationStructuredMailboxPointerDelivery< } /** - * No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch. + * No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch, or + * once the chat's queue holds the pointer. * * Every reason parks for the session's next journal edge. `unknown` may mean the nudge already * sits in the provider's input queue, so an immediate retry can stack duplicate nudges; diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 72953f1e904..1d1700e76bc 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' const hostRef: { current: unknown } = { current: null } @@ -9,6 +10,7 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' const { createStructuredMailboxPointerHost, + readStructuredSessionGateFacts, structuredPointerCallerKey, structuredSessionPointerCallerKey } = await import('./structured-mailbox-pointer-host') @@ -33,6 +35,12 @@ function transcript(count: number): AgentJournalRenderItem[] { ) } +const NOTICE_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [], + orchestration: { message: 'mail-notice', mailbox: 'dispatch:d1', dispatchId: 'd1', messages: [] } +} + describe('structured mailbox pointer host', () => { beforeEach(() => { hostRef.current = null @@ -41,30 +49,33 @@ describe('structured mailbox pointer host', () => { it('reads the gate facts from the FULL timeline, never a bounded tail', async () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily - // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is - // then delivered mid-turn, which Codex coalesces into the running turn and Claude folds into - // it -- either way folded into work already in flight rather than read as a new instruction. + // far from the tail, so any page-sized read reports a busy worker as idle — and `@idle` then + // wakes it mid-turn. const items = [runningTurn(), ...transcript(500)] - const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }] - hostRef.current = { journalSnapshot: () => ({ items, submissions }) } - // The recorded sends ride along: the lane reads what its own operation id settled as. - expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ + hostRef.current = { journalSnapshot: () => ({ items, submissions: [] }) } + expect(await readStructuredSessionGateFacts('s1')).toEqual({ turnRunning: true, - awaitingHuman: false, + awaitingHuman: false + }) + }) + + it("reads what the session's sends settled as", async () => { + const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }] + hostRef.current = { journalSnapshot: () => ({ items: [], submissions }) } + expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toEqual({ submissions }) }) - it('answers null rather than idle when the session cannot be read', async () => { - // Null retains the pointer; `{turnRunning:false}` would deliver a nudge into a session this - // runtime cannot see at all. - expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + it('answers null rather than nothing recorded when the session cannot be read', async () => { + // Null retains the pointer; an empty answer would send into a session this runtime cannot see. + expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toBeNull() hostRef.current = { journalSnapshot: () => { throw new Error('agent_session_ownership_unknown') } } - expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toBeNull() }) it('reports an unattached host rather than a rejection when nothing can be sent', async () => { @@ -108,25 +119,31 @@ describe('structured mailbox pointer host', () => { expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() }) - it('reads a queued answer as unknown, so the pointer is retained', async () => { - hostRef.current = { - send: async () => ({ + it('asks a busy chat to queue the pointer as a card, with who it is from', async () => { + const send = vi.fn( + async (_caller: unknown, _payload: { delivery?: string; source?: unknown }) => ({ ok: true, value: { clientMessageId: 'op1', queued: { messageId: 'op1', position: 0, state: 'waiting' } } }) - } + ) + hostRef.current = { send } await expect( createStructuredMailboxPointerHost().send({ sessionId: 's1', dispatchId: 'd1', operationId: 'op1', expectedRuntimeFence: 1, - body: { kind: 'message', role: 'user', blocks: [] } - } as never) - ).resolves.toEqual({ kind: 'sent', state: 'unknown' }) + body: { kind: 'message', role: 'user', blocks: [] }, + source: NOTICE_SOURCE + }) + ).resolves.toEqual({ kind: 'queued' }) + expect(send.mock.calls[0]![1]).toMatchObject({ + delivery: 'queue-if-active', + source: NOTICE_SOURCE + }) }) it('consumes mail once an accepted nudge is delivered while the worker starts (W10)', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index c948a169a89..7bb79d01e3e 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -10,7 +10,7 @@ import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredMailboxPointerHost, - StructuredPointerGateFacts + StructuredPointerSessionFacts } from './structured-mailbox-pointer-delivery' import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import { @@ -36,12 +36,12 @@ export function structuredSessionPointerCallerKey(sessionId: string): string { } /** - * The idle gate for a structured session, read off its FULL reduced timeline. + * Whether a structured session is idle, for group addressing (`@idle`), read off its FULL reduced + * timeline. * * Never a bounded page. A settled turn's lifecycle item is revised in place, so on any tail window * an idle session and a busy one whose lifecycle item scrolled off look identical — and - * idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and - * group addressing cannot disagree about it. + * idle-with-history is the normal steady state of a working agent. */ export async function readStructuredSessionGateFacts( sessionId: string @@ -50,12 +50,12 @@ export async function readStructuredSessionGateFacts( return snapshot ? structuredSessionGateFacts(snapshot.items) : null } -/** The pointer lane's gate: the shared idle facts, plus what each recorded send settled as. */ -async function readPointerGateFacts(sessionId: string): Promise { +/** What each recorded send settled as. */ +async function readPointerSessionFacts( + sessionId: string +): Promise { const snapshot = await readSessionJournal(sessionId) - return snapshot - ? { ...structuredSessionGateFacts(snapshot.items), submissions: snapshot.submissions } - : null + return snapshot ? { submissions: snapshot.submissions } : null } async function readSessionJournal(sessionId: string): Promise { @@ -77,8 +77,8 @@ async function readSessionJournal(sessionId: string): Promise { }) }) -describe('decideStructuredSessionPointerDelivery', () => { - it('delivers to an attached, idle session', () => { - expect(decideStructuredSessionPointerDelivery({ session: IDLE })).toEqual({ - deliver: true - }) - }) - - it('retains when the session is not attached on this host', () => { - expect(decideStructuredSessionPointerDelivery({ session: null })).toEqual({ - deliver: false, - retain: 'session-not-attached' - }) - }) - - it('retains mid-turn rather than delegating the race to the provider', () => { - expect( - decideStructuredSessionPointerDelivery({ - session: { turnRunning: true, awaitingHuman: false } - }) - ).toEqual({ deliver: false, retain: 'turn-unsettled' }) - }) - - it('names the human prompt ahead of the turn, so the retain reason is the actionable one', () => { - expect( - decideStructuredSessionPointerDelivery({ - session: { turnRunning: true, awaitingHuman: true } - }) - ).toEqual({ deliver: false, retain: 'awaiting-human' }) - }) -}) - describe('dispatch outcome classification', () => { it('marks mail delivered only on an accepted dispatch', () => { expect(structuredDispatchDelivered('accepted')).toBe(true) diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 4e838e313f8..fc7758e07a1 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -1,13 +1,10 @@ /** - * Delivery decisions for an orchestration mail pointer aimed at a host-owned - * structured ("native") agent session. + * What orchestration mail delivery reads of a host-owned structured ("native") agent session. * - * A structured session has no PTY the pointer can be typed into, so the nudge - * travels as a session turn instead of as bytes. Everything here is pure: the - * caller supplies the session's gate facts, and gets back a decision it can - * act on. Orchestration's database stays the source of truth — - * no decision here ever consumes mail, it only says whether the nudge may be - * attempted now. + * A structured session has no PTY the pointer can be typed into, so the nudge travels as a session + * turn instead of as bytes, and a busy session's own queue holds it until the turn ends. + * Everything here is pure. Orchestration's database stays the source of truth: nothing here + * consumes mail. */ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' @@ -20,22 +17,18 @@ import { export type StructuredPointerRetainReason = | 'session-not-attached' | 'turn-unsettled' - | 'awaiting-human' | 'dispatch-rejected' | 'dispatch-unknown' -export type StructuredPointerDecision = - | { deliver: true } - | { deliver: false; retain: StructuredPointerRetainReason } - /** The dispatch states both provider adapters converge on. */ export type StructuredDispatchState = 'accepted' | 'rejected' | 'unknown' /** - * What the delivery gate needs to know about a session, read once per attempt. + * Whether a session is busy, in the vocabulary group addressing (`@idle`) matches on. * * Deliberately two booleans rather than the journal: the caller reads the FULL reduced timeline - * (see `readGateFacts`), so nothing downstream can be tempted to re-derive them from a page. + * (see `readStructuredSessionGateFacts`), so nothing downstream can be tempted to re-derive them + * from a page. */ export type StructuredSessionGateFacts = { turnRunning: boolean @@ -46,7 +39,7 @@ export type StructuredSessionGateFacts = { /** * Projects the gate facts off a session's live items. * - * Reuses the projection the chat view already reads, so the delivery gate and the visible + * Reuses the projection the chat view already reads, so `@idle` and the visible * "working" state can never disagree. Both must be answered from the fully reduced timeline: a * settled turn is TOMBSTONED rather than rewritten to `completed`, so on a bounded tail page an * idle session and a running turn whose lifecycle item was pushed off the end look identical — @@ -61,37 +54,6 @@ export function structuredSessionGateFacts( } } -/** - * Decide whether the nudge may be sent right now. - * - * Mid-turn delivery is refused for both providers rather than delegated to - * them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into - * the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none - * of which refuse it and none of which fire a second `turn/started` -- and - * Claude folds it into the running turn (or runs it as the next turn when the - * turn ends first). Both therefore - * fold the nudge into work already in flight, where it reads as part of the - * running turn rather than a new instruction. Waiting for the turn to settle is - * the one contract that holds for both, and it preserves orchestration's - * existing idle-edge-only delivery policy. - */ -export function decideStructuredSessionPointerDelivery(input: { - session: StructuredSessionGateFacts | null -}): StructuredPointerDecision { - if (!input.session) { - return { deliver: false, retain: 'session-not-attached' } - } - // Checked before the turn gate: a pending prompt has no running turn, so the turn test alone - // reads it as idle, and sending there queues a nudge behind something only a human can clear. - if (input.session.awaitingHuman) { - return { deliver: false, retain: 'awaiting-human' } - } - if (input.session.turnRunning) { - return { deliver: false, retain: 'turn-unsettled' } - } - return { deliver: true } -} - /** * Only an accepted dispatch may mark mail delivered. * diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 4e3beb3a771..9bb125d6065 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -46,6 +46,7 @@ describe('client UI RPC pairing-local field seams', () => { { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } ], workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'], + sidebarOpen: false, agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'], agentsFilterRepoIds: ['repo-a'], agentsHideWorkspacesFromOtherDevices: true, diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 6ce700a8435..712e0555221 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -345,6 +345,79 @@ describe('a Codex send its turn ended without taking it', () => { }) }) +describe('the turn a withdrawn Codex send was answered into', () => { + async function answeredInto(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const onPage = page.ok + ? page.page.submissions.find((entry) => entry.clientMessageId === clientMessageId) + : undefined + return { + turnRecords: snapshot.items.flatMap((item) => + item.body.kind === 'turn' ? [item.itemId] : [] + ), + named: snapshot.submissions.find((entry) => entry.clientMessageId === clientMessageId) + ?.answeredInTurn, + onPage: onPage?.answeredInTurn + } + } + + it("is that turn's record, started by the send that opened it and steered by a later one", async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + const steered = await send('and check the tests') + await vi.waitFor(() => expect(steers).toBe(1)) + + await stop('turn-1') + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, steered)).toBe('withdrawn') + ) + + const { turnRecords } = await answeredInto(opening) + expect(turnRecords).toHaveLength(1) + const started = { turnItemId: turnRecords[0], via: 'start' } + const steeredIn = { turnItemId: turnRecords[0], via: 'steer' } + expect(await answeredInto(opening)).toEqual({ turnRecords, named: started, onPage: started }) + expect(await answeredInto(steered)).toEqual({ + turnRecords, + named: steeredIn, + onPage: steeredIn + }) + }) + + it('is not named on a send the turn took', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + + await stop('turn-1') + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, opening)).toBe('accepted') + ) + + expect(await answeredInto(opening)).toMatchObject({ named: undefined, onPage: undefined }) + }) + + it('is named when the answer is read after that turn ended', async () => { + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) + turns.start() + turns.end('interrupted') + release() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + const { turnRecords, named } = await answeredInto(sent) + expect(turnRecords).toHaveLength(1) + expect(named).toEqual({ turnItemId: turnRecords[0], via: 'start' }) + }) +}) + describe('a queued card sent now into the turn a Stop ends', () => { async function handoffs(messageId: string): Promise { return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId) diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts new file mode 100644 index 00000000000..104f767e333 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -0,0 +1,139 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// A busy structured chat holds the orchestration pointer as a card in its own queue, sent when the +// turn ends, as it holds a message the person sends then; the queue does nothing else with it. End +// to end on the coordinator-mail rig. + +import { describe, expect, it, vi } from 'vitest' +import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { + COORDINATOR, + WORKER_2_PANE, + WAIT, + call, + coordinatorRunAndTask, + db, + finishWorker, + host, + openChat, + ptyPointer, + queuedCardTexts, + runtime, + sendUserMessage, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +/** The person's turn, started and still running; resolves to its end. */ +async function runningUserTurn(chat: FakeConnection): Promise<() => Promise> { + expect(await sendUserMessage(COORDINATOR, 'go')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: 'turn-1' } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: 'echo-go', + clientId: chat.turns[0]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(COORDINATOR) + return async () => { + notify('turn/completed', { turn: { id: 'turn-1' } }) + await host.flushStreamedEvents(COORDINATOR) + } +} + +/** Idle edges with nothing owed: whatever they would send gets the time to show. */ +async function idleEdgesSettled(): Promise { + for (let edge = 0; edge < 3; edge += 1) { + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +/** The chat's queue as its journal stores it. */ +function queuedRows() { + return host.collaboratorsForTests().sessions.get(COORDINATOR)?.journal.queuedMessages.list() ?? [] +} + +/** A second task, for a second worker result. */ +async function secondTask(): Promise { + return idOf( + (await call('orchestration.taskCreate', { spec: 'more' }, { sessionId: COORDINATOR })).task + ) +} + +describe("a busy chat's orchestration pointer waits in its queue", () => { + it('queues the pointer as a card, with who it is from, and sends it once when the turn ends', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(chat) + await finishWorker(taskId) + await vi.waitFor( + async () => expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]), + WAIT + ) + expect(chat.turns).toHaveLength(1) + const [card] = queuedRows() + const [mail] = db.getAllMessages(`run:${runId}`) + expect(card?.source).toEqual({ + kind: 'agent', + senders: [ + { party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null } } + ], + orchestration: { + message: 'mail-notice', + mailbox: `run:${runId}`, + dispatchId: null, + messages: [{ messageId: mail!.id, runId, from: 'term_worker' }] + } + }) + + await endTurn() + await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) + expect(turnText(chat.turns[1]!)).toBe(ptyPointer(`run:${runId}`)) + expect(await queuedCardTexts()).toEqual([]) + await settleTurn(COORDINATOR, 1) + await idleEdgesSettled() + expect(chat.turns).toHaveLength(2) + }) + + it('queues a second card for mail that arrives while the first waits, each counting its own mail', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const second = await secondTask() + const endTurn = await runningUserTurn(chat) + await finishWorker(taskId) + await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT) + await finishWorker(second, { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) + const pointer = ptyPointer(`run:${runId}`) + await vi.waitFor(async () => expect(await queuedCardTexts()).toEqual([pointer, pointer]), WAIT) + + await endTurn() + await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) + await settleTurn(COORDINATOR, 1) + await vi.waitFor(() => expect(chat.turns).toHaveLength(3), WAIT) + expect(turnText(chat.turns[2]!)).toBe(pointer) + await settleTurn(COORDINATOR, 2) + await idleEdgesSettled() + expect(chat.turns).toHaveLength(3) + expect(await queuedCardTexts()).toEqual([]) + }) + + it("leaves the chat's own `check` as it is: the mail stays readable, and the card stays", async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const endTurn = await runningUserTurn(chat) + await finishWorker(taskId) + await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT) + const [mail] = db.getAllMessages(`run:${runId}`) + expect(await call('orchestration.check', {}, { sessionId: COORDINATOR })).toMatchObject({ + count: 1, + messages: [{ id: mail!.id }] + }) + expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]) + await endTurn() + }) +}) diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts new file mode 100644 index 00000000000..6fadd54a13b --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -0,0 +1,324 @@ +// The coordinator-mail rig, shared by every suite that drives a worker's result into a structured +// chat end to end in one process. +// +// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the +// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the +// Codex app-server child, which answers the JSON-RPC calls the real one does. Importing it +// registers the rig's own beforeEach/afterEach for the importing file. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { localOrchestrationCliCommand } from './orchestration/cli-command' +import { formatMessagePointer } from './orchestration/formatter' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' +import { + attachParams, + fakeCodex, + operationId, + resetProviderFaults, + type FakeConnection +} from './structured-chat-coordinator-fake-codex-fixture' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +export const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +export const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +export const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +export const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +export let codex: ReturnType +export let root: string +export let runtime: OrcaRuntimeService +export let db: OrchestrationDb +export let host: StructuredAgentSessionHost +export let dispatcher: RpcDispatcher +export let requests = 0 +export const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR) + +export function request( + method: string, + params: Record, + options: { sessionId?: string } = {} +): Parameters[0] { + requests += 1 + return { + id: `rpc-${requests}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `req-${requests}`, + ...(options.sessionId + ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } + : {}) + } +} + +export async function call( + method: string, + params: Record, + options?: { sessionId?: string } +): Promise> { + const response = await dispatcher.dispatch(request(method, params, options)) + if (!response.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response)}`) + } + return resultOf(response) +} + +export async function openChat(sessionId: string): Promise { + const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + await host.setSessionTabVisibility(sessionId, true) + threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) + return connectionFor(sessionId) +} + +export const threadBySession = new Map() + +export function connectionFor(sessionId: string): FakeConnection { + // A cleared chat's successor starts on its first message; its record then names its thread. + const head = agentSessionProviderHandleChainHead( + host.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] + ) + const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId + const connection = codex.connections.findLast((candidate) => candidate.threadId === thread) + if (!connection) { + throw new Error(`no app-server for ${sessionId}`) + } + return connection +} + +/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */ +export async function settleTurn(sessionId: string, turnIndex: number): Promise { + const connection = connectionFor(sessionId) + const turn = connection.turns[turnIndex]! + const turnId = `turn-${turnIndex + 1}` + const notify = (method: string, params: unknown) => + connection.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: turnId } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: `echo-${turn.clientUserMessageId}`, + clientId: turn.clientUserMessageId, + content: [{ type: 'text', text: 'pointer' }] + } + }) + notify('turn/completed', { turn: { id: turnId } }) + await host.flushStreamedEvents(sessionId) +} + +/** A user message typed into the chat, as the chat surface sends it. */ +export function sendUserMessage(sessionId: string, text: string) { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }] + } + return host.send( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + }, + body + } + ) +} + +export async function userTexts(sessionId: string): Promise { + return (await host.journalSnapshot(sessionId)).items.flatMap((item: AgentJournalRenderItem) => + item.body?.kind === 'message' && item.body.role === 'user' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')) + : [] + ) +} + +/** A supervised terminal worker under the coordinator's Run, and its worker_done. */ +export async function finishWorker( + taskId: string, + worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } +): Promise { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: worker.handle, + paneKey: worker.paneKey, + processIncarnation: `runtime_test:${worker.handle}:1`, + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + await call('orchestration.send', { + from: worker.handle, + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) + }) +} + +export async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { + const created = await call( + 'orchestration.runCreate', + { objective: 'ship' }, + { + sessionId: COORDINATOR + } + ) + const runId = idOf(created.run) + const task = await call( + 'orchestration.taskCreate', + { spec: 'build it' }, + { + sessionId: COORDINATOR + } + ) + return { runId, taskId: idOf(task.task) } +} + +/** `/clear` as the chat surface runs it: the conversation continues in a new session. */ +export async function clearChat(sessionId: string): Promise { + const command = 'clear' as const + const cleared = await host.conversationCommand( + { callerKey: 'test-surface' }, + { + command, + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId, + fields: { command } + }) + } + } + ) + const successor = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!successor) { + throw new Error(`clear failed: ${JSON.stringify(cleared)}`) + } + // The surface swaps the tab over to the session that continues the chat. + await host.setSessionTabVisibility(sessionId, false) + await host.setSessionTabVisibility(successor, true) + return successor +} + +/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */ +export async function startSuccessor(successor: string): Promise { + expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT) + await settleTurn(successor, 0) +} + +beforeEach(async () => { + resetProviderFaults() + root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) + codex = fakeCodex() + db = new OrchestrationDb(':memory:') + runtime = startRuntime() + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + // The same calls the runtime's own host install makes. + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) + }) + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) +}) + +/** The runtime over the shared database; a second call is what an Orca restart leaves behind. */ +export function startRuntime(): OrcaRuntimeService { + const started = new OrcaRuntimeService() + started.setOrchestrationDb(db) + vi.spyOn(started, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null + ) + return started +} + +afterEach(async () => { + try { + await stopStructuredAgentSessionRuntime() + db.close() + await observationClock.drainClosedDatabaseRepair() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) + } finally { + observationClock.restore() + } +}) + +// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. +export const WAIT = { timeout: 10_000 } + +export const POINTER = + /You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./ + +/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */ +export function ptyPointer(mailboxHandle: string): string { + return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim() +} + +/** The text of a turn the fake provider received. */ +export function turnText(turn: { text: string }): string { + const input: unknown = JSON.parse(turn.text) + return Array.isArray(input) + ? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('') + : '' +} + +/** What an Orca restart leaves behind: a new runtime over the same database and host. */ +export function restartRuntime(): void { + runtime = startRuntime() + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) +} + +/** The text of every card the chat lists in its queue, as the person sees it. */ +export async function queuedCardTexts(sessionId = COORDINATOR): Promise { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return (page.page.queuedMessages ?? []).flatMap((card) => + card.body.blocks.map((block) => (block.type === 'text' ? block.text : '')) + ) +} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 29a2d5bb79f..5eb0f57209c 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -1,319 +1,51 @@ import './rpc/unused-default-rpc-methods.test-fixture' -// A worker's result reaching the structured chat that coordinates it, end to end in one process. -// -// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the -// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the -// Codex app-server child, which answers the JSON-RPC calls the real one does. +// A worker's result reaching the structured chat that coordinates it, end to end in one process, +// on the coordinator-mail rig. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import { AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' import { refuse } from '../../shared/agent-session-wire-refusals' -import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' -import { OrcaRuntimeService } from './orca-runtime' -import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' -import { RpcDispatcher } from './rpc/dispatcher' -import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' -import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { operationId, providerFaults } from './structured-chat-coordinator-fake-codex-fixture' + import { - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' -import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' -import { - attachParams, - fakeCodex, - operationId, - providerFaults, - resetProviderFaults, - type FakeConnection -} from './structured-chat-coordinator-fake-codex-fixture' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' - -const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' -const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' -const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc' - -let codex: ReturnType -let root: string -let runtime: OrcaRuntimeService -let db: OrchestrationDb -let host: StructuredAgentSessionHost -let dispatcher: RpcDispatcher -let requests = 0 -const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR) - -function request( - method: string, - params: Record, - options: { sessionId?: string } = {} -): Parameters[0] { - requests += 1 - return { - id: `rpc-${requests}`, - authToken: 'test', - method, - params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: `req-${requests}`, - ...(options.sessionId - ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } - : {}) - } -} - -async function call( - method: string, - params: Record, - options?: { sessionId?: string } -): Promise> { - const response = await dispatcher.dispatch(request(method, params, options)) - if (!response.ok) { - throw new Error(`${method} failed: ${JSON.stringify(response)}`) - } - return resultOf(response) -} - -async function openChat(sessionId: string): Promise { - const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) - expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) - await host.setSessionTabVisibility(sessionId, true) - threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) - return connectionFor(sessionId) -} - -const threadBySession = new Map() - -function connectionFor(sessionId: string): FakeConnection { - // A cleared chat's successor starts on its first message; its record then names its thread. - const head = agentSessionProviderHandleChainHead( - host.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] - ) - const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId - const connection = codex.connections.findLast((candidate) => candidate.threadId === thread) - if (!connection) { - throw new Error(`no app-server for ${sessionId}`) - } - return connection -} - -/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */ -async function settleTurn(sessionId: string, turnIndex: number): Promise { - const connection = connectionFor(sessionId) - const turn = connection.turns[turnIndex]! - const turnId = `turn-${turnIndex + 1}` - const notify = (method: string, params: unknown) => - connection.handlers.onNotification?.(method, params) - notify('turn/started', { turn: { id: turnId } }) - notify('item/completed', { - item: { - type: 'userMessage', - id: `echo-${turn.clientUserMessageId}`, - clientId: turn.clientUserMessageId, - content: [{ type: 'text', text: 'pointer' }] - } - }) - notify('turn/completed', { turn: { id: turnId } }) - await host.flushStreamedEvents(sessionId) -} - -/** A user message typed into the chat, as the chat surface sends it. */ -function sendUserMessage(sessionId: string, text: string) { - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text }] - } - return host.send( - { callerKey: 'test-surface' }, - { - envelope: { - sessionId, - clientOperationId: operationId(), - expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - }, - body - } - ) -} - -async function userTexts(sessionId: string): Promise { - return (await host.journalSnapshot(sessionId)).items.flatMap((item: AgentJournalRenderItem) => - item.body?.kind === 'message' && item.body.role === 'user' - ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')) - : [] - ) -} - -/** A supervised terminal worker under the coordinator's Run, and its worker_done. */ -async function finishWorker( - taskId: string, - worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } -): Promise { - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: worker.handle, - paneKey: worker.paneKey, - processIncarnation: `runtime_test:${worker.handle}:1`, - worktreeId: 'repo::worker', - effects: [], - setupState: 'not_applicable' - }) - db.markWorkerDispatchReady(started.dispatch.id) - await call('orchestration.send', { - from: worker.handle, - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) - }) -} - -async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { - const created = await call( - 'orchestration.runCreate', - { objective: 'ship' }, - { - sessionId: COORDINATOR - } - ) - const runId = idOf(created.run) - const task = await call( - 'orchestration.taskCreate', - { spec: 'build it' }, - { - sessionId: COORDINATOR - } - ) - return { runId, taskId: idOf(task.task) } -} - -/** `/clear` as the chat surface runs it: the conversation continues in a new session. */ -async function clearChat(sessionId: string): Promise { - const command = 'clear' as const - const cleared = await host.conversationCommand( - { callerKey: 'test-surface' }, - { - command, - envelope: { - sessionId, - clientOperationId: operationId(), - expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId, - fields: { command } - }) - } - } - ) - const successor = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!successor) { - throw new Error(`clear failed: ${JSON.stringify(cleared)}`) - } - // The surface swaps the tab over to the session that continues the chat. - await host.setSessionTabVisibility(sessionId, false) - await host.setSessionTabVisibility(successor, true) - return successor -} - -/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */ -async function startSuccessor(successor: string): Promise { - expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT) - await settleTurn(successor, 0) -} - -beforeEach(async () => { - resetProviderFaults() - root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) - codex = fakeCodex() - db = new OrchestrationDb(':memory:') - runtime = startRuntime() - host = await ensureStructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - // The same call the runtime's own host install makes on every status change. - onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) - }) - dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) -}) - -/** The runtime over the shared database; a second call is what an Orca restart leaves behind. */ -function startRuntime(): OrcaRuntimeService { - const started = new OrcaRuntimeService() - started.setOrchestrationDb(db) - vi.spyOn(started, 'ensureStructuredAgentSessionHost').mockResolvedValue() - vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null - ) - return started -} - -afterEach(async () => { - try { - await stopStructuredAgentSessionRuntime() - db.close() - await observationClock.drainClosedDatabaseRepair() - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) - } finally { - observationClock.restore() - } -}) - -// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. -const WAIT = { timeout: 10_000 } - -const POINTER = - /You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./ - -/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */ -function ptyPointer(mailboxHandle: string): string { - return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim() -} - -/** The text of a turn the fake provider received. */ -function turnText(turn: { text: string }): string { - const input: unknown = JSON.parse(turn.text) - return Array.isArray(input) - ? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('') - : '' -} + COORDINATOR, + PEER_CHAT, + WORKER_2_PANE, + codex, + runtime, + db, + host, + dispatcher, + observationClock, + request, + call, + openChat, + connectionFor, + settleTurn, + sendUserMessage, + userTexts, + finishWorker, + coordinatorRunAndTask, + clearChat, + startSuccessor, + WAIT, + POINTER, + ptyPointer, + turnText, + queuedCardTexts, + restartRuntime +} from './structured-chat-coordinator-mail-rig.test-fixture' describe('a worker result reaches the structured chat that coordinates it', () => { it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { @@ -522,8 +254,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = // The next process: a fresh runtime over the same database redrives restored mail. The // provider still dies, so exactly one start proves it is pointed once, not in a loop. - runtime = startRuntime() - dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + restartRuntime() const before = providerFaults.starts await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) await observationClock.observe(1_500) @@ -658,9 +389,10 @@ describe('a worker result reaches the structured chat that coordinates it', () = } }) - it('holds mail a refused turn left in doubt until the next result, then points it once', async () => { + it("holds mail a refused turn left in doubt, then queues the next pointer behind it as the person's message would wait", async () => { // A failed turn/start cannot prove the turn never started, so the host records it `unknown` - // and a resend under its id replays that; new mail is a new send. + // and a resend under its id replays that. A live doubt counts as work still owed, so the next + // result's pointer waits in the chat's queue, as a message the person sent then would. const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() const second = await call( @@ -676,10 +408,14 @@ describe('a worker result reaches the structured chat that coordinates it', () = expect(chat.turns).toHaveLength(0) await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) - await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) - expect(turnText(chat.turns[0]!)).toBe( - formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim() + await vi.waitFor( + async () => + expect(await queuedCardTexts()).toEqual([ + formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim() + ]), + WAIT ) + expect(chat.turns).toHaveLength(0) expect(codex.connections.length).toBe(before) }) @@ -879,10 +615,11 @@ describe('a /clear keeps the chat its orchestration address', () => { expect(sent).toMatchObject({ message: { to_handle: `orca_session_id:${PEER_CHAT}` } }) await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(index + 1), WAIT) await settleTurn(successor, index) + // Read each ping before the next is sent, so each check holds exactly one. + const checked = await call('orchestration.check', {}, { sessionId: successor }) + expect(checked).toMatchObject({ count: 1, messages: [{ subject: `ping ${index}` }] }) + await call('orchestration.check', { ack: checked.deliveryId }, { sessionId: successor }) } - await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ - count: 3 - }) }) }) diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 9a9c50bd3af..99144519d58 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 e6fdd9f900b..7e479a9ecba 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 cadbd677650..3a599e4ac5e 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 79f9eabc066..2d283a50de2 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 7873316ef3d..f5fb9032f2d 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 979c5b7d413..1bc2915508f 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 00000000000..05599675c7c --- /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 00000000000..a2ef9682426 --- /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 51957a5eec5..b7d9345daf7 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 c36896a2481..e18fa151394 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/relay/pty-handler-startup-command-delivery.test.ts b/src/relay/pty-handler-startup-command-delivery.test.ts index 2215eff9d76..a8d812ed89a 100644 --- a/src/relay/pty-handler-startup-command-delivery.test.ts +++ b/src/relay/pty-handler-startup-command-delivery.test.ts @@ -89,8 +89,7 @@ describe('PtyHandler', () => { expect(term.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) - const submit = process.platform === 'win32' ? '\r' : '\n' - expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`) + expect(term.write).toHaveBeenCalledWith('echo provider-owned\r') expect(handler.retainedStartupCommandCount).toBe(0) }) @@ -364,7 +363,7 @@ describe('PtyHandler', () => { expect(term.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) - expect(term.write).toHaveBeenCalledWith('echo after-ready\n') + expect(term.write).toHaveBeenCalledWith('echo after-ready\r') expect(handler.retainedStartupCommandCount).toBe(0) vi.advanceTimersByTime(8) expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { @@ -421,7 +420,7 @@ describe('PtyHandler', () => { promptOptions.onPromptReady() await vi.advanceTimersByTimeAsync(50) - expect(term.write).toHaveBeenCalledWith('echo after-exec\n') + expect(term.write).toHaveBeenCalledWith('echo after-exec\r') expect(handler.retainedStartupCommandCount).toBe(0) } ) @@ -635,7 +634,7 @@ describe('PtyHandler', () => { dataCallback?.('\x1b]777;orca-shell-ready') vi.advanceTimersByTime(1500) - expect(term.write).toHaveBeenCalledWith('echo fallback\n') + expect(term.write).toHaveBeenCalledWith('echo fallback\r') vi.advanceTimersByTime(8) expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b96781818e7..162553fe613 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1025,10 +1025,8 @@ export class PtyHandler { if (heldBytes) { managed.startupIngress?.accept(heldBytes) } - const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed. const payload = buildStartupCommandSubmission(startup.command, { - submit, bracketedPasteSafe: startup.waitForShellReady }) managed.startupCommand = undefined diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 903c8b81447..1c1f72e5db8 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 00000000000..4d64bdd708f --- /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 4186481e7fa..0c0ab0dd543 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/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 168fb87f302..3b0556a161e 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -145,6 +145,7 @@ export function usePersistedUIWriter(): void { const ui = useAppStore( useShallow((s): PersistedUIWriteBaseline => ({ sidebarWidth: s.sidebarWidth, + sidebarOpen: s.sidebarOpen, rightSidebarOpen: s.rightSidebarOpen, rightSidebarTab: s.rightSidebarTab, rightSidebarExplorerView: s.rightSidebarExplorerView, diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index f5d404b4e10..be236272bf4 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -270,6 +270,40 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(after.hideCliCreatedWorkspaces).toBe(before.hideCliCreatedWorkspaces) }) + it('persists a left sidebar close across an unrelated sync and restores it on startup', async () => { + act(() => { + store.getState().toggleSidebar() + authority.set({ sidebarWidth: 320 }) + }) + deliverBroadcasts() + expect(store.getState().sidebarOpen).toBe(false) + await flushDesktopDebounce() + expect(authority.get().sidebarOpen).toBe(false) + + const restarted = createUIStore() + restarted.getState().hydratePersistedUI(authority.get(), 'startup') + expect(restarted.getState().sidebarOpen).toBe(false) + expect(restarted.getState().sidebarWidth).toBe(320) + }) + + it('persists a left sidebar reopen while the close acknowledgement is still pending', async () => { + holdAcks = true + act(() => store.getState().toggleSidebar()) + await flushDesktopDebounce() + expect(authority.get().sidebarOpen).toBe(false) + + act(() => store.getState().toggleSidebar()) + deliverBroadcasts() + expect(store.getState().sidebarOpen).toBe(true) + await resolveAcks() + await flushDesktopDebounce() + await resolveAcks() + deliverBroadcasts() + expect(authority.get().sidebarOpen).toBe(true) + expect(store.getState().sidebarOpen).toBe(true) + expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) + }) + it('a mobile tap must not revert a desktop change the mobile mirror has not seen', async () => { const mobile = createMobileClient(authority) mobile.sync() diff --git a/src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.tsx b/src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.tsx index 5761705affc..0a9e4b661e8 100644 --- a/src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.tsx +++ b/src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.tsx @@ -196,7 +196,10 @@ export default function GrabConfirmationSheet({ {target.accessibility.accessibleName ? (
- {translate('auto.components.browser.pane.GrabConfirmationSheet.eb98a0971a', '"')} + {translate( + 'auto.components.browser.pane.GrabConfirmationSheet.accessibleNameOpenQuote', + '"' + )} {translate('auto.components.browser.pane.GrabConfirmationSheet.eb98a0971a', '"')}
diff --git a/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.test.tsx b/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.test.tsx new file mode 100644 index 00000000000..23055c77086 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MarkupOverlay } from './MarkupOverlay' + +afterEach(cleanup) + +function renderOverlay() { + const view = render( + + ) + const canvas = view.container.querySelector('canvas') + if (!canvas) { + throw new Error('markup canvas not rendered') + } + canvas.setPointerCapture = vi.fn() + const undoButton = view.getByRole('button', { name: 'Undo' }) + const redoButton = view.getByRole('button', { name: 'Redo' }) + return { canvas, undoButton, redoButton } +} + +// Every ending is followed by the lost capture the browser fires after it. +function stroke( + canvas: HTMLCanvasElement, + end: 'pointerUp' | 'pointerCancel' | 'lostPointerCapture' +): void { + act(() => { + fireEvent.pointerDown(canvas, { pointerId: 1, button: 0, clientX: 0, clientY: 0 }) + fireEvent.pointerMove(canvas, { pointerId: 1, clientX: 50, clientY: 0 }) + if (end !== 'lostPointerCapture') { + fireEvent[end](canvas, { pointerId: 1, clientX: 50, clientY: 0 }) + } + fireEvent.lostPointerCapture(canvas, { pointerId: 1 }) + }) +} + +describe('MarkupOverlay canvas pointer wiring', () => { + it('commits a stroke on release', () => { + const { canvas, undoButton } = renderOverlay() + + stroke(canvas, 'pointerUp') + + expect(undoButton).toHaveProperty('disabled', false) + }) + + it('commits a stroke whose release never arrived once the canvas loses the pointer', () => { + const { canvas, undoButton, redoButton } = renderOverlay() + + stroke(canvas, 'lostPointerCapture') + act(() => { + fireEvent.click(undoButton) + }) + + // Only a committed stroke leaves something to redo; a still-held one is just dropped. + expect(redoButton).toHaveProperty('disabled', false) + }) + + it('discards a stroke whose pointer was cancelled', () => { + const { canvas, undoButton } = renderOverlay() + + stroke(canvas, 'pointerCancel') + + expect(undoButton).toHaveProperty('disabled', true) + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.tsx b/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.tsx index 4f8a02dbd91..39e86da89d7 100644 --- a/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.tsx +++ b/src/renderer/src/components/browser-pane/annotate/MarkupOverlay.tsx @@ -66,7 +66,9 @@ export function MarkupOverlay({ onPointerDown={editor.onPointerDown} onPointerMove={editor.onPointerMove} onPointerUp={editor.onPointerUp} - onPointerCancel={editor.onPointerUp} + onPointerCancel={editor.onPointerCancel} + // Why: commits a gesture whose release never arrived, so one can never be left open. + onLostPointerCapture={editor.onPointerUp} /> {pendingText ? ( @@ -121,7 +123,8 @@ export function MarkupOverlay({ ) : null}
-
+ {/* Why: cap at the overlay width so the toolbar wraps in a narrow viewport instead of clipping. */} +
-
+
{toolItems().map((item) => ( null + +// A short horizontal pen stroke at height `y`, 100px wide. +function line(id: string, y: number): PenShape { + return { + id, + kind: 'pen', + color: '#ef4444', + width: 2, + points: [ + { x: 0, y }, + { x: 100, y } + ] + } +} + +function editorWith(...shapes: PenShape[]): MarkupEditorState { + return { doc: shapes.reduce(commitShape, createMarkupDocument()), gesture: null } +} + +function erase(state: MarkupEditorState, path: MarkupPoint[], pointerId = 1): MarkupEditorState { + const [first, ...rest] = path + const begun = beginEraseGesture(state, pointerId, first, noText) + return rest.reduce((current, point) => moveGesture(current, pointerId, point, noText), begun) +} + +// A rectangle, ellipse or arrow as pressed: no drag yet, so from === to. +function pressedShape(kind: 'rect' | 'ellipse' | 'arrow', at: MarkupPoint): DraggedShape { + return { id: kind, kind, color: '#ef4444', width: 4, from: at, to: at } +} + +const ids = (state: MarkupEditorState) => state.doc.shapes.map((shape) => shape.id) +const erasedIds = (state: MarkupEditorState) => + state.gesture?.kind === 'erase' ? state.gesture.erasedIds : undefined + +describe('erase gesture', () => { + it('removes only the clicked mark, leaving the ones drawn after it', () => { + const state = endGesture( + erase(editorWith(line('a', 0), line('b', 100), line('c', 200)), [{ x: 50, y: 0 }]), + 1 + ) + + expect(ids(state)).toEqual(['b', 'c']) + expect(state.gesture).toBeNull() + }) + + it('erases only the newest of two overlapping marks on a click', () => { + const before = editorWith(line('older', 0), line('newer', 0)) + const pressed = erase(before, [{ x: 50, y: 0 }]) + // The pointer is still down at the press point: only what a release commits is hidden. + expect(erasedIds(pressed)).toEqual(new Set(['newer'])) + + const after = endGesture(pressed, 1) + expect(ids(after)).toEqual(['older']) + expect(undoShape(after.doc).shapes.map((shape) => shape.id)).toEqual(['older', 'newer']) + }) + + it('keeps a click a click when a tap jitters by a fraction of a pixel', () => { + const pressed = erase(editorWith(line('older', 0), line('newer', 0)), [{ x: 50, y: 0 }]) + const jittered = moveGesture(pressed, 1, { x: 50.6, y: 0.4 }, noText) + + expect(jittered).toBe(pressed) + expect(ids(endGesture(jittered, 1))).toEqual(['older']) + }) + + it('keeps a click a click when the pointer wanders inside the slop and back', () => { + const before = editorWith(line('older', 0), line('newer', 0), line('near', 12)) + // `near` is out of reach of the press point but within reach 3.5px below it. + const wandered = erase(before, [ + { x: 50, y: 0 }, + { x: 50, y: 3.5 }, + { x: 50, y: 0 } + ]) + + expect(erasedIds(wandered)).toEqual(new Set(['newer'])) + expect(ids(endGesture(wandered, 1))).toEqual(['older', 'near']) + }) + + it('becomes a drag once the pointer leaves the slop', () => { + const dragged = erase(editorWith(line('older', 0), line('newer', 0)), [ + { x: 50, y: 0 }, + { x: 54, y: 0 } + ]) + + expect(erasedIds(dragged)).toEqual(new Set(['older', 'newer'])) + }) + + it('erases every mark under the press point once the click becomes a drag', () => { + const before = editorWith(line('older', 0), line('newer', 0), line('away', 200)) + // The drag leaves the marks behind straight away, so only the press point touched them. + const dragged = erase(before, [ + { x: 50, y: 0 }, + { x: 50, y: 60 } + ]) + expect(erasedIds(dragged)).toEqual(new Set(['older', 'newer'])) + + const after = endGesture(dragged, 1) + expect(ids(after)).toEqual(['away']) + expect(undoShape(after.doc).shapes.map((shape) => shape.id)).toEqual(['older', 'newer', 'away']) + }) + + it('records a whole drag as one undo step that redo replays', () => { + const before = editorWith(line('a', 0), line('b', 100), line('c', 200)) + const after = endGesture( + erase(before, [ + { x: 50, y: -50 }, + { x: 50, y: 150 } + ]), + 1 + ) + + expect(ids(after)).toEqual(['c']) + const undone = undoShape(after.doc) + expect(undone.shapes.map((shape) => shape.id)).toEqual(['a', 'b', 'c']) + expect(redoShape(undone).shapes.map((shape) => shape.id)).toEqual(['c']) + }) + + it('leaves the document and its history untouched when nothing was hit', () => { + const before = editorWith(line('a', 0)) + const after = endGesture(erase(before, [{ x: 50, y: 300 }]), 1) + + expect(after.doc).toBe(before.doc) + }) + + it('does not erase a mark the pointer curved around between events', () => { + // Down left of the stroke, around its end, to its far side: the chord from the + // first to the last point crosses it, but no swept segment does. + const after = endGesture( + erase(editorWith(line('a', 0)), [ + { x: 50, y: -40 }, + { x: 160, y: -40 }, + { x: 160, y: 40 }, + { x: 50, y: 40 } + ]), + 1 + ) + + expect(ids(after)).toEqual(['a']) + }) + + it('keeps the same erased set while a drag hits nothing new', () => { + const begun = beginEraseGesture(editorWith(line('a', 0)), 1, { x: 50, y: 0 }, noText) + const moved = moveGesture(begun, 1, { x: 50, y: 300 }, noText) + + expect(erasedIds(begun)).toEqual(new Set(['a'])) + expect(erasedIds(moved)).toBe(erasedIds(begun)) + }) + + it('erases a text label the drag passes through', () => { + const label: TextShape = { + id: 'label', + kind: 'text', + color: '#111827', + at: { x: 200, y: 200 }, + text: 'note', + fontSize: 18 + } + const inkBox = () => ({ x: 200, y: 200, width: 60, height: 20 }) + const before: MarkupEditorState = { + doc: commitShape(createMarkupDocument(), label), + gesture: null + } + const begun = beginEraseGesture(before, 1, { x: 100, y: 210 }, inkBox) + const after = endGesture(moveGesture(begun, 1, { x: 230, y: 210 }, inkBox), 1) + + expect(erasedIds(begun)).toEqual(new Set()) + expect(ids(after)).toEqual([]) + }) + + it('ignores a second pointer while a gesture is in flight', () => { + const first = erase(editorWith(line('a', 0), line('b', 100)), [{ x: 50, y: 0 }], 1) + + const secondDown = beginEraseGesture(first, 2, { x: 50, y: 100 }, noText) + expect(secondDown).toBe(first) + expect(moveGesture(first, 2, { x: 50, y: 100 }, noText)).toBe(first) + expect(endGesture(first, 2)).toBe(first) + + expect(ids(endGesture(first, 1))).toEqual(['b']) + }) + + it('settles an erase whose release was lost when the same pointer presses again', () => { + const swept = erase(editorWith(line('a', 0), line('b', 100), line('c', 200)), [{ x: 50, y: 0 }]) + + // No release for pointer 1; it presses again below every mark and drags a little. + const after = endGesture( + erase(swept, [ + { x: 50, y: 300 }, + { x: 60, y: 300 } + ]), + 1 + ) + + // The new press must not sweep from the stale point, which would cross b and c. + expect(ids(after)).toEqual(['b', 'c']) + expect(undoShape(after.doc).shapes.map((shape) => shape.id)).toEqual(['a', 'b', 'c']) + }) + + it('commits against the document as it is on release', () => { + const swept = erase(editorWith(line('a', 0)), [{ x: 50, y: 0 }]) + // The document moved under the gesture and no longer holds the mark. + const without = { ...swept, doc: undoShape(swept.doc) } + const after = endGesture(without, 1) + + expect(after.doc).toBe(without.doc) + expect(after.gesture).toBeNull() + }) +}) + +describe('draw gesture', () => { + it('keeps every point of a freehand stroke', () => { + const begun = beginDrawGesture(editorWith(), 1, { + id: 'pen', + kind: 'highlight', + color: '#eab308', + width: 4, + points: [{ x: 0, y: 0 }] + }) + const moved = [ + { x: 10, y: 5 }, + { x: 20, y: 0 } + ].reduce((state, point) => moveGesture(state, 1, point, noText), begun) + + expect(endGesture(moved, 1).doc.shapes).toMatchObject([ + { + points: [ + { x: 0, y: 0 }, + { x: 10, y: 5 }, + { x: 20, y: 0 } + ] + } + ]) + }) + + it('commits a stroke whose release was lost and starts afresh on the next press', () => { + const stale = moveGesture( + beginDrawGesture(editorWith(), 1, line('old', 0)), + 1, + { x: 120, y: 0 }, + noText + ) + + const next = beginDrawGesture(stale, 1, { + id: 'new', + kind: 'pen', + color: '#ef4444', + width: 2, + points: [{ x: 0, y: 300 }] + }) + const ended = endGesture(moveGesture(next, 1, { x: 50, y: 300 }, noText), 1) + + expect(ended.doc.shapes.map((shape) => shape.id)).toEqual(['old', 'new']) + expect(ended.doc.shapes[1]).toMatchObject({ + points: [ + { x: 0, y: 300 }, + { x: 50, y: 300 } + ] + }) + }) + + it('extends the shape on move and commits it once on release', () => { + const begun = beginDrawGesture(editorWith(), 1, { + id: 'new', + kind: 'rect', + color: '#ef4444', + width: 4, + from: { x: 10, y: 10 }, + to: { x: 10, y: 10 } + }) + // A second pointer landing mid-stroke must not replace it. + expect(beginDrawGesture(begun, 2, line('other', 0))).toBe(begun) + const moved = moveGesture(begun, 1, { x: 60, y: 40 }, noText) + const ended = endGesture(moved, 1) + + expect(ended.doc.shapes).toEqual([ + { + id: 'new', + kind: 'rect', + color: '#ef4444', + width: 4, + from: { x: 10, y: 10 }, + to: { x: 60, y: 40 } + } + ]) + // A stray second release has no gesture left to commit. + expect(endGesture(ended, 1)).toBe(ended) + }) + + it.each(['rect', 'ellipse', 'arrow'] as const)( + 'saves no %s released without being dragged, so it adds no undo step', + (kind) => { + const before = editorWith(line('a', 0)) + const pressed = beginDrawGesture(before, 1, pressedShape(kind, { x: 50, y: 0 })) + // Undo with only the unmoved press held goes straight to the last mark. + expect(ids(undoMarkup(pressed))).toEqual([]) + + expect(endGesture(pressed, 1).doc).toBe(before.doc) + } + ) + + it('lets an eraser click take a mark where a shape was pressed without dragging', () => { + const at = { x: 50, y: 0 } + const clicked = endGesture( + beginDrawGesture(editorWith(line('a', 0)), 1, pressedShape('rect', at)), + 1 + ) + + expect(ids(endGesture(erase(clicked, [at]), 1))).toEqual([]) + }) + + it('still saves a pen tap, which leaves a visible dot', () => { + const tapped = endGesture( + beginDrawGesture(editorWith(), 1, { ...line('dot', 0), points: [{ x: 50, y: 0 }] }), + 1 + ) + + expect(ids(tapped)).toEqual(['dot']) + }) +}) + +describe('cancelled pointer', () => { + it('discards a stroke in progress without adding history', () => { + const before = editorWith(line('a', 0)) + const held = moveGesture( + beginDrawGesture(before, 1, line('held', 300)), + 1, + { x: 50, y: 300 }, + noText + ) + + const cancelled = cancelGesture(held, 1) + expect(cancelled.doc).toBe(before.doc) + expect(cancelled.gesture).toBeNull() + }) + + it('discards an erase, so the marks it hid stay, and a later lost capture does nothing', () => { + const before = editorWith(line('a', 0), line('b', 100)) + const held = erase(before, [ + { x: 50, y: -50 }, + { x: 50, y: 150 } + ]) + expect(erasedIds(held)).toEqual(new Set(['a', 'b'])) + + const cancelled = cancelGesture(held, 1) + expect(cancelled.doc).toBe(before.doc) + // lostpointercapture follows pointercancel and ends the same pointer's gesture. + expect(endGesture(cancelled, 1)).toBe(cancelled) + }) + + it('ignores a cancel from a pointer that does not own the gesture', () => { + const held = erase(editorWith(line('a', 0)), [{ x: 50, y: 0 }], 1) + + expect(cancelGesture(held, 2)).toBe(held) + }) +}) + +describe('history commands mid-gesture', () => { + // A stroke from (0, 300) to (50, 300), still held down. + function drawing(state: MarkupEditorState): MarkupEditorState { + return moveGesture(beginDrawGesture(state, 1, line('held', 300)), 1, { x: 50, y: 300 }, noText) + } + + // The rest of the held drag, which must neither draw nor erase. + function finishDrag(state: MarkupEditorState): MarkupEditorState { + return endGesture(moveGesture(state, 1, { x: 50, y: 100 }, noText), 1) + } + + it('undo mid-stroke drops only the stroke, and the next undo takes the last mark', () => { + const before = editorWith(line('a', 0), line('b', 100)) + const held = drawing(before) + expect(canUndoMarkup(held)).toBe(true) + + const undone = undoMarkup(held) + expect(undone.doc).toBe(before.doc) + expect(undone.gesture).toBeNull() + + const released = finishDrag(undone) + expect(released.doc).toBe(before.doc) + expect(released.gesture).toBeNull() + expect(ids(undoMarkup(released))).toEqual(['a']) + }) + + it('undo mid-erase restores the hidden marks and leaves the document alone', () => { + const before = editorWith(line('a', 0), line('b', 100)) + const held = erase(before, [{ x: 50, y: 0 }]) + expect(erasedIds(held)).toEqual(new Set(['a'])) + + const released = finishDrag(undoMarkup(held)) + expect(released.doc).toBe(before.doc) + expect(ids(undoMarkup(released))).toEqual(['a']) + }) + + it('undo during an erase that hides nothing undoes the last mark and ends the erase', () => { + const before = editorWith(line('a', 0), line('b', 100)) + const held = erase(before, [{ x: 50, y: 300 }]) + expect(erasedIds(held)).toEqual(new Set()) + expect(canUndoMarkup(held)).toBe(true) + + const undone = undoMarkup(held) + expect(ids(undone)).toEqual(['a']) + expect(undone.gesture).toBeNull() + // The rest of the drag crosses the restored `b`, but must not erase it. + expect(ids(finishDrag(undone))).toEqual(['a']) + }) + + it('undo during an erase that hides nothing, with no history, keeps the erase', () => { + const held = erase(editorWith(), [{ x: 50, y: 300 }]) + expect(canUndoMarkup(held)).toBe(false) + + expect(undoMarkup(held)).toBe(held) + }) + + it('an unmoved shape press on an empty canvas leaves undo disabled and is kept', () => { + const held = beginDrawGesture(editorWith(), 1, pressedShape('ellipse', { x: 50, y: 0 })) + expect(canUndoMarkup(held)).toBe(false) + expect(undoMarkup(held)).toBe(held) + + // Dragging it out still draws the ellipse. + const ended = endGesture(moveGesture(held, 1, { x: 90, y: 40 }, noText), 1) + expect(ids(ended)).toEqual(['ellipse']) + }) + + it('a second undo while the pointer is still down undoes the last committed mark', () => { + const held = undoMarkup(drawing(editorWith(line('a', 0), line('b', 100)))) + + const again = undoMarkup(held) + expect(ids(again)).toEqual(['a']) + expect(ids(finishDrag(again))).toEqual(['a']) + }) + + it('redo mid-gesture cancels the gesture, then redoes', () => { + const undone = undoMarkup(editorWith(line('a', 0), line('b', 100))) + + const redone = redoMarkup(drawing(undone)) + expect(ids(redone)).toEqual(['a', 'b']) + expect(ids(finishDrag(redone))).toEqual(['a', 'b']) + }) + + it('redo with nothing to redo keeps the held gesture', () => { + const held = erase(editorWith(line('a', 0), line('b', 100)), [{ x: 50, y: 0 }]) + + expect(redoMarkup(held)).toBe(held) + expect(ids(endGesture(held, 1))).toEqual(['b']) + }) + + it('clear mid-erase cancels the erase, then clears as one undo step', () => { + const held = erase(editorWith(line('a', 0), line('b', 100)), [{ x: 50, y: 0 }]) + + const released = finishDrag(applyDocumentCommand(held, clearShapes)) + expect(ids(released)).toEqual([]) + expect(ids(undoMarkup(released))).toEqual(['a', 'b']) + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/markup-gesture.ts b/src/renderer/src/components/browser-pane/annotate/markup-gesture.ts new file mode 100644 index 00000000000..2a134490d70 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/markup-gesture.ts @@ -0,0 +1,213 @@ +// The markup editor's document plus its in-flight pointer gesture, advanced by +// pure transitions. Keeping both in one value lets a release commit from the +// latest state, and keeps the transitions safe to run twice under StrictMode. + +import { + canRedo, + canUndo, + commitShape, + redoShape, + setShapes, + undoShape, + type MarkupDocument, + type MarkupPoint, + type MarkupShape, + type TextShape +} from './markup-drawing-model' +import { + shapesTouchedBySweep, + topmostShapeAt, + type TextInkBoxMeasurer +} from './markup-shape-hit-test' + +export type DraggedShape = Exclude + +// Why pointerId: a second pointer (another finger) must not restart or steer a +// gesture the first one owns. +export type MarkupGesture = + | { kind: 'draw'; pointerId: number; shape: DraggedShape } + | { + kind: 'erase' + pointerId: number + origin: MarkupPoint + // A 'pressed' erase is still a click; it becomes 'dragging' past ERASER_CLICK_SLOP. + phase: 'pressed' | 'dragging' + last: MarkupPoint + erasedIds: ReadonlySet + } + +export type MarkupEditorState = { doc: MarkupDocument; gesture: MarkupGesture | null } + +// How far (CSS px) an erase press may travel and stay a click: touch and pen taps jitter. +const ERASER_CLICK_SLOP = 4 + +export function beginDrawGesture( + state: MarkupEditorState, + pointerId: number, + shape: DraggedShape +): MarkupEditorState { + const settled = settleMissedRelease(state, pointerId) + return settled.gesture ? settled : { ...settled, gesture: { kind: 'draw', pointerId, shape } } +} + +export function beginEraseGesture( + state: MarkupEditorState, + pointerId: number, + point: MarkupPoint, + measureTextInkBox: TextInkBoxMeasurer +): MarkupEditorState { + const settled = settleMissedRelease(state, pointerId) + if (settled.gesture) { + return settled + } + // Why: a click takes only the mark on top. Leaving the slop makes it a drag, + // and that sweep starts at the press point, so it takes the rest under it too. + const topmost = topmostShapeAt(settled.doc.shapes, point, measureTextInkBox) + return { + ...settled, + gesture: { + kind: 'erase', + pointerId, + origin: point, + phase: 'pressed', + last: point, + erasedIds: new Set(topmost ? [topmost.id] : []) + } + } +} + +// Why: a pointer cannot press twice without releasing, so a press from the +// gesture's own pointer means its release was lost; settle it as that release +// would have, instead of letting it block the new press and steer from a stale point. +function settleMissedRelease(state: MarkupEditorState, pointerId: number): MarkupEditorState { + return state.gesture?.pointerId === pointerId ? endGesture(state, pointerId) : state +} + +export function moveGesture( + state: MarkupEditorState, + pointerId: number, + point: MarkupPoint, + measureTextInkBox: TextInkBoxMeasurer +): MarkupEditorState { + const { gesture } = state + if (gesture?.pointerId !== pointerId) { + return state + } + if (gesture.kind === 'erase') { + if (gesture.phase === 'pressed') { + const { origin } = gesture + if (Math.hypot(point.x - origin.x, point.y - origin.y) < ERASER_CLICK_SLOP) { + return state + } + // `last` is still the origin here, so the first sweep starts at the press point. + const dragging: EraseGesture = { ...gesture, phase: 'dragging' } + return { + ...state, + gesture: sweepEraser(dragging, state.doc.shapes, point, measureTextInkBox) + } + } + return { ...state, gesture: sweepEraser(gesture, state.doc.shapes, point, measureTextInkBox) } + } + return { ...state, gesture: { ...gesture, shape: dragShapeTo(gesture.shape, point) } } +} + +// Commits the gesture as one undoable step. A gesture with no visible result +// leaves history untouched so Undo never has a step with no visible effect. +export function endGesture(state: MarkupEditorState, pointerId: number): MarkupEditorState { + const { doc, gesture } = state + if (gesture?.pointerId !== pointerId) { + return state + } + if (gesture.kind === 'draw') { + return { doc: hasNoSize(gesture.shape) ? doc : commitShape(doc, gesture.shape), gesture: null } + } + const remaining = doc.shapes.filter((shape) => !gesture.erasedIds.has(shape.id)) + return { + doc: remaining.length === doc.shapes.length ? doc : setShapes(doc, remaining), + gesture: null + } +} + +// Why: a cancelled pointer (an OS gesture or palm rejection took it) was not a +// deliberate release, so its gesture is discarded rather than committed. +export function cancelGesture(state: MarkupEditorState, pointerId: number): MarkupEditorState { + return state.gesture?.pointerId === pointerId ? { ...state, gesture: null } : state +} + +// Why: Undo mid-gesture takes back only that gesture, as the newest step, so the +// next Undo takes back the last committed mark rather than both at once. A gesture +// that shows nothing yet (an erase hiding nothing, an unmoved shape press) is no +// step, so Undo goes to the document. Either way the gesture is dropped, so the +// rest of that drag does nothing until the next press. +export function undoMarkup(state: MarkupEditorState): MarkupEditorState { + if (gestureHasEffect(state.gesture)) { + return { ...state, gesture: null } + } + return canUndo(state.doc) ? { doc: undoShape(state.doc), gesture: null } : state +} + +export function canUndoMarkup(state: MarkupEditorState): boolean { + return gestureHasEffect(state.gesture) || canUndo(state.doc) +} + +// Whether releasing the gesture would change the document. +function gestureHasEffect(gesture: MarkupGesture | null): boolean { + if (gesture === null) { + return false + } + return gesture.kind === 'draw' ? !hasNoSize(gesture.shape) : gesture.erasedIds.size > 0 +} + +// Why: a rectangle, ellipse or arrow pressed without dragging paints nothing; saved, +// it would be an invisible topmost mark that soaks up the next eraser click there. +function hasNoSize(shape: DraggedShape): boolean { + return ( + shape.kind !== 'pen' && + shape.kind !== 'highlight' && + shape.from.x === shape.to.x && + shape.from.y === shape.to.y + ) +} + +// Why: with nothing to redo the document stays put, so a held gesture is kept. +export function redoMarkup(state: MarkupEditorState): MarkupEditorState { + return canRedo(state.doc) ? applyDocumentCommand(state, redoShape) : state +} + +// Redo and Clear replace the document, so the gesture made against it is dropped. +export function applyDocumentCommand( + state: MarkupEditorState, + command: (doc: MarkupDocument) => MarkupDocument +): MarkupEditorState { + return { doc: command(state.doc), gesture: null } +} + +type EraseGesture = Extract + +function sweepEraser( + gesture: EraseGesture, + shapes: readonly MarkupShape[], + point: MarkupPoint, + measureTextInkBox: TextInkBoxMeasurer +): EraseGesture { + const touched = shapesTouchedBySweep( + shapes.filter((shape) => !gesture.erasedIds.has(shape.id)), + gesture.last, + point, + measureTextInkBox + ) + // Why: keep the same Set when nothing new was hit so the cached canvas layer + // is not re-rasterized on every pointermove. + const erasedIds = + touched.length === 0 + ? gesture.erasedIds + : new Set([...gesture.erasedIds, ...touched.map((shape) => shape.id)]) + return { ...gesture, last: point, erasedIds } +} + +function dragShapeTo(shape: DraggedShape, point: MarkupPoint): DraggedShape { + if (shape.kind === 'pen' || shape.kind === 'highlight') { + return { ...shape, points: [...shape.points, point] } + } + return { ...shape, to: point } +} diff --git a/src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.test.ts b/src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.test.ts new file mode 100644 index 00000000000..67cbc10e497 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { arrowHeadGeometry, type MarkupPoint, type MarkupShape } from './markup-drawing-model' +import { shapesTouchedBySweep, topmostShapeAt } from './markup-shape-hit-test' + +// Pinned here on purpose: how close the pointer must come is part of the behaviour. +const ERASER_RADIUS = 8 + +const base = { id: 's', color: '#ef4444' } +const noText = () => null + +function touches(shape: MarkupShape, from: MarkupPoint, to: MarkupPoint = from): boolean { + return shapesTouchedBySweep([shape], from, to, noText).length === 1 +} + +describe('shapesTouchedBySweep', () => { + it('hits a pen stroke within its half-width plus the eraser radius, and not beyond', () => { + const pen: MarkupShape = { + ...base, + kind: 'pen', + width: 4, + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ] + } + const reach = 2 + ERASER_RADIUS + expect(touches(pen, { x: 50, y: reach })).toBe(true) + expect(touches(pen, { x: 50, y: reach + 1 })).toBe(false) + // Past the end of the stroke the round cap still counts. + expect(touches(pen, { x: 100 + reach, y: 0 })).toBe(true) + expect(touches(pen, { x: 100 + reach + 1, y: 0 })).toBe(false) + // A sweep that passes the end without crossing the stroke or ending near it. + expect(touches(pen, { x: 105, y: -50 }, { x: 105, y: 50 })).toBe(true) + expect(touches(pen, { x: -5, y: -50 }, { x: -5, y: 50 })).toBe(true) + // A drag that stops beside the stroke without crossing it. + expect(touches(pen, { x: 50, y: -50 }, { x: 50, y: -reach })).toBe(true) + expect(touches(pen, { x: 50, y: -50 }, { x: 50, y: -reach - 1 })).toBe(false) + }) + + it('hits a thin stroke that a fast drag crosses between two pointer events', () => { + const pen: MarkupShape = { + ...base, + kind: 'pen', + width: 2, + points: [ + { x: 50, y: -100 }, + { x: 50, y: 100 } + ] + } + expect(touches(pen, { x: 0, y: 0 }, { x: 100, y: 0 })).toBe(true) + expect(touches(pen, { x: 0, y: 0 })).toBe(false) + expect(touches(pen, { x: 100, y: 0 })).toBe(false) + }) + + it('hits the dot a single tap leaves', () => { + const dot: MarkupShape = { ...base, kind: 'pen', width: 8, points: [{ x: 10, y: 10 }] } + expect(touches(dot, { x: 10 + 4 + ERASER_RADIUS, y: 10 })).toBe(true) + expect(touches(dot, { x: 10 + 4 + ERASER_RADIUS + 1, y: 10 })).toBe(false) + }) + + it('treats a highlight as the fat stroke it is drawn as', () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ] + const highlight: MarkupShape = { ...base, kind: 'highlight', width: 4, points } + // A width-4 highlight is drawn 16px wide, so its ink reaches 8px from the centre line. + const edge = { x: 50, y: 8 + ERASER_RADIUS } + expect(touches(highlight, edge)).toBe(true) + expect(touches(highlight, { x: 50, y: 8 + ERASER_RADIUS + 1 })).toBe(false) + expect(touches({ ...base, kind: 'pen', width: 4, points }, edge)).toBe(false) + }) + + it('hits an arrow on its head wings as well as its shaft', () => { + const from = { x: 0, y: 0 } + const to = { x: 200, y: 0 } + const arrow: MarkupShape = { ...base, kind: 'arrow', width: 8, from, to } + const head = arrowHeadGeometry(from, to, 8) + expect(head).not.toBeNull() + // The wing tips sit well off the shaft, so only the head geometry can match them. + for (const wing of head ? [head.left, head.right] : []) { + expect(Math.abs(wing.y)).toBeGreaterThan(4 + ERASER_RADIUS) + expect(touches(arrow, wing)).toBe(true) + } + expect(touches(arrow, { x: 100, y: 4 + ERASER_RADIUS })).toBe(true) + expect(touches(arrow, { x: 100, y: 4 + ERASER_RADIUS + 1 })).toBe(false) + }) + + it('hits a rectangle on its border but not in its hollow interior', () => { + const rect: MarkupShape = { + ...base, + kind: 'rect', + width: 4, + from: { x: 300, y: 200 }, + to: { x: 100, y: 100 } + } + expect(touches(rect, { x: 100 - 2 - ERASER_RADIUS, y: 150 })).toBe(true) + expect(touches(rect, { x: 100 - 2 - ERASER_RADIUS - 1, y: 150 })).toBe(false) + for (const onEdge of [ + { x: 200, y: 100 }, + { x: 300, y: 150 }, + { x: 200, y: 200 } + ]) { + expect(touches(rect, onEdge)).toBe(true) + } + expect(touches(rect, { x: 200, y: 150 })).toBe(false) + expect(touches(rect, { x: 150, y: 150 }, { x: 250, y: 150 })).toBe(false) + }) + + it('hits a large ellipse anywhere on its outline but not inside it', () => { + const ellipse: MarkupShape = { + ...base, + kind: 'ellipse', + width: 2, + from: { x: 200, y: 100 }, + to: { x: 1400, y: 700 } + } + const outline = (degrees: number, grow = 0): MarkupPoint => { + const angle = (degrees * Math.PI) / 180 + return { x: 800 + (600 + grow) * Math.cos(angle), y: 400 + (300 + grow) * Math.sin(angle) } + } + // Odd angles on purpose: a coarse polygon approximation drifts furthest from + // the true outline between its vertices. + for (const degrees of [0, 11.25, 33.75, 101.25, 137, 222, 303.75]) { + expect(touches(ellipse, outline(degrees))).toBe(true) + } + expect(touches(ellipse, outline(0, 1 + ERASER_RADIUS))).toBe(true) + expect(touches(ellipse, outline(0, 1 + ERASER_RADIUS + 1))).toBe(false) + expect(touches(ellipse, { x: 800, y: 400 })).toBe(false) + expect(touches(ellipse, outline(0, -30))).toBe(false) + }) + + it('follows the curve of a small ellipse', () => { + const circle: MarkupShape = { + ...base, + kind: 'ellipse', + width: 2, + from: { x: 0, y: 0 }, + to: { x: 20, y: 20 } + } + const reach = 1 + ERASER_RADIUS + // 30 degrees is mid-side for the hexagon the chord limit alone would give this circle. + const at = (radius: number): MarkupPoint => ({ + x: 10 + radius * Math.cos(Math.PI / 6), + y: 10 + radius * Math.sin(Math.PI / 6) + }) + expect(touches(circle, at(10 + reach - 0.5))).toBe(true) + expect(touches(circle, at(10 + reach + 0.5))).toBe(false) + }) + + it('hits text anywhere in its measured ink box, including a sweep that ends inside it', () => { + const text: MarkupShape = { + ...base, + kind: 'text', + at: { x: 100, y: 100 }, + text: 'note', + fontSize: 18 + } + const box = () => ({ x: 98, y: 97, width: 60, height: 26 }) + const hit = (from: MarkupPoint, to: MarkupPoint = from) => + shapesTouchedBySweep([text], from, to, box).length === 1 + expect(hit({ x: 128, y: 110 })).toBe(true) + expect(hit({ x: 0, y: 110 }, { x: 128, y: 110 })).toBe(true) + expect(hit({ x: 98 + 60 + ERASER_RADIUS, y: 110 })).toBe(true) + expect(hit({ x: 98 + 60 + ERASER_RADIUS + 1, y: 110 })).toBe(false) + expect(hit({ x: 98 - ERASER_RADIUS - 1, y: 110 })).toBe(false) + expect(hit({ x: 128, y: 97 - ERASER_RADIUS - 1 })).toBe(false) + expect(hit({ x: 128, y: 97 + 26 + ERASER_RADIUS + 1 })).toBe(false) + // Without a canvas to measure on there is no drawn ink to hit. + expect(touches(text, { x: 128, y: 110 })).toBe(false) + }) + + it('returns every touched shape, not only the topmost', () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ] + const shapes: MarkupShape[] = [ + { id: 'under', color: '#eab308', kind: 'highlight', width: 4, points }, + { id: 'over', color: '#ef4444', kind: 'pen', width: 2, points }, + { id: 'away', color: '#ef4444', kind: 'pen', width: 2, points: [{ x: 0, y: 300 }] } + ] + const ids = shapesTouchedBySweep(shapes, { x: 50, y: 0 }, { x: 50, y: 0 }, noText).map( + (shape) => shape.id + ) + expect(ids).toEqual(['under', 'over']) + }) +}) + +describe('topmostShapeAt', () => { + it('returns the mark drawn last among those under the point', () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ] + const shapes: MarkupShape[] = [ + { id: 'under', color: '#eab308', kind: 'highlight', width: 4, points }, + { id: 'over', color: '#ef4444', kind: 'pen', width: 2, points }, + { id: 'away', color: '#ef4444', kind: 'pen', width: 2, points: [{ x: 0, y: 300 }] } + ] + expect(topmostShapeAt(shapes, { x: 50, y: 0 }, noText)?.id).toBe('over') + expect(topmostShapeAt(shapes, { x: 50, y: 150 }, noText)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.ts b/src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.ts new file mode 100644 index 00000000000..ca3dbf8f58f --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.ts @@ -0,0 +1,187 @@ +// Hit testing for the markup eraser. Pure geometry over the same model helpers +// the renderer strokes with, so "the ink you touch" matches what is drawn. + +import { + arrowHeadGeometry, + highlightWidth, + normalizeRect, + strokeDotRadius, + type MarkupPoint, + type MarkupShape, + type NormalizedRect, + type TextShape +} from './markup-drawing-model' + +// How close (CSS px) the pointer must come to a mark's ink to erase it. +const ERASER_RADIUS = 8 + +// Longest chord used to approximate an ellipse; keeps the error far below ERASER_RADIUS. +const ELLIPSE_MAX_CHORD = 12 +const ELLIPSE_MIN_SEGMENTS = 16 + +/** Ink box of a drawn text shape, or null when there is no canvas to measure with. */ +export type TextInkBoxMeasurer = (shape: TextShape) => NormalizedRect | null + +type StrokedShape = Exclude +type StrokedInk = { paths: MarkupPoint[][]; halfWidth: number } + +// Why a swept segment rather than a point: pointermove events are sparse, so a +// fast drag would otherwise jump over a thin stroke. +export function shapesTouchedBySweep( + shapes: readonly MarkupShape[], + from: MarkupPoint, + to: MarkupPoint, + measureTextInkBox: TextInkBoxMeasurer +): MarkupShape[] { + return shapes.filter((shape) => sweepTouchesShape(shape, from, to, measureTextInkBox)) +} + +// The mark drawn last among those under `point`, i.e. the one painted on top. +export function topmostShapeAt( + shapes: readonly MarkupShape[], + point: MarkupPoint, + measureTextInkBox: TextInkBoxMeasurer +): MarkupShape | undefined { + for (let i = shapes.length - 1; i >= 0; i -= 1) { + if (sweepTouchesShape(shapes[i], point, point, measureTextInkBox)) { + return shapes[i] + } + } + return undefined +} + +function sweepTouchesShape( + shape: MarkupShape, + from: MarkupPoint, + to: MarkupPoint, + measureTextInkBox: TextInkBoxMeasurer +): boolean { + return shape.kind === 'text' + ? sweepTouchesBox(from, to, measureTextInkBox(shape)) + : sweepTouchesStroke(from, to, strokedInk(shape)) +} + +function sweepTouchesStroke(from: MarkupPoint, to: MarkupPoint, ink: StrokedInk): boolean { + const reach = ink.halfWidth + ERASER_RADIUS + return ink.paths.some((path) => sweepDistanceToPath(from, to, path) <= reach) +} + +function sweepTouchesBox(from: MarkupPoint, to: MarkupPoint, box: NormalizedRect | null): boolean { + if (!box) { + return false + } + // A sweep that never crosses the outline is entirely inside or entirely outside. + const inside = + from.x >= box.x && + from.x <= box.x + box.width && + from.y >= box.y && + from.y <= box.y + box.height + return inside || sweepDistanceToPath(from, to, rectOutline(box)) <= ERASER_RADIUS +} + +// The polylines a shape is stroked along. Rect and ellipse are hollow, so only +// their outline is ink. +function strokedInk(shape: StrokedShape): StrokedInk { + switch (shape.kind) { + case 'pen': + return { paths: [shape.points], halfWidth: strokeDotRadius(shape.width) } + case 'highlight': + return { paths: [shape.points], halfWidth: strokeDotRadius(highlightWidth(shape.width)) } + case 'arrow': { + const head = arrowHeadGeometry(shape.from, shape.to, shape.width) + const shaft = [shape.from, shape.to] + return { + paths: head ? [shaft, [head.left, head.tip, head.right]] : [shaft], + halfWidth: shape.width / 2 + } + } + case 'rect': + return { + paths: [rectOutline(normalizeRect(shape.from, shape.to))], + halfWidth: shape.width / 2 + } + case 'ellipse': + return { + paths: [ellipseOutline(normalizeRect(shape.from, shape.to))], + halfWidth: shape.width / 2 + } + } +} + +function rectOutline(rect: NormalizedRect): MarkupPoint[] { + const right = rect.x + rect.width + const bottom = rect.y + rect.height + return [ + { x: rect.x, y: rect.y }, + { x: right, y: rect.y }, + { x: right, y: bottom }, + { x: rect.x, y: bottom }, + { x: rect.x, y: rect.y } + ] +} + +function ellipseOutline(rect: NormalizedRect): MarkupPoint[] { + const rx = rect.width / 2 + const ry = rect.height / 2 + const segments = Math.max( + ELLIPSE_MIN_SEGMENTS, + Math.ceil((2 * Math.PI * Math.max(rx, ry)) / ELLIPSE_MAX_CHORD) + ) + const outline: MarkupPoint[] = [] + for (let i = 0; i <= segments; i += 1) { + const angle = (i / segments) * 2 * Math.PI + outline.push({ x: rect.x + rx + rx * Math.cos(angle), y: rect.y + ry + ry * Math.sin(angle) }) + } + return outline +} + +function sweepDistanceToPath(from: MarkupPoint, to: MarkupPoint, path: MarkupPoint[]): number { + // A single-point path is a dot, which has no segment to iterate. + let nearest = path.length === 1 ? pointToSegmentDistance(path[0], from, to) : Infinity + for (let i = 1; i < path.length; i += 1) { + nearest = Math.min(nearest, segmentDistance(from, to, path[i - 1], path[i])) + } + return nearest +} + +function segmentDistance( + a1: MarkupPoint, + a2: MarkupPoint, + b1: MarkupPoint, + b2: MarkupPoint +): number { + if (segmentsCross(a1, a2, b1, b2)) { + return 0 + } + return Math.min( + pointToSegmentDistance(a1, b1, b2), + pointToSegmentDistance(a2, b1, b2), + pointToSegmentDistance(b1, a1, a2), + pointToSegmentDistance(b2, a1, a2) + ) +} + +// Strict crossing only; touching and collinear cases fall out of the endpoint distances. +function segmentsCross( + a1: MarkupPoint, + a2: MarkupPoint, + b1: MarkupPoint, + b2: MarkupPoint +): boolean { + return cross(a1, a2, b1) * cross(a1, a2, b2) < 0 && cross(b1, b2, a1) * cross(b1, b2, a2) < 0 +} + +function cross(origin: MarkupPoint, a: MarkupPoint, b: MarkupPoint): number { + return (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x) +} + +function pointToSegmentDistance(point: MarkupPoint, a: MarkupPoint, b: MarkupPoint): number { + const dx = b.x - a.x + const dy = b.y - a.y + const lengthSquared = dx * dx + dy * dy + const t = + lengthSquared === 0 + ? 0 + : Math.min(1, Math.max(0, ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared)) + return Math.hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy)) +} diff --git a/src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts b/src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts index 444f8d01265..295ab6c5b94 100644 --- a/src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts +++ b/src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { drawShape } from './markup-shape-render' +import { drawShape, textInkBox } from './markup-shape-render' import { HIGHLIGHT_ALPHA, type MarkupShape } from './markup-drawing-model' // Minimal recording stand-in for CanvasRenderingContext2D — drawShape only uses @@ -24,6 +24,16 @@ function makeRecordingCtx() { strokeRect: record('strokeRect'), fillText: record('fillText'), strokeText: record('strokeText'), + // Records the style in force when measuring, and returns fixed ink metrics. + measureText: () => { + calls.push({ method: 'measureText', args: [ctx.font, ctx.textBaseline] }) + return { + actualBoundingBoxLeft: 1, + actualBoundingBoxRight: 40, + actualBoundingBoxAscent: -2, + actualBoundingBoxDescent: 20 + } + }, lineCap: '', lineJoin: '', strokeStyle: '', @@ -151,3 +161,24 @@ describe('drawShape dispatch', () => { expect(methods('restore')).toHaveLength(1) }) }) + +describe('textInkBox', () => { + it('covers the measured glyphs plus the halo, measured in the style text is drawn with', () => { + const { ctx, methods } = makeRecordingCtx() + + const box = textInkBox(ctx, { + id: 't', + kind: 'text', + color: '#111827', + at: { x: 100, y: 200 }, + text: 'gy', + fontSize: 18 + }) + + const [font, textBaseline] = methods('measureText')[0].args + expect(font).toContain('18px') + expect(textBaseline).toBe('top') + // The halo is 3px wide at 18px type, so it adds 1.5px on every side. + expect(box).toEqual({ x: 97.5, y: 200.5, width: 44, height: 21 }) + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/markup-shape-render.ts b/src/renderer/src/components/browser-pane/annotate/markup-shape-render.ts index 1df81a939f9..8e7d65da654 100644 --- a/src/renderer/src/components/browser-pane/annotate/markup-shape-render.ts +++ b/src/renderer/src/components/browser-pane/annotate/markup-shape-render.ts @@ -6,10 +6,13 @@ import { arrowHeadGeometry, highlightWidth, normalizeRect, + strokeDotRadius, + textHaloWidth, HIGHLIGHT_ALPHA, type ArrowShape, type EllipseShape, type MarkupShape, + type NormalizedRect, type PenShape, type HighlightShape, type RectShape, @@ -80,7 +83,7 @@ function strokePolyline( // Why: a tap (single point) still leaves a visible dot. const point = points[0] ctx.beginPath() - ctx.arc(point.x, point.y, Math.max(width / 2, 1), 0, Math.PI * 2) + ctx.arc(point.x, point.y, strokeDotRadius(width), 0, Math.PI * 2) ctx.fill() return } @@ -125,19 +128,39 @@ function drawEllipse(ctx: CanvasRenderingContext2D, shape: EllipseShape): void { ctx.stroke() } -function drawText(ctx: CanvasRenderingContext2D, shape: TextShape): void { +// Shared by draw and measure so the measured ink box matches the drawn glyphs. +function applyTextStyle(ctx: CanvasRenderingContext2D, shape: TextShape): void { ctx.font = `600 ${shape.fontSize}px ${TEXT_FONT_FAMILY}` ctx.textBaseline = 'top' // Why: a thin contrasting halo keeps text legible over busy screenshots // regardless of the underlying pixels. - ctx.lineWidth = Math.max(shape.fontSize / 6, 2) - ctx.strokeStyle = haloColor(shape.color) + ctx.lineWidth = textHaloWidth(shape.fontSize) ctx.lineJoin = 'round' +} + +function drawText(ctx: CanvasRenderingContext2D, shape: TextShape): void { + applyTextStyle(ctx, shape) + ctx.strokeStyle = haloColor(shape.color) // Text comes from a single-line input, so there are no newlines to lay out. ctx.strokeText(shape.text, shape.at.x, shape.at.y) ctx.fillText(shape.text, shape.at.x, shape.at.y) } +// The box the drawn text (glyphs plus halo) actually covers. +export function textInkBox(ctx: CanvasRenderingContext2D, shape: TextShape): NormalizedRect { + ctx.save() + applyTextStyle(ctx, shape) + const metrics = ctx.measureText(shape.text) + ctx.restore() + const halo = textHaloWidth(shape.fontSize) + return { + x: shape.at.x - metrics.actualBoundingBoxLeft - halo / 2, + y: shape.at.y - metrics.actualBoundingBoxAscent - halo / 2, + width: metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight + halo, + height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + halo + } +} + // White text gets a dark halo, everything else a light halo. function haloColor(color: string): string { return color.toLowerCase() === '#ffffff' ? 'rgba(0,0,0,0.65)' : 'rgba(255,255,255,0.85)' diff --git a/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts b/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts index 6e6ba294028..95f8712fd91 100644 --- a/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts +++ b/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts @@ -1,12 +1,13 @@ // @vitest-environment happy-dom +import { StrictMode } from 'react' import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useMarkupEditor } from './useMarkupEditor' const realCrypto = globalThis.crypto -function pointerDownEvent(clientX: number, clientY: number): React.PointerEvent { +function pointerEvent(clientX: number, clientY: number): React.PointerEvent { return { button: 0, clientX, @@ -17,6 +18,13 @@ function pointerDownEvent(clientX: number, clientY: number): React.PointerEvent< } as unknown as React.PointerEvent } +function secondPointerEvent( + clientX: number, + clientY: number +): React.PointerEvent { + return { ...pointerEvent(clientX, clientY), pointerId: 2 } +} + describe('useMarkupEditor.commitPendingText in a non-secure browser context', () => { beforeEach(() => { // Match a non-secure browser context (LAN web client over plain HTTP): @@ -35,7 +43,7 @@ describe('useMarkupEditor.commitPendingText in a non-secure browser context', () const { result } = renderHook(() => useMarkupEditor(false, vi.fn())) act(() => result.current.setTool('text')) - act(() => result.current.onPointerDown(pointerDownEvent(5, 5))) + act(() => result.current.onPointerDown(pointerEvent(5, 5))) expect(result.current.pendingText).not.toBeNull() expect(() => act(() => result.current.commitPendingText('hello'))).not.toThrow() @@ -45,3 +53,164 @@ describe('useMarkupEditor.commitPendingText in a non-secure browser context', () expect(shape?.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/) }) }) + +describe('useMarkupEditor gestures', () => { + type Editor = ReturnType + + // A horizontal pen stroke from (0, y) to (100, y). + function drawLine(editor: { current: Editor }, y: number): void { + act(() => editor.current.onPointerDown(pointerEvent(0, y))) + act(() => editor.current.onPointerMove(pointerEvent(100, y))) + act(() => editor.current.onPointerUp(pointerEvent(100, y))) + } + + // Pointer positions are read relative to the canvas, so the editor needs one. + function renderEditor(options?: { wrapper: typeof StrictMode }): { current: Editor } { + const { result } = renderHook(() => useMarkupEditor(false, vi.fn()), options) + result.current.canvasRef.current = document.createElement('canvas') + return result + } + + it('commits a drawn stroke exactly once under StrictMode', () => { + const result = renderEditor({ wrapper: StrictMode }) + + drawLine(result, 0) + + expect(result.current.shapes).toHaveLength(1) + }) + + it('erases the touched mark, hiding it from the export before release, and undo restores it', () => { + const result = renderEditor({ wrapper: StrictMode }) + drawLine(result, 0) + drawLine(result, 200) + const [first, second] = result.current.shapes + + act(() => result.current.setTool('eraser')) + act(() => result.current.onPointerDown(pointerEvent(50, 0))) + expect(result.current.shapes).toEqual([second]) + + act(() => result.current.onPointerUp(pointerEvent(50, 0))) + expect(result.current.shapes).toEqual([second]) + + act(() => result.current.undo()) + expect(result.current.shapes).toEqual([first, second]) + }) + + it('shows a click on overlapping marks taking only the newest, and a drag taking both', () => { + const result = renderEditor({ wrapper: StrictMode }) + drawLine(result, 0) + drawLine(result, 0) + const [older, newer] = result.current.shapes + + act(() => result.current.setTool('eraser')) + act(() => result.current.onPointerDown(pointerEvent(50, 0))) + expect(result.current.shapes).toEqual([older]) + act(() => result.current.onPointerUp(pointerEvent(50, 0))) + expect(result.current.shapes).toEqual([older]) + + act(() => result.current.undo()) + act(() => result.current.onPointerDown(pointerEvent(50, 0))) + act(() => result.current.onPointerMove(pointerEvent(50, 40))) + expect(result.current.shapes).toEqual([]) + act(() => result.current.onPointerUp(pointerEvent(50, 40))) + + act(() => result.current.undo()) + expect(result.current.shapes).toEqual([older, newer]) + }) + + it('adds no undo step for an eraser click on empty space', () => { + const result = renderEditor() + drawLine(result, 0) + + act(() => result.current.setTool('eraser')) + act(() => result.current.onPointerDown(pointerEvent(50, 300))) + act(() => result.current.onPointerUp(pointerEvent(50, 300))) + act(() => result.current.undo()) + + expect(result.current.shapes).toEqual([]) + }) + + it('lets only the finger that started an erase steer and end it', () => { + const result = renderEditor() + drawLine(result, 0) + drawLine(result, 200) + const [first, second] = result.current.shapes + + act(() => result.current.setTool('eraser')) + act(() => result.current.onPointerDown(pointerEvent(50, 0))) + act(() => result.current.onPointerMove(secondPointerEvent(50, 200))) + expect(result.current.shapes).toEqual([second]) + + act(() => result.current.onPointerUp(secondPointerEvent(50, 200))) + act(() => result.current.onPointerMove(pointerEvent(50, 200))) + act(() => result.current.onPointerUp(pointerEvent(50, 200))) + expect(result.current.shapes).toEqual([]) + + // Both marks went in one gesture, so one undo brings both back. + act(() => result.current.undo()) + expect(result.current.shapes).toEqual([first, second]) + }) + + it('takes back only an in-flight erase on undo, and ignores the rest of that drag', () => { + const result = renderEditor({ wrapper: StrictMode }) + drawLine(result, 0) + drawLine(result, 200) + const [first, second] = result.current.shapes + + act(() => result.current.setTool('eraser')) + act(() => result.current.onPointerDown(pointerEvent(50, 0))) + expect(result.current.shapes).toEqual([second]) + act(() => result.current.undo()) + expect(result.current.shapes).toEqual([first, second]) + + // The held pointer sweeps across the second mark, then lifts. + act(() => result.current.onPointerMove(pointerEvent(50, 200))) + act(() => result.current.onPointerUp(pointerEvent(50, 200))) + expect(result.current.shapes).toEqual([first, second]) + + act(() => result.current.undo()) + expect(result.current.shapes).toEqual([first]) + }) + + it('takes back only an in-flight stroke on undo, even the first one', () => { + const result = renderEditor({ wrapper: StrictMode }) + expect(result.current.canUndo).toBe(false) + + act(() => result.current.onPointerDown(pointerEvent(0, 0))) + expect(result.current.canUndo).toBe(true) + act(() => result.current.undo()) + act(() => result.current.onPointerMove(pointerEvent(100, 0))) + act(() => result.current.onPointerUp(pointerEvent(100, 0))) + expect(result.current.shapes).toEqual([]) + expect(result.current.canUndo).toBe(false) + + drawLine(result, 50) + expect(result.current.shapes).toHaveLength(1) + }) + + it('keeps an in-flight erase when redo has nothing to redo', () => { + const result = renderEditor() + drawLine(result, 0) + drawLine(result, 200) + const [, second] = result.current.shapes + + act(() => result.current.setTool('eraser')) + act(() => result.current.onPointerDown(pointerEvent(50, 0))) + act(() => result.current.redo()) + act(() => result.current.onPointerUp(pointerEvent(50, 0))) + + expect(result.current.shapes).toEqual([second]) + }) + + it('drops an in-flight stroke when everything is cleared', () => { + const result = renderEditor() + + act(() => result.current.onPointerDown(pointerEvent(0, 0))) + act(() => result.current.onPointerMove(pointerEvent(100, 0))) + act(() => result.current.clear()) + act(() => result.current.onPointerMove(pointerEvent(150, 0))) + act(() => result.current.onPointerUp(pointerEvent(150, 0))) + + expect(result.current.shapes).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.ts b/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.ts index 91f1a4c0961..ecf8c78e992 100644 --- a/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.ts +++ b/src/renderer/src/components/browser-pane/annotate/useMarkupEditor.ts @@ -1,28 +1,32 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createBrowserUuid } from '@/lib/browser-uuid' import { blitMarkupScene, renderCommittedLayer } from './markup-canvas-render' import { useMarkupKeyboardShortcuts, type PendingText } from './useMarkupKeyboardShortcuts' import { useMarkupPointerHandlers } from './useMarkupPointerHandlers' +import { + applyDocumentCommand, + canUndoMarkup, + redoMarkup, + undoMarkup, + type MarkupEditorState +} from './markup-gesture' +import type { TextInkBoxMeasurer } from './markup-shape-hit-test' +import { textInkBox } from './markup-shape-render' import { canRedo, - canUndo, clearShapes, commitShape, createMarkupDocument, DEFAULT_MARKUP_COLOR, DEFAULT_MARKUP_FONT_SIZE, DEFAULT_MARKUP_WIDTH, - redoShape, - undoShape, - type MarkupDocument, - type MarkupShape, type MarkupTool } from './markup-drawing-model' type Size = { width: number; height: number; dpr: number } // Owns the markup surface: document, active tool/style, the pending text box, and -// the canvas paint effect. Draw-only — committed shapes are not re-editable. +// the canvas paint effect. Committed shapes can be erased but not re-edited. export function useMarkupEditor(busy: boolean, onCancel: () => void) { const rootRef = useRef(null) const canvasRef = useRef(null) @@ -35,8 +39,21 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { } const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 }) - const [doc, setDoc] = useState(() => createMarkupDocument()) - const [inProgress, setInProgress] = useState(null) + const [{ doc, gesture }, setState] = useState(() => ({ + doc: createMarkupDocument(), + gesture: null + })) + const drawing = gesture?.kind === 'draw' ? gesture.shape : null + const erasedIds = gesture?.kind === 'erase' ? gesture.erasedIds : null + // Why: one list drives both the canvas and the export, so marks hidden by an + // in-flight erase can never end up in the copied PNG. + const shapes = useMemo( + () => + erasedIds && erasedIds.size > 0 + ? doc.shapes.filter((shape) => !erasedIds.has(shape.id)) + : doc.shapes, + [doc.shapes, erasedIds] + ) const [tool, setTool] = useState('pen') const [color, setColor] = useState(DEFAULT_MARKUP_COLOR) const [width, setWidth] = useState(DEFAULT_MARKUP_WIDTH) @@ -80,8 +97,8 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { if (!layer) { return } - renderCommittedLayer(layer, doc.shapes, size.width, size.height, size.dpr) - }, [doc.shapes, size]) + renderCommittedLayer(layer, shapes, size.width, size.height, size.dpr) + }, [shapes, size]) // Blit the cached layer + the in-progress shape, coalesced to one paint per // frame so a burst of pointermove events can't queue redundant full repaints. @@ -92,10 +109,10 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { return undefined } const handle = requestAnimationFrame(() => { - blitMarkupScene(canvas, layer, inProgress, size.width, size.height, size.dpr) + blitMarkupScene(canvas, layer, drawing, size.width, size.height, size.dpr) }) return () => cancelAnimationFrame(handle) - }, [doc.shapes, inProgress, size]) + }, [shapes, drawing, size]) // Why: focus the text input on mount — a placement click can beat autoFocus. useEffect(() => { @@ -106,14 +123,18 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { return () => cancelAnimationFrame(handle) }, [pendingText]) - const undo = useCallback(() => setDoc((current) => undoShape(current)), []) - const redo = useCallback(() => setDoc((current) => redoShape(current)), []) + const undo = useCallback(() => setState(undoMarkup), []) + const redo = useCallback(() => setState(redoMarkup), []) const clear = useCallback(() => { - // Why: also drop any open text input / in-progress stroke so a clear leaves a - // truly clean slate — otherwise a pending input blur can re-add text. + // Why: also drop any open text input so a clear leaves a truly clean slate — + // otherwise a pending input blur can re-add text. setPendingText(null) - setInProgress(null) - setDoc((current) => clearShapes(current)) + setState((state) => applyDocumentCommand(state, clearShapes)) + }, []) + + const measureTextInkBox = useCallback((shape) => { + const ctx = committedLayerRef.current?.getContext('2d') + return ctx ? textInkBox(ctx, shape) : null }, []) useMarkupKeyboardShortcuts({ pendingText, setPendingText, undo, redo, onCancel }) @@ -124,11 +145,10 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { color, width, pendingText, - inProgress, canvasRef, - setInProgress, + measureTextInkBox, setPendingText, - setDoc + setState }) const commitPendingText = useCallback( @@ -139,8 +159,9 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { if (!at || trimmed.length === 0) { return } - setDoc((document) => - commitShape(document, { + setState((state) => ({ + ...state, + doc: commitShape(state.doc, { id: createBrowserUuid(), kind: 'text', color, @@ -148,7 +169,7 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { text: trimmed, fontSize }) - ) + })) }, [color, fontSize, pendingText] ) @@ -164,8 +185,8 @@ export function useMarkupEditor(busy: boolean, onCancel: () => void) { width, fontSize, pendingText, - shapes: doc.shapes, - canUndo: canUndo(doc), + shapes, + canUndo: canUndoMarkup({ doc, gesture }), canRedo: canRedo(doc), setTool, setColor, diff --git a/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts b/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts index 4aa6a928719..f51a563ba66 100644 --- a/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts +++ b/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts @@ -3,7 +3,8 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useMarkupPointerHandlers, type MarkupPointerParams } from './useMarkupPointerHandlers' -import type { MarkupShape } from './markup-drawing-model' +import { createMarkupDocument } from './markup-drawing-model' +import type { MarkupEditorState } from './markup-gesture' const realCrypto = globalThis.crypto @@ -24,15 +25,14 @@ function baseParams(overrides: Partial = {}): MarkupPointer color: '#ef4444', width: 4, pendingText: null, - inProgress: null, canvasRef: { current: { getBoundingClientRect: () => ({ left: 0, top: 0 }) } as unknown as HTMLCanvasElement }, - setInProgress: vi.fn(), + measureTextInkBox: () => null, setPendingText: vi.fn(), - setDoc: vi.fn(), + setState: vi.fn(), ...overrides } } @@ -52,50 +52,19 @@ describe('useMarkupPointerHandlers in a non-secure browser context', () => { }) it('starts a pen stroke with a valid id instead of throwing', () => { - const setInProgress = vi.fn() - const { result } = renderHook(() => useMarkupPointerHandlers(baseParams({ setInProgress }))) + let state: MarkupEditorState = { doc: createMarkupDocument(), gesture: null } + const setState: MarkupPointerParams['setState'] = (update) => { + state = typeof update === 'function' ? update(state) : update + } + const { result } = renderHook(() => useMarkupPointerHandlers(baseParams({ setState }))) expect(() => act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))).not.toThrow() - expect(setInProgress).toHaveBeenCalledTimes(1) - const shape = setInProgress.mock.calls[0][0] as MarkupShape - expect(shape.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/) - }) -}) - -describe('useMarkupPointerHandlers pointer up', () => { - const inProgress: MarkupShape = { - id: 'shape-1', - kind: 'pen', - color: '#ef4444', - width: 4, - points: [{ x: 0, y: 0 }] - } as MarkupShape - - it('commits outside the setInProgress updater so a double-invoked updater cannot duplicate the shape', () => { - const setInProgress = vi.fn() - const setDoc = vi.fn() - const { result } = renderHook(() => - useMarkupPointerHandlers(baseParams({ inProgress, setInProgress, setDoc })) - ) - - act(() => result.current.onPointerUp()) - - expect(setDoc).toHaveBeenCalledTimes(1) - // The clear must be a plain value, not an updater that also commits. - expect(setInProgress).toHaveBeenCalledWith(null) - }) - - it('does not commit when no shape is in progress', () => { - const setInProgress = vi.fn() - const setDoc = vi.fn() - const { result } = renderHook(() => - useMarkupPointerHandlers(baseParams({ inProgress: null, setInProgress, setDoc })) - ) - - act(() => result.current.onPointerUp()) - - expect(setDoc).not.toHaveBeenCalled() - expect(setInProgress).toHaveBeenCalledWith(null) + expect(state.gesture).toMatchObject({ + kind: 'draw', + shape: { + id: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/) + } + }) }) }) diff --git a/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.ts b/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.ts index 9c681fe2f52..e07a097975a 100644 --- a/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.ts +++ b/src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.ts @@ -2,13 +2,16 @@ import { useCallback } from 'react' import type React from 'react' import { createBrowserUuid } from '@/lib/browser-uuid' import type { PendingText } from './useMarkupKeyboardShortcuts' +import type { MarkupPoint, MarkupTool } from './markup-drawing-model' import { - commitShape, - type MarkupDocument, - type MarkupPoint, - type MarkupShape, - type MarkupTool -} from './markup-drawing-model' + beginDrawGesture, + beginEraseGesture, + cancelGesture, + endGesture, + moveGesture, + type MarkupEditorState +} from './markup-gesture' +import type { TextInkBoxMeasurer } from './markup-shape-hit-test' export type MarkupPointerParams = { busy: boolean @@ -16,15 +19,14 @@ export type MarkupPointerParams = { color: string width: number pendingText: PendingText | null - inProgress: MarkupShape | null canvasRef: React.RefObject - setInProgress: React.Dispatch> + measureTextInkBox: TextInkBoxMeasurer setPendingText: (value: PendingText | null) => void - setDoc: React.Dispatch> + setState: React.Dispatch> } -// Canvas pointer interactions: draw a new shape, or place text. Split out of -// useMarkupEditor to keep that hook focused. +// Canvas pointer interactions: draw a new shape, erase touched ones, or place +// text. Split out of useMarkupEditor to keep that hook focused. export function useMarkupPointerHandlers(params: MarkupPointerParams) { const { busy, @@ -32,11 +34,10 @@ export function useMarkupPointerHandlers(params: MarkupPointerParams) { color, width, pendingText, - inProgress, canvasRef, - setInProgress, + measureTextInkBox, setPendingText, - setDoc + setState } = params const pointFromEvent = useCallback( @@ -69,43 +70,55 @@ export function useMarkupPointerHandlers(params: MarkupPointerParams) { return } event.currentTarget.setPointerCapture(event.pointerId) - const id = createBrowserUuid() - if (tool === 'pen' || tool === 'highlight') { - setInProgress({ id, kind: tool, color, width, points: [point] }) - } else { - setInProgress({ id, kind: tool, color, width, from: point, to: point }) + const { pointerId } = event + if (tool === 'eraser') { + setState((state) => beginEraseGesture(state, pointerId, point, measureTextInkBox)) + return } + const id = createBrowserUuid() + const shape = + tool === 'pen' || tool === 'highlight' + ? { id, kind: tool, color, width, points: [point] } + : { id, kind: tool, color, width, from: point, to: point } + setState((state) => beginDrawGesture(state, pointerId, shape)) }, - [busy, color, pendingText, pointFromEvent, setInProgress, setPendingText, tool, width] + [ + busy, + color, + measureTextInkBox, + pendingText, + pointFromEvent, + setPendingText, + setState, + tool, + width + ] ) const onPointerMove = useCallback( (event: React.PointerEvent) => { - setInProgress((current) => { - if (!current) { - return current - } - const point = pointFromEvent(event) - if (current.kind === 'pen' || current.kind === 'highlight') { - return { ...current, points: [...current.points, point] } - } - if (current.kind === 'text') { - return current - } - return { ...current, to: point } - }) + const point = pointFromEvent(event) + const { pointerId } = event + setState((state) => moveGesture(state, pointerId, point, measureTextInkBox)) }, - [pointFromEvent, setInProgress] + [measureTextInkBox, pointFromEvent, setState] ) - // Why: committing inside the setInProgress updater made it impure, so StrictMode's - // double-invoke appended the shape twice (commitShape does not dedupe by id). - const onPointerUp = useCallback(() => { - if (inProgress) { - setDoc((document) => commitShape(document, inProgress)) - } - setInProgress(null) - }, [inProgress, setDoc, setInProgress]) + const onPointerUp = useCallback( + (event: React.PointerEvent) => { + const { pointerId } = event + setState((state) => endGesture(state, pointerId)) + }, + [setState] + ) - return { onPointerDown, onPointerMove, onPointerUp } + const onPointerCancel = useCallback( + (event: React.PointerEvent) => { + const { pointerId } = event + setState((state) => cancelGesture(state, pointerId)) + }, + [setState] + ) + + return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel } } diff --git a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts index d6c74e235a3..7e46821999d 100644 --- a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts @@ -4,8 +4,28 @@ import { Editor } from '@tiptap/core' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRichMarkdownExtensions } from './rich-markdown-extensions' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' -import { releaseLocalImageSrc, resetLocalImageSrcStateForTests } from './useLocalImageSrc' +import { + getLocalImageSrcCacheKey, + releaseLocalImageSrcByKey, + resetLocalImageSrcStateForTests +} from './useLocalImageSrc' import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import { documentResourceAccess } from '@/lib/local-file-access' + +// The key the editor leases: rich markdown reads images as resources of their document. +function diagramCacheKey(): string { + const key = getLocalImageSrcCacheKey( + 'diagram.png', + '/repo/docs/readme.md', + undefined, + undefined, + documentResourceAccess('/repo/docs/readme.md') + ) + if (!key) { + throw new Error('diagram.png has no local image cache key') + } + return key +} async function flushPromises(): Promise { for (let index = 0; index < 5; index += 1) { @@ -81,7 +101,7 @@ describe('rich markdown local images', () => { setRichMarkdownImageResolverContext(editor, { filePath: '/repo/docs/readme.md' }) await flushPromises() - releaseLocalImageSrc('diagram.png', '/repo/docs/readme.md') + releaseLocalImageSrcByKey(diagramCacheKey()) expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:rich-local-image') expect(host.querySelector('img')?.src).toBe('blob:rich-local-image') diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index 26ebe4de483..d4b0cdcb173 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -1,16 +1,18 @@ // @vitest-environment happy-dom -import { act, createElement, Fragment } from 'react' +import { act, createElement, Fragment, useEffect } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getLocalImageCacheKey, + getLocalImageSrcCacheKey, invalidateLocalImageSrcCacheForTests, loadLocalImageSrc, - releaseLocalImageSrc, + releaseLocalImageSrcByKey, resetLocalImageSrcStateForTests, useLocalImageSrc } from './useLocalImageSrc' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { blobUrlCache, cacheLocalImageBlob, @@ -48,6 +50,14 @@ function setReadFile(readFile: ReturnType): void { } as unknown as Window['api'] } +function releaseByPath(rawSrc: string, filePath: string): void { + const key = getLocalImageSrcCacheKey(rawSrc, filePath) + if (!key) { + throw new Error(`${rawSrc} has no local image cache key`) + } + releaseLocalImageSrcByKey(key) +} + async function flushPromises(): Promise { await Promise.resolve() await Promise.resolve() @@ -275,7 +285,7 @@ describe('loadLocalImageSrc', () => { setReadFile(readFile) const pending = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') - releaseLocalImageSrc('diagram.png', '/repo/docs/readme.md') + releaseByPath('diagram.png', '/repo/docs/readme.md') read.resolve(binaryPreview()) await expect(pending).resolves.toBeNull() @@ -296,7 +306,7 @@ describe('loadLocalImageSrc', () => { setReadFile(readFile) const stale = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') - releaseLocalImageSrc('diagram.png', '/repo/docs/readme.md') + releaseByPath('diagram.png', '/repo/docs/readme.md') const fresh = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') expect(readFile).toHaveBeenCalledTimes(2) @@ -311,7 +321,7 @@ describe('loadLocalImageSrc', () => { it('cleans version metadata for released unique paths', () => { for (let index = 0; index < 500; index += 1) { const path = `/repo/docs/image-${index}.png` - releaseLocalImageSrc(path, '/repo/docs/readme.md') + releaseByPath(path, '/repo/docs/readme.md') expect(getLocalImageCacheKeyVersion(getLocalImageCacheKey(path, undefined, undefined))).toBe( 0 ) @@ -437,3 +447,126 @@ describe('loadLocalImageSrc', () => { ) }) }) + +describe('useLocalImageSrc runtime owner', () => { + const imageSrc = 'diagram.png' + const documentPath = '/repo/docs/readme.md' + + function sshOwner(overrides: Partial = {}): RuntimeFileOperationArgs { + return { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 1, + ...overrides + } + } + + function OwnerProbe({ + onRender, + runtimeContext + }: { + onRender: (displaySrc: string | undefined) => void + runtimeContext: RuntimeFileOperationArgs + }): null { + onRender(useLocalImageSrc(imageSrc, documentPath, null, runtimeContext)) + return null + } + + // Stands in for a surface that drops its copy whenever the owner object changes; only the + // probe's pin keeps the shown URL alive through that release. + function ReleaseOnOwnerObject({ + runtimeContext + }: { + runtimeContext: RuntimeFileOperationArgs + }): null { + useEffect(() => { + const key = getLocalImageSrcCacheKey(imageSrc, documentPath, null, runtimeContext) + return () => { + if (key) { + releaseLocalImageSrcByKey(key) + } + } + }, [runtimeContext]) + return null + } + + function renderOwner( + root: Root, + runtimeContext: RuntimeFileOperationArgs, + onRender: (displaySrc: string | undefined) => void + ): void { + root.render( + createElement( + Fragment, + null, + createElement(OwnerProbe, { onRender, runtimeContext }), + createElement(ReleaseOnOwnerObject, { runtimeContext }) + ) + ) + } + + it('keeps its pin and URL when an equal runtime context is rebuilt', async () => { + const readFile = vi.fn().mockResolvedValue(binaryPreview()) + let nextUrl = 0 + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:owner-${++nextUrl}`) + setReadFile(readFile) + const renders: (string | undefined)[] = [] + const onRender = (displaySrc: string | undefined): void => { + renders.push(displaySrc) + } + + const container = document.createElement('div') + const root: Root = createRoot(container) + await act(async () => { + renderOwner(root, sshOwner(), onRender) + await flushPromises() + }) + expect(renders.at(-1)).toBe('blob:owner-1') + + for (let update = 0; update < 3; update += 1) { + await act(async () => { + renderOwner(root, sshOwner(), onRender) + await flushPromises() + }) + } + + expect(readFile).toHaveBeenCalledOnce() + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + expect(new Set(renders.filter(Boolean))).toEqual(new Set(['blob:owner-1'])) + root.unmount() + }) + + it.each([ + ['an SSH reconnect', { expectedSshConnectionGeneration: 2 }], + ['another worktree', { worktreeId: 'wt-2', worktreePath: '/repo-2' }] + ])('re-reads the image after %s changes its owner', async (_label, change) => { + const readFile = vi.fn().mockResolvedValue(binaryPreview()) + let nextUrl = 0 + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:owner-${++nextUrl}`) + setReadFile(readFile) + const renders: (string | undefined)[] = [] + const onRender = (displaySrc: string | undefined): void => { + renders.push(displaySrc) + } + + const container = document.createElement('div') + const root: Root = createRoot(container) + await act(async () => { + renderOwner(root, sshOwner(), onRender) + await flushPromises() + }) + expect(renders.at(-1)).toBe('blob:owner-1') + + await act(async () => { + renderOwner(root, sshOwner(change), onRender) + await flushPromises() + }) + + expect(readFile).toHaveBeenCalledTimes(2) + expect(renders.at(-1)).toBe('blob:owner-2') + root.unmount() + }) +}) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 2e1224ecb97..f256b173552 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useEffectEvent, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readLocalImagePreview } from './local-image-src-reader' @@ -62,6 +62,27 @@ function isExternalUrl(src: string): boolean { return /^(?:https?|data|blob):/i.test(src) } +type LocalImageRuntimeContext = Omit & { + connectionId?: string | null +} + +/** The cache identity of a local image, or null when it is external or has no owner yet. */ +export function getLocalImageSrcCacheKey( + rawSrc: string | undefined, + filePath: string, + connectionId?: string | null, + runtimeContext?: LocalImageRuntimeContext | null, + access?: LocalFileAccess +): string | null { + if (!rawSrc || isExternalUrl(rawSrc) || runtimeContext === null) { + return null + } + const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) + return absolutePath + ? getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) + : null +} + /** * Resolves a raw markdown image src to a displayable URL. For local images, * reads the file via IPC and returns a blob URL. For http/https/data URLs, @@ -72,64 +93,52 @@ export function useLocalImageSrc( rawSrc: string | undefined, filePath: string, connectionId?: string | null, - runtimeContext?: - | (Omit & { connectionId?: string | null }) - | null, + runtimeContext?: LocalImageRuntimeContext | null, access?: LocalFileAccess ): string | undefined { const [generation, setGeneration] = useState(getLocalImageCacheGeneration()) + const externalSrc = + rawSrc && runtimeContext !== null && isExternalUrl(rawSrc) ? rawSrc : undefined + // Why key effects on the cache key: callers rebuild equal context objects on unrelated store + // updates, and re-leasing on each one revoked the URL an was still showing. + const cacheKey = getLocalImageSrcCacheKey(rawSrc, filePath, connectionId, runtimeContext, access) + const readImage = useEffectEvent((): Promise => { + const absolutePath = rawSrc ? resolveImageAbsolutePath(rawSrc, filePath) : null + return absolutePath + ? loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext, access) + : Promise.resolve(null) + }) useEffect(() => { - return acquireLocalImageSrcLease(rawSrc, filePath, connectionId, runtimeContext, access) - }, [rawSrc, filePath, connectionId, runtimeContext, access]) + if (!cacheKey) { + return + } + pinLocalImageCache(cacheKey) + return () => unpinLocalImageCache(cacheKey) + }, [cacheKey]) useEffect(() => { return onImageCacheInvalidated(() => setGeneration(getLocalImageCacheGeneration())) }, []) - const [displaySrc, setDisplaySrc] = useState(() => { - if (!rawSrc || runtimeContext === null) { - return undefined - } - if (isExternalUrl(rawSrc)) { - return rawSrc - } - const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (absolutePath) { - const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) - if (blobUrlCache.has(cacheKey)) { - return blobUrlCache.get(cacheKey) - } - } - return undefined - }) + const [displaySrc, setDisplaySrc] = useState( + () => externalSrc ?? (cacheKey ? blobUrlCache.get(cacheKey) : undefined) + ) useEffect(() => { - if (!rawSrc || runtimeContext === null) { - setDisplaySrc(undefined) + if (externalSrc || !cacheKey) { + setDisplaySrc(externalSrc) return } - - if (isExternalUrl(rawSrc)) { - setDisplaySrc(rawSrc) - return - } - - const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (!absolutePath) { - setDisplaySrc(undefined) - return - } - - const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) - if (blobUrlCache.has(cacheKey)) { - setDisplaySrc(blobUrlCache.get(cacheKey)) + const cached = blobUrlCache.get(cacheKey) + if (cached) { + setDisplaySrc(cached) return } let cancelled = false const effectGeneration = generation - loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext, access) + readImage() .then((url) => { if (cancelled) { return @@ -145,7 +154,7 @@ export function useLocalImageSrc( return () => { cancelled = true } - }, [rawSrc, filePath, generation, connectionId, runtimeContext, access]) + }, [cacheKey, externalSrc, generation]) return displaySrc } @@ -248,40 +257,16 @@ export function acquireLocalImageSrcLease( rawSrc: string | undefined, filePath: string, connectionId?: string | null, - runtimeContext?: - | (Omit & { connectionId?: string | null }) - | null, + runtimeContext?: LocalImageRuntimeContext | null, access?: LocalFileAccess ): (() => void) | undefined { - if (!rawSrc || isExternalUrl(rawSrc) || runtimeContext === null) { + const key = getLocalImageSrcCacheKey(rawSrc, filePath, connectionId, runtimeContext, access) + if (!key) { return undefined } - const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (!absolutePath) { - return undefined - } - const key = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) pinLocalImageCache(key) return () => unpinLocalImageCache(key) } -/** Evict one no-longer-visible transcript preview immediately. */ -export function releaseLocalImageSrc( - rawSrc: string, - filePath: string, - connectionId?: string | null, - runtimeContext?: - | (Omit & { connectionId?: string | null }) - | null, - access?: LocalFileAccess -): void { - if (!rawSrc || isExternalUrl(rawSrc) || runtimeContext === null) { - return - } - const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (!absolutePath) { - return - } - const key = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) - releaseLocalImageBlob(key) -} +/** Evict the image cached under a `getLocalImageSrcCacheKey` key unless a preview pins it. */ +export const releaseLocalImageSrcByKey = releaseLocalImageBlob diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 29f7b099109..f0f138ff9c1 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 99db859aab5..44f2f806e48 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 3012aa28cc4..a8fed079f69 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 ccbd38afe84..77f33c63334 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 819cfaeaf9e..1a928c7c8ad 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 ceb812e6291..66d5870e1e1 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 e2c4c6ff030..c29afa08fa6 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 a81019456b5..b89546d8a42 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/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index 2ba15a08d4c..e2b0433d73d 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -6,8 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { invalidateLocalImageSrcCacheForTests, + loadLocalImageSrc, resetLocalImageSrcStateForTests } from '@/components/editor/useLocalImageSrc' +import { chatImageAccess } from '@/lib/local-file-access' import { NativeChatImageAttachments } from './NativeChatTranscriptChrome' globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -136,6 +138,88 @@ describe('NativeChatImageAttachments', () => { root.unmount() }) + it('keeps the shown image when an equal runtime context is rebuilt', async () => { + const container = document.createElement('div') + const root = createRoot(container) + const blocks = [{ type: 'image-ref' as const, path: '/repo/image.png' }] + + await act(async () => { + root.render( + createElement(NativeChatImageAttachments, { + blocks, + runtimeContext: runtimeContext('wt-1') + }) + ) + await flushPromises() + }) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('blob:owner-1') + + for (let update = 0; update < 3; update += 1) { + await act(async () => { + root.render( + createElement(NativeChatImageAttachments, { + blocks, + runtimeContext: runtimeContext('wt-1') + }) + ) + await flushPromises() + }) + } + + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + expect(window.api.fs.readFile).toHaveBeenCalledOnce() + expect(container.querySelector('img')).toBe(img) + expect(img?.getAttribute('src')).toBe('blob:owner-1') + + root.unmount() + }) + + it('keeps an off-screen image cached when an equal runtime context is rebuilt', async () => { + class FakeIntersectionObserver { + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + } + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) + const container = document.createElement('div') + const root = createRoot(container) + const blocks = [{ type: 'image-ref' as const, path: '/repo/image.png' }] + const renderWith = (context: RuntimeFileOperationArgs): void => + root.render(createElement(NativeChatImageAttachments, { blocks, runtimeContext: context })) + const loadSameEntry = (): Promise => + loadLocalImageSrc( + '/repo/image.png', + '/repo/image.png', + undefined, + runtimeContext('wt-1'), + chatImageAccess() + ) + + try { + await act(async () => { + renderWith(runtimeContext('wt-1')) + await flushPromises() + }) + // Off-screen nothing pins the entry, so only the release effect's deps decide whether it survives. + await expect(loadSameEntry()).resolves.toBe('blob:owner-1') + + for (let update = 0; update < 3; update += 1) { + await act(async () => { + renderWith(runtimeContext('wt-1')) + await flushPromises() + }) + } + + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + await expect(loadSameEntry()).resolves.toBe('blob:owner-1') + expect(window.api.fs.readFile).toHaveBeenCalledOnce() + } finally { + // The visibility observer is module-wide; a leaked one breaks later tests. + root.unmount() + } + }) + it('retries a failed thumbnail after the image cache refreshes', async () => { const container = document.createElement('div') const root = createRoot(container) diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 402c82816a8..bd93d0c2ee7 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -10,8 +10,9 @@ import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-p import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { getLocalImageCacheKey, + getLocalImageSrcCacheKey, useLocalImageSrc, - releaseLocalImageSrc + releaseLocalImageSrcByKey } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { isNativeChatPastedImagePath } from './native-chat-image-paste' @@ -75,6 +76,25 @@ function transcriptImageIdentity( // load on scroll with no click, so main only serves local image files by their real type. const TRANSCRIPT_IMAGE_ACCESS = chatImageAccess() +// Why the cache key, not the context object: the owner hook rebuilds an equal context on +// unrelated store updates, and releasing on each one revoked the URL the was showing. +function transcriptImageReleaseKey( + source: string | undefined, + filePath: string, + runtimeContext: RuntimeFileOperationArgs | null | undefined +): string | null { + if (!runtimeContext) { + return null + } + return getLocalImageSrcCacheKey( + source, + filePath, + runtimeContext.connectionId, + runtimeContext, + TRANSCRIPT_IMAGE_ACCESS + ) +} + function TranscriptImagePreview({ block, runtimeContext @@ -124,17 +144,16 @@ function TranscriptImagePreview({ } return observeTranscriptVisibility(element, setNear) }, []) + const releaseKey = transcriptImageReleaseKey(source, filePath, runtimeContext) useEffect(() => { - const context = runtimeContext - if (!source || external || context === undefined || context === null) { + if (!releaseKey) { return } if (!leaseActive) { - releaseLocalImageSrc(source, filePath, context.connectionId, context, TRANSCRIPT_IMAGE_ACCESS) + releaseLocalImageSrcByKey(releaseKey) } - return () => - releaseLocalImageSrc(source, filePath, context.connectionId, context, TRANSCRIPT_IMAGE_ACCESS) - }, [external, filePath, leaseActive, runtimeContext, source]) + return () => releaseLocalImageSrcByKey(releaseKey) + }, [leaseActive, releaseKey]) const showPreview = leaseActive && 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 826121db975..52b1e09346f 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 8749d20ad39..c1f6dd24cb5 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 00000000000..25782727d40 --- /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 00000000000..76b82af5393 --- /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 00000000000..55ed8516227 --- /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 00000000000..53ef375c854 --- /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 00000000000..37c7d0124f8 --- /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 00000000000..af2b7cf889d --- /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 00000000000..32864af94ce --- /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 00000000000..7dde1cb4fca --- /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 00000000000..13e4bde8503 --- /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 00000000000..02de2aa92bf --- /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 00000000000..96ba8bc26af --- /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 00000000000..cd4a11faf77 --- /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 00000000000..83323315c83 --- /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 00000000000..e0da93ddcd1 --- /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 00000000000..b8ca9d888b0 --- /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 f25d5d86381..90029987cf4 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