From c0c893d17165e7327eb0d9bbd8ee01e4ab39209f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:17:21 -0700 Subject: [PATCH 001/211] fix(pty): bound cooked reply queue (#13422) * fix(pty): bound cooked reply queue * fix(pty): bound cooked reply queue Implement bounded storage for cooked-echo-safe replies: 64 pending replies and 4096 UTF-16 code units. Shed oldest replies on overflow, never ordinary input. Add drain failure containment with generation fencing to prevent stale operations from clearing fresh input after clear() reuse. * fix(pty): report pty id in drain failures When the async drain yields, the owner may rebind to a different PTY before the failure surfaces. Pass the failing pty id so the transport can ignore stale failures from a rebound owner. Also tighten the pending reply queue size bound to prevent half-written entries. --- config/reliability-gates.jsonc | 160 ++++++++ .../pty-input-write-queue.test.ts | 358 +++++++++++++++++- .../terminal-pane/pty-input-write-queue.ts | 314 +++++++++++---- .../terminal-pane/pty-transport.test.ts | 54 +++ .../components/terminal-pane/pty-transport.ts | 14 +- tests/e2e/pty-input-write-queue-ssh.spec.ts | 66 ++++ 6 files changed, 871 insertions(+), 95 deletions(-) create mode 100644 tests/e2e/pty-input-write-queue-ssh.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3993a178f38..2e244697586 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8251,6 +8251,166 @@ "knownGaps": ["No manifest command yet.", "No startup color-query contract is wired."], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, + { + "id": "terminal-input.cooked-reply-queue", + "title": "Cooked-echo-safe terminal reply backlog stays bounded without evicting ordinary input", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-provider-contract", + "surfaces": [ + "desktop terminal input", + "OSC 10/11 replies", + "mode-2031 replies", + "local PTY", + "daemon and SSH PTYs" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves a real Linux SSH process receives xterm's OSC reply through the same queue. No live daemon, WSL, physical Linux/Windows client, or mixed-version run is registered.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/13137", + "https://github.com/stablyai/orca/issues/7329" + ], + "invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending cooked-echo-safe replies and 4096 UTF-16 code units. Overflow removes the oldest pending query-reply entries, never ordinary input or reply-shaped bytes from the ordinary path, so later user input has a finite reply backlog; every retained reply reaches the established provider path as one atomic write. Drain failures settle without an unhandled rejection and cannot clear a newer queue generation.", + "oracle": "Synchronously enqueue 10,000 cooked-echo-safe query replies before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Repeat behind 10,000 ordinary inputs and through the real IPC transport, requiring ordinary and reply-shaped ordinary-path bytes to survive while only the newest 64 explicit replies remain. Exercise the text ceiling, real xterm OSC 10/11 generation, provider-write failure, rejected yield, and clear/reuse generation fencing.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts", + "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/shared/terminal-query-reply.test.ts", + "src/shared/pty-startup-ingress-live-query-reply.test.ts", + "tests/e2e/terminal-osc-color-queries.spec.ts", + "tests/e2e/terminal-typing-latency.spec.ts", + "tests/e2e/pty-input-write-queue-ssh.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", + "assertions": [ + "a synchronous 10,000-reply flood writes only the immediate first and newest 64 pending replies before a trailing keystroke", + "10,000 ordinary queued inputs survive a later reply flood while reply retention remains bounded", + "reply-shaped bytes from the ordinary input path never enter the shed-able reply budget", + "reply count and text overflow remove only the oldest pending replies", + "real xterm OSC 10/11 query handlers remain subject to the same retention ceiling", + "retained cooked-echo-safe replies stay one provider write each", + "provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing", + "clear releases saturated reply accounting and fences in-flight validation before later input" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "sendInputImmediate applies the reply ceiling while sendInput preserves a reply-shaped ordinary payload in exact IPC write order", + "a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation" + ] + }, + { + "file": "src/shared/terminal-query-reply.test.ts", + "assertions": [ + "only complete cooked-echo-risk replies are classified and consecutive replies extract losslessly" + ] + }, + { + "file": "src/shared/pty-startup-ingress-live-query-reply.test.ts", + "assertions": [ + "repeated live query replies are delivered independently and their cooked echoes stay hidden" + ] + }, + { + "file": "tests/e2e/terminal-osc-color-queries.spec.ts", + "assertions": [ + "real Electron xterm OSC 10/11 replies reach the active local PTY as separate writes" + ] + }, + { + "file": "tests/e2e/terminal-typing-latency.spec.ts", + "assertions": [ + "real keyboard input reaches and echoes from the active local PTY within the interactive latency ceilings" + ] + }, + { + "file": "tests/e2e/pty-input-write-queue-ssh.spec.ts", + "assertions": [ + "a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts", + "result": "passed", + "durationSeconds": 2.35, + "summary": "Four files and 138 tests passed, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing." + }, + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 13.2, + "summary": "The real Electron/xterm OSC 10/11 query path wrote the exact foreground and background replies separately to the active local PTY." + }, + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 10.6, + "summary": "Real keyboard events traversed xterm and the input queue to a local interactive PTY with every sampled key echoed inside the latency ceiling." + }, + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 36.8, + "summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit, local Electron, and Docker SSH contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction into its dedicated spec and then passed the registered dedicated command once. CI and the normal soak window are not yet available." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. No saved intentional-break artifact is attached yet." + }, + "performanceBudget": { + "required": true, + "evidence": "Pending cooked replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. A synchronous flood forwards only the immediate first and newest 64 replies before the trailing key. Ten complete gate runs, including two 10,000-entry flood shapes each, finished in 29.57 seconds with zero failures. The change adds no polling, timers, listeners, subprocesses, or network retries." + }, + "promotionCriteria": [ + "Run the gate on Windows and Linux CI.", + "Exercise a live daemon PTY with cooked echo enabled and record exact shell-visible and provider-visible bytes.", + "Attach an intentional-break run proving reply overflow cannot evict ordinary input or exceed either ceiling.", + "Accumulate the normal soak window with zero unexplained flakes." + ], + "knownGaps": [ + "No live daemon, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.", + "The remote-runtime transport has a separate immediate-input queue and is unaffected by this gate.", + "Ordinary-input queue growth predates this change and is outside this reply-only gate.", + "Reply overload intentionally sheds the oldest synthetic replies; live application behavior under a pathological query flood is not measured." + ], + "demotionRule": "Keep experimental or revert if ordinary input is evicted, pending replies exceed either ceiling, retained replies are coalesced, a trailing key sits behind more than the bounded reply window, or the gate develops unexplained flakes." + }, { "id": "terminal-query.mobile-view-authority", "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects", diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index 2bcd4ed6172..f7d0812f616 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' import { + PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES, + PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS, TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, createPtyInputWriteQueue } from './pty-input-write-queue' @@ -7,9 +10,14 @@ import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES } from '../../../../shared/terminal-input' +import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' import { PtyStartupIngress, type PtyIngressEmission } from '../../../../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../../../../shared/terminal-query-reply' +import { + extractOnlyCookedEchoSafeQueryReplies, + needsCookedEchoSafeQueryReply +} from '../../../../shared/terminal-query-reply' +import { installTerminalCapabilityReplyHandlers } from './terminal-capability-replies' const WHEEL_UP_REPORT = '\x1b[<64;60;20M' @@ -27,6 +35,44 @@ function createRecordingQueue(options: { writable?: () => boolean } = {}): { return { writes, queue } } +function createParkedQueue(): { + writes: WriteRecord[] + pendingYields: (() => void)[] + queue: ReturnType +} { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }) + }) + return { writes, pendingYields, queue } +} + +async function releaseNextWrite( + writes: WriteRecord[], + pendingYields: (() => void)[] +): Promise { + const before = writes.length + const release = pendingYields.shift() + expect(release).toBeDefined() + release?.() + await Promise.resolve() + expect(writes.length).toBeGreaterThan(before) +} + +function writeTerminal(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +function extractReplyWrites(writes: WriteRecord[]): string[] { + return writes.flatMap((write) => extractOnlyCookedEchoSafeQueryReplies(write.data) ?? []) +} + describe('pty input write queue', () => { it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => { const { writes, queue } = createRecordingQueue() @@ -136,26 +182,21 @@ describe('pty input write queue', () => { const { writes, queue } = createRecordingQueue() const reply = mode2031SequenceFor('dark') - expect(queue.enqueue('pty-1', reply)).toBe(true) - expect(queue.enqueue('pty-1', reply)).toBe(true) + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) await queue.waitForDrain() - // Atomic writes so each host intercept sees a single extractable reply — - // not one coalesced `?997;1n?997;1n` payload. expect(writes).toEqual([ { id: 'pty-1', data: reply }, { id: 'pty-1', data: reply } ]) - expect(writes).toHaveLength(2) - expect(writes[0]?.data).toBe(reply) - expect(writes[1]?.data).toBe(reply) }) it('does not coalesce a color-scheme reply with a following keystroke', async () => { const { writes, queue } = createRecordingQueue() const reply = mode2031SequenceFor('dark') - queue.enqueue('pty-1', reply) + queue.enqueueQueryReply('pty-1', reply) queue.enqueue('pty-1', 'y') await queue.waitForDrain() @@ -165,6 +206,268 @@ describe('pty input write queue', () => { ]) }) + it('bounds an OSC 10/11 reply flood and drains a following keystroke', async () => { + const { writes, pendingYields, queue } = createParkedQueue() + const replies: string[] = [] + let allAccepted = true + + for (let index = 0; index < 10_000; index += 1) { + const color = index.toString(16).padStart(4, '0') + const reply = `\x1b]${index % 2 === 0 ? 10 : 11};rgb:${color}/0000/0000\x1b\\` + replies.push(reply) + allAccepted = queue.enqueueQueryReply('pty-1', reply) && allAccepted + } + expect(allAccepted).toBe(true) + expect(queue.enqueue('pty-1', 'k')).toBe(true) + + await Promise.resolve() + expect(pendingYields).toHaveLength(1) + for (let turn = 0; turn < PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; turn += 1) { + await releaseNextWrite(writes, pendingYields) + } + await queue.waitForDrain() + + const replyWrites = writes.filter((write) => write.data.startsWith('\x1b]')) + expect(replyWrites.map((write) => write.data)).toEqual([ + replies[0], + ...replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) + ]) + expect(replyWrites.every((write) => needsCookedEchoSafeQueryReply(write.data))).toBe(true) + expect(writes.at(-1)?.data).toBe('k') + }) + + it('drops the oldest reply-only payload when the reply text budget fills', async () => { + const reply = (slot: 10 | 11, marker: string): string => + `\x1b]${slot};${marker.repeat(1_400)}\x1b\\` + const first = reply(10, '1') + const dropped = reply(11, '2') + const second = reply(10, '3') + const third = reply(11, '4') + const { writes, pendingYields, queue } = createParkedQueue() + + expect(second.length * 2).toBeLessThanOrEqual( + PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS + ) + expect(second.length * 3).toBeGreaterThan(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS) + queue.enqueueQueryReply('pty-1', first) + queue.enqueueQueryReply('pty-1', dropped) + queue.enqueue('pty-1', 'x') + queue.enqueueQueryReply('pty-1', second) + queue.enqueueQueryReply('pty-1', third) + queue.enqueue('pty-1', 'k') + + await Promise.resolve() + expect(pendingYields).toHaveLength(1) + for (let turn = 0; turn < 3; turn += 1) { + await releaseNextWrite(writes, pendingYields) + } + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: first }, + { id: 'pty-1', data: 'x' }, + { id: 'pty-1', data: second }, + { id: 'pty-1', data: third }, + { id: 'pty-1', data: 'k' } + ]) + }) + + it('bounds replies generated by a real xterm OSC query flood', async () => { + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + term.options.theme = { foreground: '#2e3434', background: '#ffffff' } + const { writes, queue } = createRecordingQueue() + const generated: string[] = [] + const disposable = installTerminalCapabilityReplyHandlers({ + terminal: { + cols: term.cols, + rows: term.rows, + element: undefined, + options: term.options + }, + parser: term.parser, + sendInput: (data) => { + generated.push(data) + return queue.enqueueQueryReply('pty-1', data) + }, + isReplaying: () => false + }) + + try { + await writeTerminal(term, '\x1b]10;?;?\x1b\\'.repeat(34)) + await queue.waitForDrain() + + const foreground = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' + const background = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + expect(generated).toEqual(Array.from({ length: 34 }, () => [foreground, background]).flat()) + expect(extractReplyWrites(writes)).toEqual([ + foreground, + ...generated.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) + ]) + } finally { + disposable.dispose() + term.dispose() + } + }) + + it('rejects one reply larger than the total reply retention budget', async () => { + const { writes, queue } = createRecordingQueue() + const prefix = '\x1b]10;' + const suffix = '\x1b\\' + const input = `${prefix}${'x'.repeat( + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + 1 - prefix.length - suffix.length + )}${suffix}` + + expect(input).toHaveLength(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + 1) + expect(queue.enqueueQueryReply('pty-1', input)).toBe(false) + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) + + it('preserves an ordinary backlog while bounding later replies', async () => { + const { writes, queue } = createRecordingQueue() + const ordinary = Array.from({ length: 10_000 }, (_, index) => String(index % 10)) + const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) + + for (const input of ordinary) { + expect(queue.enqueue('pty-1', input)).toBe(true) + } + for (const reply of replies) { + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe( + [...ordinary, ...replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)].join('') + ) + expect(extractReplyWrites(writes)).toEqual( + replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) + ) + }) + + it('never sheds reply-shaped bytes sent through the ordinary input path', async () => { + const { writes, queue } = createRecordingQueue() + const ordinary = '\x1b]10;user-ordinary-marker\x1b\\' + const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) + + expect(queue.enqueueQueryReply('pty-1', '\x1b[?10000;1n')).toBe(true) + expect(queue.enqueue('pty-1', ordinary)).toBe(true) + for (const reply of replies) { + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toContain(ordinary) + }) + + it('contains drain failures and rejects input until queue reuse', async () => { + const failure = new Error('write failed') + const writes: WriteRecord[] = [] + let shouldThrow = true + const onDrainFailure = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => { + if (shouldThrow) { + throw failure + } + writes.push({ id, data }) + }, + onDrainFailure + }) + + try { + expect(queue.enqueue('pty-1', 'stale')).toBe(true) + expect(queue.enqueue('pty-1', 'also-stale')).toBe(false) + await expect(queue.waitForDrain()).resolves.toBeUndefined() + + shouldThrow = false + expect(queue.enqueue('pty-1', 'still-stale')).toBe(false) + queue.clear() + expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }]) + expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure) + expect(onDrainFailure).toHaveBeenCalledOnce() + } finally { + warn.mockRestore() + } + }) + + it('reports the pty id that failed so a rebound owner can ignore the drain failure', async () => { + const failure = new Error('yield failed') + const onDrainFailure = vi.fn() + let rejectYield: ((error: Error) => void) | undefined + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: () => undefined, + yieldBetweenWrites: () => + new Promise((_resolve, reject) => { + rejectYield = reject + }), + onDrainFailure + }) + + try { + expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect(rejectYield).toBeDefined() + rejectYield?.(failure) + await queue.waitForDrain() + + expect(onDrainFailure).toHaveBeenCalledExactlyOnceWith('pty-1') + } finally { + warn.mockRestore() + } + }) + + it('keeps a pending reply small enough that one drain step writes and removes it', () => { + // Larger replies would span chunks, so admitReply could evict a half-written entry. + expect(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS * 3).toBeLessThanOrEqual( + TERMINAL_INPUT_CHUNK_MAX_BYTES + ) + }) + + it('does not let a stale drain failure clear input queued after reuse', async () => { + const failure = new Error('stale yield failed') + const writes: WriteRecord[] = [] + let rejectYield: ((error: Error) => void) | undefined + let rejectNextYield = true + const onDrainFailure = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => { + if (!rejectNextYield) { + return Promise.resolve() + } + return new Promise((_resolve, reject) => { + rejectYield = reject + }) + }, + onDrainFailure + }) + + try { + expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect(rejectYield).toBeDefined() + queue.clear() + rejectNextYield = false + expect(queue.enqueue('pty-2', 'fresh')).toBe(true) + rejectYield?.(failure) + await queue.waitForDrain() + + expect(writes.at(-1)).toEqual({ id: 'pty-2', data: 'fresh' }) + expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure) + expect(onDrainFailure).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + it('dual mode-2031 enqueues through the real queue never paint 997 under host echo-safe write', async () => { // Issue path: xterm onData + mode-2031 scan each enqueue mode2031SequenceFor. // Drive the real write queue → host intercept (extract + answerLiveQueryReply) @@ -198,8 +501,8 @@ describe('pty input write queue', () => { write: hostWrite }) - expect(queue.enqueue('pty-1', reply)).toBe(true) - expect(queue.enqueue('pty-1', reply)).toBe(true) + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) await queue.waitForDrain() await vi.advanceTimersByTimeAsync(0) @@ -243,4 +546,37 @@ describe('pty input write queue', () => { expect(writes.length).toBe(1) expect(writes.map((write) => write.data).join('')).not.toContain('tail') }) + + it('clear() fences an in-flight validation before queue reuse', async () => { + vi.useFakeTimers() + const { writes, queue } = createRecordingQueue() + const stale = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) + + try { + expect(queue.enqueue('pty-1', stale)).toBe(true) + queue.clear() + expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + + await vi.runAllTimersAsync() + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }]) + } finally { + vi.useRealTimers() + } + }) + + it('clear() resets reply capacity for later input', async () => { + const { writes, queue } = createRecordingQueue() + const reply = mode2031SequenceFor('dark') + + for (let index = 0; index <= PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; index += 1) { + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) + } + queue.clear() + expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) + await queue.waitForDrain() + + expect(extractReplyWrites(writes)).toEqual([reply, reply]) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index d98465c252a..5e815b01dfc 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -9,10 +9,18 @@ import { needsCookedEchoSafeQueryReply } from '../../../../shared/terminal-query // 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on // the hot input path. export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 +// Match host delivery's reply ceiling while keeping all retained reply text under one PTY chunk. +export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64 +// Keep ≤ TERMINAL_INPUT_CHUNK_MAX_BYTES/3 so a reply is written and dropped in one drain step: +// admitReply evicts the head, and a half-written entry would truncate. Guarded by a unit test. +export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS = + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS type PendingPtyInputWrite = { + sequence: number id: string text: string + replyOnly: boolean tooLarge: boolean | Promise chunks?: Iterator nextChunk?: string @@ -20,6 +28,7 @@ type PendingPtyInputWrite = { export type PtyInputWriteQueue = { enqueue: (id: string, data: string) => boolean + enqueueQueryReply: (id: string, data: string) => boolean waitForDrain: () => Promise clear: () => void } @@ -28,90 +37,209 @@ export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean write: (id: string, data: string) => void yieldBetweenWrites?: () => Promise + onDrainFailure?: (id: string) => void } -function isCoalescibleText(text: string): boolean { +function isCoalescibleInput(input: PendingPtyInputWrite): boolean { // Echo-risk replies stay atomic so host classifiers cannot miss them (#13137). - return ( - text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS && !needsCookedEchoSafeQueryReply(text) - ) + return input.text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS && !input.replyOnly } export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { const yieldBetweenWrites = deps.yieldBetweenWrites ?? yieldToEventLoop - let pending: PendingPtyInputWrite[] = [] + let pendingOrdinary: (PendingPtyInputWrite | undefined)[] = [] + let pendingOrdinaryHead = 0 + let pendingReplies: (PendingPtyInputWrite | undefined)[] = [] + let pendingReplyHead = 0 + let pendingReplyCount = 0 + let pendingReplyCodeUnits = 0 + let nextSequence = 0 + let generation = 0 + let failedGeneration: number | null = null let drainPromise: Promise | null = null + function compactOrdinary(): void { + if (pendingOrdinaryHead === pendingOrdinary.length) { + pendingOrdinary = [] + pendingOrdinaryHead = 0 + } else if (pendingOrdinaryHead >= 1024 && pendingOrdinaryHead * 2 >= pendingOrdinary.length) { + pendingOrdinary = pendingOrdinary.slice(pendingOrdinaryHead) + pendingOrdinaryHead = 0 + } + } + + function compactReplies(): void { + if (pendingReplyHead === pendingReplies.length) { + pendingReplies = [] + pendingReplyHead = 0 + } else if (pendingReplyHead >= 1024 && pendingReplyHead * 2 >= pendingReplies.length) { + pendingReplies = pendingReplies.slice(pendingReplyHead) + pendingReplyHead = 0 + } + } + + function resetSequenceIfEmpty(): void { + if (pendingOrdinary.length === 0 && pendingReplies.length === 0) { + nextSequence = 0 + } + } + + function firstPending(): PendingPtyInputWrite | undefined { + const ordinary = pendingOrdinary[pendingOrdinaryHead] + const reply = pendingReplies[pendingReplyHead] + if (!ordinary) { + return reply + } + if (!reply) { + return ordinary + } + return ordinary.sequence < reply.sequence ? ordinary : reply + } + + function shiftOrdinary(): PendingPtyInputWrite | undefined { + const removed = pendingOrdinary[pendingOrdinaryHead] + pendingOrdinary[pendingOrdinaryHead] = undefined + pendingOrdinaryHead += 1 + compactOrdinary() + resetSequenceIfEmpty() + return removed + } + + function shiftReply(): PendingPtyInputWrite | undefined { + const removed = pendingReplies[pendingReplyHead] + pendingReplies[pendingReplyHead] = undefined + pendingReplyHead += 1 + if (removed) { + pendingReplyCount -= 1 + pendingReplyCodeUnits -= removed.text.length + } + compactReplies() + resetSequenceIfEmpty() + return removed + } + + function removePending(item: PendingPtyInputWrite): void { + if (item.replyOnly) { + shiftReply() + } else { + shiftOrdinary() + } + } + + function admitReply(text: string): boolean { + if (text.length > PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS) { + return false + } + while ( + pendingReplyCount >= PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES || + pendingReplyCodeUnits + text.length > PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS + ) { + if (!shiftReply()) { + return false + } + } + return true + } + + function clearPending(): void { + pendingOrdinary = [] + pendingOrdinaryHead = 0 + pendingReplies = [] + pendingReplyHead = 0 + pendingReplyCount = 0 + pendingReplyCodeUnits = 0 + nextSequence = 0 + } + async function drain(): Promise { - while (pending.length > 0) { - const next = pending[0] - if (!next) { - pending.shift() - continue - } - if (!deps.isWritable(next.id)) { - pending.shift() - continue - } - if (next.tooLarge !== false) { - next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (next.tooLarge) { - pending.shift() - continue - } + let failureGeneration = generation + // Why: the drain yields, so the owner may rebind before the failure surfaces; report the id that actually failed. + let failingId: string | null = null + try { + let next: PendingPtyInputWrite | undefined + while ((next = firstPending())) { + failureGeneration = generation + failingId = next.id if (!deps.isWritable(next.id)) { - pending.shift() + removePending(next) continue } - } - // Why: dense input streams (SGR wheel reports during trackpad momentum, - // key auto-repeat) enqueue one tiny item per event. Writing one item per - // macrotask turn lets Chromium's nested-timer clamp pace the drain at - // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after - // the gesture ended and the TUI visibly replays them one by one. - // Coalescing consecutive validated small items into a single write keeps - // the PTY byte stream identical while draining the backlog in one turn. - if (next.chunks === undefined && isCoalescibleText(next.text)) { - let payload = next.text - pending.shift() - while (pending.length > 0) { - const peek = pending[0] - if ( - !peek || - peek.id !== next.id || - peek.tooLarge !== false || - peek.chunks !== undefined || - !isCoalescibleText(peek.text) || - payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS - ) { - break + if (next.tooLarge !== false) { + next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (firstPending() !== next) { + continue + } + if (next.tooLarge) { + removePending(next) + continue + } + if (!deps.isWritable(next.id)) { + removePending(next) + continue } - payload += peek.text - pending.shift() } - deps.write(next.id, payload) - if (pending.length > 0) { + // Why: dense input streams (SGR wheel reports during trackpad momentum, + // key auto-repeat) enqueue one tiny item per event. Writing one item per + // macrotask turn lets Chromium's nested-timer clamp pace the drain at + // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after + // the gesture ended and the TUI visibly replays them one by one. + // Coalescing consecutive validated small items into a single write keeps + // the PTY byte stream identical while draining the backlog in one turn. + if (next.chunks === undefined && isCoalescibleInput(next)) { + let payload = next.text + removePending(next) + let peek: PendingPtyInputWrite | undefined + while ((peek = firstPending())) { + if ( + peek.id !== next.id || + peek.tooLarge !== false || + peek.chunks !== undefined || + !isCoalescibleInput(peek) || + payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + ) { + break + } + payload += peek.text + removePending(peek) + } + deps.write(next.id, payload) + if (firstPending()) { + await yieldBetweenWrites() + } + continue + } + next.chunks ??= iterateTerminalInputChunks(next.text) + const chunk = + next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } + next.nextChunk = undefined + if (chunk.done) { + removePending(next) + continue + } + deps.write(next.id, chunk.value) + const following = next.chunks.next() + if (following.done) { + removePending(next) + } else { + next.nextChunk = following.value + } + if (firstPending()) { await yieldBetweenWrites() } - continue } - next.chunks ??= iterateTerminalInputChunks(next.text) - const chunk = - next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } - next.nextChunk = undefined - if (chunk.done) { - pending.shift() - continue + } catch (error) { + const failureIsCurrent = generation === failureGeneration + if (failureIsCurrent) { + clearPending() + failedGeneration = generation } - deps.write(next.id, chunk.value) - const following = next.chunks.next() - if (following.done) { - pending.shift() - } else { - next.nextChunk = following.value - } - if (pending.length > 0) { - await yieldBetweenWrites() + console.warn('[pty-input-write-queue] drain failed:', error) + if (failureIsCurrent && failingId !== null) { + try { + deps.onDrainFailure?.(failingId) + } catch (recoveryError) { + console.warn('[pty-input-write-queue] failure handler failed:', recoveryError) + } } } } @@ -120,27 +248,51 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput if (drainPromise) { return } - drainPromise = drain().finally(() => { + const finishDrain = (): void => { drainPromise = null - if (pending.length > 0) { + if (firstPending()) { scheduleDrain() } - }) + } + drainPromise = drain().finally(finishDrain) + } + + function enqueueInput(id: string, data: string, queryReply: boolean): boolean { + try { + if (failedGeneration === generation) { + return false + } + const replyOnly = queryReply && needsCookedEchoSafeQueryReply(data) + if (replyOnly && !admitReply(data)) { + return false + } + const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(data) + if (tooLarge === true) { + return false + } + const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge } + nextSequence += 1 + if (replyOnly) { + pendingReplies.push(item) + pendingReplyCount += 1 + pendingReplyCodeUnits += data.length + } else { + pendingOrdinary.push(item) + } + scheduleDrain() + return true + } catch { + return false + } } return { enqueue(id: string, data: string): boolean { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (tooLarge === true) { - return false - } - pending.push({ id, text: data, tooLarge }) - scheduleDrain() - return true - } catch { - return false - } + return enqueueInput(id, data, false) + }, + + enqueueQueryReply(id: string, data: string): boolean { + return enqueueInput(id, data, true) }, async waitForDrain(): Promise { @@ -150,7 +302,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput }, clear(): void { - pending = [] + generation += 1 + failedGeneration = null + clearPending() } } } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 6bbba35a4be..902652ad082 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -14,6 +14,7 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' +import { PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES } from './pty-input-write-queue' describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window @@ -107,6 +108,29 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('routes a thrown renderer write to the owning transport recovery callback', async () => { + const failure = new Error('ipc write failed') + vi.mocked(window.api.pty.write).mockImplementation(() => { + throw failure + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + try { + const { createIpcPtyTransport } = await import('./pty-transport') + const recovery = vi.fn() + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: { onWriteUnavailable: recovery } }) + + expect(transport.sendInput('input')).toBe(true) + expect(transport.sendInput('later-input')).toBe(false) + + expect(recovery).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure) + } finally { + warn.mockRestore() + } + }) + it('does not create a second kill authority when a mounted pane detaches', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const kill = window.api.pty.kill as unknown as ReturnType @@ -1132,6 +1156,36 @@ describe('createIpcPtyTransport', () => { } }) + it('bounds immediate cooked replies without shedding ordinary-path lookalikes', async () => { + vi.useFakeTimers() + try { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + const first = '\x1b[?10000;1n' + const ordinary = '\x1b]10;user-ordinary-marker\x1b\\' + const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) + + await transport.connect({ url: '', callbacks: {} }) + expect(transport.sendInputImmediate(first)).toBe(true) + expect(transport.sendInput(ordinary)).toBe(true) + for (const reply of replies) { + expect(transport.sendInputImmediate(reply)).toBe(true) + } + + await vi.runAllTimersAsync() + + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', first], + ['pty-1', ordinary], + ...replies + .slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) + .map((reply) => ['pty-1', reply]) + ]) + } finally { + vi.useRealTimers() + } + }) + it('yields while validating accepted large local IPC terminal input before renderer-to-main writes', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index edba63981cc..f7248ec704d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -577,9 +577,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let ptyId: string | null = null // Why: replayed eager-buffer data (often from a prior app session) must not fire fresh bells, unread marks, or notifications on reconnect. let suppressAttentionEvents = false + let storedCallbacks: Parameters[0]['callbacks'] = {} const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data) + write: (id, data) => window.api.pty.write(id, data), + // Guard like the registered writeUnavailable handler: a rebind during the async drain + // must not tell the new pane that its write failed. + onDrainFailure: (id) => { + if (ptyId === id) { + storedCallbacks.onWriteUnavailable?.() + } + } }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, @@ -593,8 +601,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra onAgentExited, onAgentStatus }) - let storedCallbacks: Parameters[0]['callbacks'] = {} - // Why: a new pane can attach to the same ptyId before the old instance's detach() runs; track owned handlers so unregister never deletes the live one. const ownedDataAndReplayHandlers = new Map< string, @@ -1051,7 +1057,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - return inputWriteQueue.enqueue(ptyId, data) + return inputWriteQueue.enqueueQueryReply(ptyId, data) }, ...(connectionId diff --git a/tests/e2e/pty-input-write-queue-ssh.spec.ts b/tests/e2e/pty-input-write-queue-ssh.spec.ts new file mode 100644 index 00000000000..353f7fd22b0 --- /dev/null +++ b/tests/e2e/pty-input-write-queue-ssh.spec.ts @@ -0,0 +1,66 @@ +import { test } from './helpers/orca-app' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function remoteOscQueryScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "let received = ''", + `process.stdout.write('REMOTE_OSC_READY_${runId}\\n')`, + "process.stdin.on('data', (chunk) => {", + ' received += chunk', + " if (received.includes('\\x1b]10;rgb:')) {", + ` process.stdout.write('REMOTE_OSC_REPLY_${runId}\\n')`, + ' process.exit(0)', + ' }', + '})', + "setTimeout(() => process.stdout.write('\\x1b]10;?\\x1b\\\\'), 100)" + ].join(';') +} + +test.describe('PTY input write queue over SSH', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH E2E.') + test.skip(process.platform === 'win32', 'Docker SSH E2E uses POSIX ssh tooling.') + + test('returns an xterm OSC query reply through the live SSH PTY', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await connectDockerSshRelayTarget(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + const runId = String(Date.now()) + + await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteOscQueryScript(runId))}`) + await waitForTerminalOutput(orcaPage, `REMOTE_OSC_READY_${runId}`, 30_000, 80_000) + await waitForTerminalOutput(orcaPage, `REMOTE_OSC_REPLY_${runId}`, 30_000, 80_000) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) From ec7e3ea47742ba10aa4e176d6271dc99d00ac345 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:32:08 -0700 Subject: [PATCH 002/211] fix(terminal): prevent paired activity renderer starvation (#13508) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- config/reliability-gates.jsonc | 116 ++- ...ession-tabs-agent-status-heartbeat.test.ts | 192 +++++ ...ile-session-tabs-agent-status-heartbeat.ts | 133 +++ ...ntime-hook-agent-status-projection.test.ts | 59 ++ src/main/runtime/orca-runtime.test.ts | 16 +- src/main/runtime/orca-runtime.ts | 264 ++++-- ...-cold-park-pre-gate-loop.react185.test.tsx | 342 ++++++++ .../terminal-cold-park-verdict-loop.test.tsx | 1 + ...-watcher-synchronization.react185.test.tsx | 251 ++++++ ...parked-terminal-watcher-synchronization.ts | 167 ++++ .../use-terminal-tab-cold-parking.test.ts | 2 + .../use-terminal-tab-cold-parking.ts | 44 +- .../paired-terminal-parking-fixture.ts | 26 + .../helpers/paired-terminal-parking-oracle.ts | 2 + .../paired-terminal-title-fanout-oracle.ts | 310 +++++++ ...-tabs-decorative-title-fanout.unit.test.ts | 772 ++++++++++++++++++ ...n-tabs-rich-status-boundaries.unit.test.ts | 305 +++++++ 17 files changed, 2916 insertions(+), 86 deletions(-) create mode 100644 src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts create mode 100644 src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts create mode 100644 tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts create mode 100644 tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts create mode 100644 tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2e244697586..a4fdc4e284c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-09", + "updatedAt": "2026-08-10", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -4315,14 +4315,16 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, then drive 180 timed real-PTY spinner frames through a second production `session.tabs.subscribeAll` transport and the real Zustand store. Each worktree must publish and apply only its exact initial-working and final-idle titles. Both topologies ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Production-parity React contracts reproduce and remove the independent cold-park watcher/title feedback loop, including memo-cache discard, StrictMode cleanup replay, detached split-PTY replacement, and the zero-park fast path. A deterministic cross-boundary contract drives 24 real PTY trackers in distinct worktrees through 2,880 decorative spinner and bare-native redraw chunks and counts host publications, serialized bytes, renderer apply calls, and store mutations while preserving exact per-PTY raw chunks and per-worktree semantic transitions. Fake-clock continuation beyond the 30-minute stale boundary requires one deduplicated refresh per worktree every 15 minutes, globally spaced by at least 50 ms, with every mirrored working status still fresh. Exact-identical Pi plus rotating normalized Pi/Grok frames renew coarse working/permission state; rich prompt/tool/question fields retain their original 30-minute lease, and `stateStartedAt`/acknowledgement identity remain stable across title/hook source swaps. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -4335,11 +4337,20 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts", + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts", + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts", + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts", + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx", + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx", + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx", + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/renderer/src/web/web-preload-api.test.ts", "src/main/ipc/runtime-environments.test.ts", @@ -4371,6 +4382,59 @@ "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts", + "assertions": [ + "one globally spaced FIFO refreshes each continuously active worktree before status expiry", + "sibling PTYs deduplicate to one worktree refresh while semantic transitions bypass the queue", + "sole-source PTY teardown removes queued work while sibling PTY sources remain deduplicated", + "worktree, pending-queue, and timer state is removed on teardown" + ] + }, + { + "file": "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts", + "assertions": [ + "a live pending question remains visible under the current live shell title but cannot cross a later working interval, identity-only owner title, or provider generation", + "inherited tool names, stale rows, resume-only rows, and unconfirmed restored rows cannot reopen a shell-reclaimed pane" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx", + "assertions": [ + "parked terminal and unified-label title republication settles without a maximum-depth loop", + "equivalent semantic inputs remain stable when React discards memoized identities" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx", + "assertions": [ + "StrictMode cleanup replay restores watcher ownership", + "detached split-PTY and layout replacement reconciles", + "unparked unrelated writes perform zero PTY-map reads" + ] + }, + { + "file": "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts", + "assertions": [ + "2,880 decorative spinner and bare-native real-PTY OSC chunks publish zero session-tabs snapshots or serialized bytes after semantic convergence", + "the viewer performs zero snapshot applies and zero store mutations during decorative activity", + "after 30 minutes, one globally spaced refresh per worktree keeps every mirrored working status fresh", + "exact Pi and rotating normalized Pi/Grok frames renew explicit and fallback status without changing stateStartedAt or acknowledgement semantics", + "matching title evidence renews retained OSC hook status without resetting stateStartedAt, then drops unverifiable prompt metadata after the hook row expires", + "renderer-authored rich status keeps its original lease, then renews only coarse title-confirmed state and provider identity", + "ordinary-shell title transitions replace working stateStartedAt with the completion transition time", + "decorative heartbeats remain blocked until unknown foreground ownership resolves, then resume at the bounded interval", + "tracked terminal state reset clears delayed foreground ownership markers", + "raw chunks, semantic idle transitions, and visible output remain exact" + ] + }, + { + "file": "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts", + "assertions": [ + "pending renderer, retained OSC, and live-hook questions survive the shell title that currently obscures them", + "a new working interval, provider generation, or identity owner strips the prior question" + ] + }, { "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] @@ -4425,6 +4489,7 @@ "assertions": [ "ordinary-parks paired terminals and restores authoritative host scrollback", "warm-mounted hidden paired streams schedule zero renderer output work under sustained host output", + "six hidden real PTYs publish and apply only exact working and idle title transitions across paired transport", "client recovery completes while every target terminal remains unmounted in the host renderer" ] }, @@ -4433,6 +4498,7 @@ "assertions": [ "ordinary-parks paired terminals against an isolated headless Orca host", "cold-activates only visible paired terminals against an isolated headless host", + "the headless host bounds real-PTY decorative title transport and production store application", "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" ] }, @@ -4527,6 +4593,42 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 1.56, + "summary": "All 16 production-parity parking/watcher contracts passed on the combined candidate, covering the maximum-depth loop, semantic stability, StrictMode ownership, split-PTY reconciliation, and zero-park fast path." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 27.6, + "summary": "The isolated headed desktop host and separate encrypted paired client drove 180 timed decorative real-PTY titles across six hidden worktrees and observed exactly 12 semantic transport envelopes and 12 production store title mutations, then reduced six managers to one, stayed within heap/cell/timer budgets, restored the bounded host tail exactly, and continued input/output while the host renderer retained zero target managers." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 33.9, + "summary": "The isolated headless `orca serve` host passed the same six-worktree real-title transport/store bound, parking, and exact-restoration oracle; a second scenario proved cold activation mounted only the visible paired terminal." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 5.62, + "summary": "Forty-two deterministic contracts passed. The real-PTY cross-boundary contract delivered all 2,880 decorative spinner and bare-native redraw chunks with zero hot-path host publications, serialized bytes, renderer applies, or store mutations; continued past 30 minutes with one globally spaced freshness refresh per worktree every 15 minutes; kept all 24 mirrored statuses working; and preserved stable stateStartedAt. Exact-identical Pi and rotating normalized Pi/Grok frames renewed renderer-authored and retained-hook status without changing acknowledgement semantics. Rich status could not cross task completion, permission, provider-generation, or identity-owner boundaries, while neutral titles preserved same-task prompt and acknowledgement identity until authoritative shell evidence. Fresh renderer, retained OSC, and live-hook questions remained visible under the current shell title, but a later working interval, identity-only owner title, or provider generation stripped them; inherited tools and stale, resume-only, restored, or prior-interval questions stayed suppressed. After each rich source's original lease expired, title evidence kept only coarse working state and provider identity fresh while dropping unverifiable prompts. Unknown foreground ownership held publications at zero until resolution, then resumed the bounded heartbeat; tracked terminal state reset removed its delayed marker. The focused heartbeat contracts proved worktree deduplication, FIFO fairness, semantic bypass, PTY-source teardown, and timer cleanup. Causal-disable controls made ownership, teardown, retained source-switch, renderer rich-TTL, completion-transition, boundary-ordering, and live-question oracles fail; restoring the production corrections returned green." + }, { "date": "2026-08-07", "runner": "local", @@ -4691,11 +4793,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The parked-title production-parity control throws Maximum update depth exceeded on the affected parking synchronization and settles with semantic dependencies; forced memo-cache discard, StrictMode cleanup replay, and split-PTY replacement are independently red before their corrections and green on the combined candidate. The real-PTY session-tabs oracle produces 96 full snapshots, 64,032 serialized bytes, 96 renderer applies, and 96 store mutations on unmodified main and on a one-line causal revert before reaching the alternating native-title phase; the candidate produces zero in all four dimensions while preserving all 2,880 redraw chunks and 24 exact semantic transitions. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." }, "performanceBudget": { "required": true, - "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. The headed and headless production transports each admit only the initial and final semantic title snapshots per worktree across 180 timed decorative frames, and the real store performs only those 12 exact title mutations. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked watcher synchronization is keyed by bounded parking semantics rather than fresh object identities; unrelated store writes perform zero PTY-map reads when no tab is parked, while parked reconciliation remains bounded by the worktree's terminal tabs and assignments. Decorative title ticks are collapsed both on the paired client-event channel and at the authoritative real-PTY session-tabs classifier before snapshot construction, serialization, transport, recovery, or store application; semantic facts remain exact. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. The forced hidden-worktree retention policy keeps at most four otherwise un-parkable worktrees mounted and force-parks all after 15 minutes; the separate ordinary hot-retain policy keeps at most four recently visible worktrees warm for five minutes. Legacy hosts still gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", @@ -4712,7 +4814,7 @@ "The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.", "The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism." ], - "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." + "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event or session-tabs publication volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, { "id": "terminal-performance.no-hot-list-sessions", diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts new file mode 100644 index 00000000000..582837826ac --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMobileSessionTabsAgentStatusHeartbeat, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS +} from './mobile-session-tabs-agent-status-heartbeat' + +describe('mobile session-tabs agent-status heartbeat', () => { + const worktreesByPtyId = new Map() + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + worktreesByPtyId.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('refreshes continuously active PTYs through one globally capped FIFO', () => { + const emitted: { worktreeId: string; at: number }[] = [] + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push({ worktreeId, at: Date.now() }) + ) + const ptyIds = Array.from({ length: 24 }, (_, index) => `pty-${index}`) + for (const ptyId of ptyIds) { + worktreesByPtyId.set(ptyId, [ptyId.replace('pty', 'worktree')]) + heartbeat.observeSemanticTitle(ptyId) + } + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - 1) + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.runOnlyPendingTimers() + expect(emitted).toEqual([]) + + vi.advanceTimersByTime(1) + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * (ptyIds.length - 1)) + + expect(emitted.map(({ worktreeId }) => worktreeId)).toEqual( + ptyIds.map((ptyId) => ptyId.replace('pty', 'worktree')) + ) + for (let index = 1; index < emitted.length; index += 1) { + expect(emitted[index]!.at - emitted[index - 1]!.at).toBeGreaterThanOrEqual( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + } + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.runOnlyPendingTimers() + expect(emitted).toHaveLength(ptyIds.length) + heartbeat.dispose() + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets semantic transitions bypass and cancel queued heartbeats', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + heartbeat.observeSemanticTitle('pty-2') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + heartbeat.removePty('pty-1') + heartbeat.removePty('pty-2') + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates sibling PTYs because one snapshot refreshes their worktree', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-1']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + heartbeat.removePty('pty-1') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + }) + + it('does not slip spacing-delayed worktrees to the next stale boundary', () => { + const emitted: { worktreeId: string; at: number }[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push({ worktreeId, at: Date.now() }) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS) + expect(emitted).toEqual([ + { worktreeId: 'worktree-1', at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS }, + { + worktreeId: 'worktree-2', + at: + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + } + ]) + + vi.advanceTimersByTime( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted.at(-1)).toEqual({ + worktreeId: 'worktree-1', + at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + }) + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - 1) + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted).toHaveLength(3) + + vi.advanceTimersByTime(1) + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted.at(-1)).toEqual({ + worktreeId: 'worktree-2', + at: + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + }) + }) + + it('drops removed worktrees and pending work during teardown', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + expect(vi.getTimerCount()).toBe(1) + heartbeat.removePty('pty-1') + vi.runAllTimers() + expect(emitted).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + + heartbeat.scheduleDecorativeHeartbeat('pty-2') + expect(vi.getTimerCount()).toBe(1) + heartbeat.removeWorktree('worktree-2') + expect(vi.getTimerCount()).toBe(0) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + expect(vi.getTimerCount()).toBe(1) + heartbeat.cancelPending() + expect(vi.getTimerCount()).toBe(0) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.dispose() + vi.runAllTimers() + expect(emitted).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts new file mode 100644 index 00000000000..6c80cf4a35d --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts @@ -0,0 +1,133 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' + +// Refresh halfway through the stale lease so a bounded global queue has ample drain time. +export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS = AGENT_STATUS_STALE_AFTER_MS / 2 +export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50 + +export type MobileSessionTabsAgentStatusHeartbeat = { + observeSemanticTitle: (ptyId: string) => void + scheduleDecorativeHeartbeat: (ptyId: string) => void + removePty: (ptyId: string) => void + removeWorktree: (worktreeId: string) => void + cancelPending: () => void + dispose: () => void +} + +export function createMobileSessionTabsAgentStatusHeartbeat( + resolveWorktreeIds: (ptyId: string) => Iterable, + emit: (worktreeId: string) => void +): MobileSessionTabsAgentStatusHeartbeat { + const lastEligibilityCheckAtByPtyId = new Map() + const lastRefreshAtByWorktreeId = new Map() + const pendingPtyIdsByWorktreeId = new Map>() + let lastGlobalHeartbeatAt: number | null = null + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const arm = (): void => { + if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) { + return + } + const now = Date.now() + const delay = + lastGlobalHeartbeatAt === null + ? 0 + : Math.max( + 0, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - (now - lastGlobalHeartbeatAt) + ) + timer = setTimeout(() => { + timer = null + const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value + if (typeof worktreeId !== 'string') { + return + } + const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) + pendingPtyIdsByWorktreeId.delete(worktreeId) + const emittedAt = Date.now() + lastRefreshAtByWorktreeId.set(worktreeId, emittedAt) + for (const ptyId of pendingPtyIds ?? []) { + lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt) + } + lastGlobalHeartbeatAt = emittedAt + emit(worktreeId) + arm() + }, delay) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + return { + observeSemanticTitle(ptyId: string): void { + const observedAt = Date.now() + lastEligibilityCheckAtByPtyId.set(ptyId, observedAt) + for (const worktreeId of resolveWorktreeIds(ptyId)) { + lastRefreshAtByWorktreeId.set(worktreeId, observedAt) + pendingPtyIdsByWorktreeId.delete(worktreeId) + } + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + scheduleDecorativeHeartbeat(ptyId: string): void { + const now = Date.now() + const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId) + if ( + lastEligibilityCheckAt !== undefined && + now - lastEligibilityCheckAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + return + } + lastEligibilityCheckAtByPtyId.set(ptyId, now) + for (const worktreeId of resolveWorktreeIds(ptyId)) { + const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) + if ( + lastRefreshAt === undefined || + now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set() + pendingPtyIds.add(ptyId) + pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds) + } + } + arm() + }, + removePty(ptyId: string): void { + lastEligibilityCheckAtByPtyId.delete(ptyId) + for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) { + pendingPtyIds.delete(ptyId) + if (pendingPtyIds.size === 0) { + pendingPtyIdsByWorktreeId.delete(worktreeId) + } + } + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + removeWorktree(worktreeId: string): void { + lastRefreshAtByWorktreeId.delete(worktreeId) + pendingPtyIdsByWorktreeId.delete(worktreeId) + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + cancelPending(): void { + clearTimer() + pendingPtyIdsByWorktreeId.clear() + }, + dispose(): void { + clearTimer() + pendingPtyIdsByWorktreeId.clear() + lastEligibilityCheckAtByPtyId.clear() + lastRefreshAtByWorktreeId.clear() + lastGlobalHeartbeatAt = null + } + } +} diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 47715ea7c5d..7981366cadb 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -230,6 +230,65 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('does not carry a shell-obscured question into the next working interval', async () => { + const rows = [hookRow()] + const runtime = await createRuntimeWithHookRows(rows) + const questionAt = rows[0]!.receivedAt + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(questionAt + 1) + try { + const initial = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const initialTab = initial.tabs[0] + if (initialTab?.type !== 'terminal' || !initialTab.terminal) { + throw new Error('expected a live terminal handle') + } + await runtime.renameTerminal(initialTab.terminal, 'bash') + observePaneTitle(runtime, 'bash') + let result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + let tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).toEqual( + expect.objectContaining({ state: 'waiting', interactivePrompt: ASK_PROMPT }) + ) + + dateNow.mockReturnValue(questionAt + 2) + observePaneTitle(runtime, '⠋ Claude') + result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).toEqual( + expect.objectContaining({ state: 'working', prompt: '' }) + ) + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + } finally { + dateNow.mockRestore() + } + }) + + it('does not carry a hook question across a provider generation reset', async () => { + const runtime = await createRuntimeWithHookRows([hookRow()]) + const internals = runtime as unknown as { + ptysById: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void + } + internals.ptysById.get(PTY_ID)!.title = 'bash' + internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + }) + + it('does not carry a hook question across an identity-only owner title', async () => { + const runtime = await createRuntimeWithHookRows([hookRow()]) + const internals = runtime as unknown as { + ptysById: Map + } + internals.ptysById.get(PTY_ID)!.title = 'bash' + observePaneTitle(runtime, 'Cursor Agent') + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + }) + // The title path is refreshed live; an older hook `done` must not erase it. it('keeps the title-derived working state when the hook row predates the title', async () => { const now = Date.now() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 90a40f9da06..6ab019db6ca 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -24549,7 +24549,7 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) - it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + it('keeps decorative Pi frames queued behind the foreground owner probe', async () => { const foregroundProcess = deferred() const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) const getForegroundProcess = vi.fn(() => foregroundProcess.promise) @@ -24568,9 +24568,9 @@ describe('OrcaRuntimeService', () => { const events: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) - runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠋ Pi\x07', 123) await new Promise((resolve) => setImmediate(resolve)) - runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠙ Pi\x07', 124) await new Promise((resolve) => setImmediate(resolve)) expect(getForegroundProcess).toHaveBeenCalledTimes(1) @@ -24585,18 +24585,22 @@ describe('OrcaRuntimeService', () => { tabs: [ expect.objectContaining({ type: 'terminal', - title: 'OMP ready', + title: '⠋ OMP', agentStatus: expect.objectContaining({ - state: 'done', + state: 'working', agentType: 'omp', terminalHandle: terminal.handle, - terminalTitle: 'OMP ready' + terminalTitle: '⠋ OMP' }) }) ] }) ]) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠹ Pi\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(1) + unsubscribe() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 10e143df938..6be6eb98c24 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27,10 +27,10 @@ import type { } from './remote-terminal-source-range-consumer' import { createTerminalTitleTracker, - stripBrailleSpinnerGlyphs, type TerminalTitleFactMeta, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' import type { TerminalSideEffectBatch, @@ -981,6 +981,10 @@ import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' +import { + createMobileSessionTabsAgentStatusHeartbeat, + type MobileSessionTabsAgentStatusHeartbeat +} from './mobile-session-tabs-agent-status-heartbeat' import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer' import { appendRecentPtyPathCandidates, @@ -1300,6 +1304,9 @@ type RuntimePtyWorktreeRecord = { lastAgentStatus: AgentStatus | null /** False until a live OSC frame sets the status; restore seeds never set it. */ lastAgentStatusObservedLive: boolean + lastAgentStatusStartedAtEpochMs: number | null + // A later semantic title interval cannot inherit rich fields from an earlier task. + lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null lastOscTitleAt: number | null // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, @@ -1509,12 +1516,8 @@ type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker // Why: onPtyData batches the mobile session-tab touch to once per chunk; // the stale-working-title timer fires between chunks and must touch - // immediately. These flags route the tracker callback to the right mode. + // immediately. This flag routes the tracker callback to the right mode. applyingChunk: boolean - // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the - // synthetic path gates mobile snapshot fan-out on a non-decorative title - // change (spinner glyph + status comparison key kept below). - applyingSyntheticFrame: boolean lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean // Why: facts observed while applying a chunk are batched into one @@ -2746,6 +2749,11 @@ export class OrcaRuntimeService { createMobileSessionTabsNotifyCoalescer((worktreeId) => this.notifyMobileSessionTabsChangedNow(worktreeId) ) + private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat = + createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId), + (worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId) + ) // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) // each await a full host reveal; only one terminal can be focused, so latest-wins // single-flight bounds host work. Does not replace cheaper activation or @@ -6534,6 +6542,21 @@ export class OrcaRuntimeService { } } + private getMobileSessionWorktreeIdsForPty(ptyId: string): string[] { + const worktreeIds: string[] = [] + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const hasPtyBackedTab = snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) + if (hasPtyBackedTab) { + worktreeIds.push(worktreeId) + } + } + return worktreeIds + } + /** Bump the snapshot version and emit, coalesced unless `immediate`. * Why the bump: clients gate mirrored snapshots on a strictly increasing * `snapshotVersion`, so a re-emit at the same version is silently dropped. */ @@ -9081,6 +9104,9 @@ export class OrcaRuntimeService { // subscriber closing mid-window still receives the latest settled state. this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(subscription) + if (this.mobileSessionTabListeners.size === 0) { + this.mobileSessionTabsAgentStatusHeartbeat.cancelPending() + } } } @@ -9867,13 +9893,11 @@ export class OrcaRuntimeService { ingestSyntheticTitleFrame(ptyId: string, data: string): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.applyingChunk = true - entry.applyingSyntheticFrame = true entry.chunkTouchedSessionTabs = false try { entry.tracker.applySyntheticTitleFrame(data) } finally { entry.applyingChunk = false - entry.applyingSyntheticFrame = false this.flushPendingTerminalSideEffectFacts(ptyId, entry) } if (entry.chunkTouchedSessionTabs) { @@ -10111,13 +10135,13 @@ export class OrcaRuntimeService { return { ...snapshot, lastTitle: tracked } } - /** Decorative comparison key: spinner frame glyphs stripped, derived agent - * status kept so a working→idle flip with an otherwise-equal label still - * counts as a change. */ + /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { - return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( - normalizedTitle - )}` + // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. + const normalizedSignature = + rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) + const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) + return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` } private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { @@ -10148,28 +10172,42 @@ export class OrcaRuntimeService { ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - // Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is - // false — but the tracker title is that pane's only Cursor identity and still has to - // fan out to the sidebar and mobile (#10258). - if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) { - return - } + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } + const tracksReplicatedStatus = + live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 + const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null + if ( + tracksReplicatedStatus && + decorativeOnly && + !this.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId) && + (titleStatus === 'working' || titleStatus === 'permission') + ) { + // Normalized Pi/Gemini/Grok frames still renew the replicated status lease. + this.mobileSessionTabsAgentStatusHeartbeat.scheduleDecorativeHeartbeat(ptyId) + } + // Why: an identity-only cursor title records nothing, but the tracker + // title is that pane's only Cursor identity and must still fan out (#10258). + if (!changed && !identityOnlyTitle) { + return + } if (live?.applyingChunk) { // Why: synthetic spinner ticks change only the braille glyph // ~12.5x/sec; fanning out full mobile session snapshots per frame // is pure churn. Raw lastOscTitle updates above stay cheap. - if (!(live.applyingSyntheticFrame && decorativeOnly)) { + if (!decorativeOnly) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) live.chunkTouchedSessionTabs = true } } else { // Stale-working-title timer path — fires between chunks, so the // per-chunk batching in onPtyData cannot pick it up. + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) this.touchMobileSessionSnapshotsForPty(ptyId) } }, @@ -10213,7 +10251,6 @@ export class OrcaRuntimeService { const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, - applyingSyntheticFrame: false, lastMobileTitleGateKey: null, chunkTouchedSessionTabs: false, pendingFacts: [], @@ -10255,11 +10292,22 @@ export class OrcaRuntimeService { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() + const observedAtEpochMs = identityOnlyTitle ? null : Date.now() pty.lastOscTitle = recordedTitle pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt - pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now() + pty.lastOscTitleEpochMs = observedAtEpochMs pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true + if (prevStatus !== agentStatus) { + pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs + } + if ( + identityOnlyTitle || + terminalTitleBlocksExplicitAgentStatus(recordedTitle) || + (prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus) + ) { + pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now() + } if (identityOnlyTitle) { pty.managementTitle = null pty.managementTitleAt = null @@ -10339,6 +10387,8 @@ export class OrcaRuntimeService { private disposePtyTitleTracker(ptyId: string): void { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { titleGateKeys.delete(ptyId) } @@ -10360,6 +10410,8 @@ export class OrcaRuntimeService { // Why: the prior process's live frames say nothing about the replacement, // so the seed a same-id restore applies must not inherit its authority. pty.lastAgentStatusObservedLive = false + pty.lastAgentStatusStartedAtEpochMs = null + pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() pty.managementTitle = null pty.managementTitleAt = null } @@ -16916,6 +16968,9 @@ export class OrcaRuntimeService { return } this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (this.mobileSessionTabListeners.size > 0) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) + } if (!foregroundAgentChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -23975,6 +24030,7 @@ export class OrcaRuntimeService { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) @@ -29012,6 +29068,8 @@ export class OrcaRuntimeService { lastExitCode: null, lastAgentStatus: null, lastAgentStatusObservedLive: false, + lastAgentStatusStartedAtEpochMs: null, + lastAgentStatusRichInvalidatedAtEpochMs: null, lastOscTitle: null, lastOscTitleAt: null, lastOscTitleEpochMs: null, @@ -29763,6 +29821,7 @@ export class OrcaRuntimeService { nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) @@ -30299,16 +30358,21 @@ export class OrcaRuntimeService { (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) ? hookAgentStatus.providerSession : tab.agentStatus?.providerSession - const normalizedTabAgentStatus = tab.agentStatus - ? normalizeCompatibleAgentStatusEntryForOwner( - { - ...tab.agentStatus, - ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) - }, - ownerAgent - ) - : null - // Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost. + const statusPty = liveLeafPty ?? mobileStatusPty + const normalizedTabAgentStatus = this.renewMobileAgentStatusFromPtyTitle( + tab.agentStatus + ? normalizeCompatibleAgentStatusEntryForOwner( + { + ...tab.agentStatus, + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + }, + ownerAgent + ) + : null, + statusPty, + { preserveQuestionUnderShellTitle: true } + ) + // Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title. const hasLiveAgentSignal = normalizedTabAgentStatus?.interactivePrompt != null || normalizedTabAgentStatus?.toolName != null @@ -30328,8 +30392,10 @@ export class OrcaRuntimeService { agentStatus: { state: 'done' as const, prompt: '', - updatedAt: normalizedTabAgentStatus.updatedAt, - stateStartedAt: normalizedTabAgentStatus.stateStartedAt, + updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt, + stateStartedAt: + statusPty?.lastAgentStatusStartedAtEpochMs ?? + normalizedTabAgentStatus.stateStartedAt, paneKey: normalizedTabAgentStatus.paneKey, stateHistory: [], agentType: normalizedTabAgentStatus.agentType, @@ -30422,6 +30488,91 @@ export class OrcaRuntimeService { } } + private renewMobileAgentStatusFromPtyTitle( + status: AgentStatusEntry | null, + pty: RuntimePtyWorktreeRecord | null, + options: { preserveQuestionUnderShellTitle?: boolean } = {} + ): AgentStatusEntry | null { + if (!status || !pty) { + return status + } + if ( + options.preserveQuestionUnderShellTitle && + status.interactivePrompt != null && + terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const richStatusCanOwnTitleInterval = + pty.lastAgentStatusRichInvalidatedAtEpochMs === null || + status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + const titleEvidenceAt = pty.lastOscTitleEpochMs + if (titleEvidenceAt === null) { + return richStatusCanOwnTitleInterval ? status : null + } + const buildTitleOnlyStatus = ( + state: AgentStatusEntry['state'], + updatedAt: number, + stateStartedAt: number + ): AgentStatusEntry => ({ + state, + prompt: '', + updatedAt, + stateStartedAt, + paneKey: status.paneKey, + stateHistory: [], + ...(status.agentType ? { agentType: status.agentType } : {}), + ...(status.terminalHandle ? { terminalHandle: status.terminalHandle } : {}), + ...(status.worktreeId ? { worktreeId: status.worktreeId } : {}), + ...(status.tabId ? { tabId: status.tabId } : {}), + ...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}), + ...(status.providerSession ? { providerSession: status.providerSession } : {}) + }) + const titleConfirmsState = + (pty.lastAgentStatus === 'working' && status.state === 'working') || + (pty.lastAgentStatus === 'permission' && + (status.state === 'blocked' || status.state === 'waiting')) + if (!titleConfirmsState) { + if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + return status + } + if ( + pty.lastAgentStatus === null && + !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const titleState = + pty.lastAgentStatus === 'working' + ? 'working' + : pty.lastAgentStatus === 'permission' + ? 'blocked' + : 'done' + return buildTitleOnlyStatus( + titleState, + titleEvidenceAt, + pty.lastAgentStatusStartedAtEpochMs ?? titleEvidenceAt + ) + } + const richStatusIsFresh = Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + const richStatusOwnsCurrentState = richStatusIsFresh && richStatusCanOwnTitleInterval + // Fresh explicit evidence from this title interval owns acknowledgement identity. + const stateStartedAt = richStatusOwnsCurrentState + ? status.stateStartedAt + : (pty.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt) + if (richStatusOwnsCurrentState) { + pty.lastAgentStatusStartedAtEpochMs = stateStartedAt + } + const updatedAt = Math.max(status.updatedAt, titleEvidenceAt) + if (!richStatusOwnsCurrentState) { + return buildTitleOnlyStatus(status.state, updatedAt, stateStartedAt) + } + if (updatedAt === status.updatedAt && stateStartedAt === status.stateStartedAt) { + return status + } + return { ...status, updatedAt, stateStartedAt } + } + /** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */ private buildPtyMobileAgentStatus( pty: RuntimePtyWorktreeRecord | null, @@ -30496,24 +30647,29 @@ export class OrcaRuntimeService { // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) if (liveRow) { - return { - agentStatus: normalizeCompatibleAgentStatusEntryForOwner( - { - ...liveRow.payload, - paneKey, - updatedAt: liveRow.updatedAt, - stateStartedAt: liveRow.stateStartedAt, - stateHistory: [], - ...(terminalHandle ? { terminalHandle } : {}), - ...((pty?.worktreeId ?? liveRow.worktreeId) - ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } - : {}), - tabId: tab.parentTabId, - terminalTitle, - ...providerSession - }, - ownerAgent - ) + const liveStatus = normalizeCompatibleAgentStatusEntryForOwner( + { + ...liveRow.payload, + paneKey, + updatedAt: liveRow.updatedAt, + stateStartedAt: liveRow.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? liveRow.worktreeId) + ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle, + ...providerSession + }, + ownerAgent + ) + // A live question outranks only the shell title that currently obscures it. + const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, { + preserveQuestionUnderShellTitle: true + }) + if (renewedStatus) { + return { agentStatus: renewedStatus } } } // Last resort: the pane's hook evidence is identity only (resume rows, stale @@ -30534,7 +30690,7 @@ export class OrcaRuntimeService { : 'done', prompt: '', updatedAt: evidenceAt, - stateStartedAt: evidenceAt, + stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? evidenceAt, paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx new file mode 100644 index 00000000000..902c3254557 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx @@ -0,0 +1,342 @@ +/** @vitest-environment happy-dom */ +import { act, StrictMode } from 'react' +import type * as ReactModule from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const harness = vi.hoisted(() => ({ + worktreeId: 'repo::/cold-park-pre-gate', + discardMemoCaches: false, + coverageCalls: 0, + syncCalls: 0, + slotRenders: 0, + renderedParkedSets: [] as string[][] +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useMemo: ((calculate, dependencies) => + harness.discardMemoCaches + ? calculate() + : actual.useMemo(calculate, dependencies)) as typeof actual.useMemo + } +}) + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + activeGroupIdByWorktree: {} as Record, + groupsByWorktree: {} as Record, + pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, + runtimeStatusByEnvironmentId: new Map(), + runtimePaneTitlesByTabId: {} as Record>, + settings: {} as Record, + sleepingAgentSessionsByPaneKey: {} as Record, + tabsByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record, + unifiedTabsByWorktree: {} as Record, + consumeSuppressedPtyExit: () => false, + focusGroup: () => {}, + reconcileWorktreeTabModel: () => ({ renderableTabCount: 2 }), + setActiveWorktree: () => {} + })) + return { useAppStore } +}) + +vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({ + useNativeChatToggleShortcut: () => {} +})) + +vi.mock('./TerminalOverlaySlot', () => ({ + TerminalOverlaySlot: () => { + harness.slotRenders += 1 + return null + } +})) + +vi.mock('./terminal-parked-tab-watchers', async () => { + const { useAppStore } = await import('../../store') + return { + canWatcherCoverParkedTerminalTab: () => { + harness.coverageCalls += 1 + return harness.coverageCalls % 2 === 1 + }, + disposeParkedTerminalWatchersForWorktree: () => {}, + syncParkedTerminalTabWatchers: (args: { + worktreeId: string + parkedTabIds: ReadonlySet + }) => { + harness.syncCalls += 1 + harness.renderedParkedSets.push([...args.parkedTabIds].sort()) + if (args.parkedTabIds.size === 0) { + return + } + ;( + useAppStore as unknown as { + setState: (update: (state: ParkingStoreState) => Partial) => void + } + ).setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [args.worktreeId]: state.tabsByWorktree[args.worktreeId].map((tab) => ({ + ...tab, + title: `${tab.id}-${harness.syncCalls}` + })) + }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [args.worktreeId]: state.unifiedTabsByWorktree[args.worktreeId].map((tab) => ({ + ...tab, + label: `${tab.entityId}-${harness.syncCalls}` + })) + } + })) + } + } +}) + +vi.mock('./terminal-parking-e2e-overrides', () => ({ + getTerminalParkingPolicyOverrides: () => ({ coldParkDelayMs: 0, hotRetainMs: 0 }) +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +import { useAppStore } from '../../store' +import TerminalPaneOverlayLayer from './TerminalPaneOverlayLayer' + +const TAB_IDS = ['tab-a', 'tab-b'] as const +const GROUP_ID = 'group-a' + +type ParkingStoreState = { + groupsByWorktree: Record + tabsByWorktree: Record + unifiedTabsByWorktree: Record +} + +const parkingStore = useAppStore as unknown as { + getState: () => ParkingStoreState + setState: (partial: Record) => void +} + +function terminalTab(id: string): TerminalTab { + return { + id, + worktreeId: harness.worktreeId, + ptyId: `${harness.worktreeId}@@session-${id}`, + title: id, + generation: 0 + } as TerminalTab +} + +function unifiedTerminalTab(id: string): Tab { + return { + id: `unified-${id}`, + entityId: id, + worktreeId: harness.worktreeId, + groupId: GROUP_ID, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function renderProductionLayer(root: Root, coldParkTerminalPanes = true): unknown { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let thrown: unknown = null + try { + act(() => { + root.render( + + + + ) + }) + } catch (error) { + thrown = error + } + consoleError.mockRestore() + return thrown +} + +describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + harness.discardMemoCaches = false + harness.coverageCalls = 0 + harness.syncCalls = 0 + harness.slotRenders = 0 + harness.renderedParkedSets.length = 0 + const tabs = TAB_IDS.map(terminalTab) + const unifiedTabs = TAB_IDS.map(unifiedTerminalTab) + parkingStore.setState({ + activeGroupIdByWorktree: { [harness.worktreeId]: GROUP_ID }, + groupsByWorktree: { + [harness.worktreeId]: [ + { + id: GROUP_ID, + worktreeId: harness.worktreeId, + activeTabId: unifiedTabs[0].id, + tabOrder: unifiedTabs.map((tab) => tab.id), + recentTabIds: [unifiedTabs[0].id] + } + ] + }, + tabsByWorktree: { [harness.worktreeId]: tabs }, + unifiedTabsByWorktree: { [harness.worktreeId]: unifiedTabs } + }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + try { + act(() => root?.unmount()) + } catch { + // A failed initial commit leaves no mounted tree to clean up. + } + root = undefined + container.remove() + }) + + it('settles title publications without suppressing PTY rechecks', () => { + root = createRoot(container) + const thrown = renderProductionLayer(root) + + expect(thrown).toBeNull() + expect(harness.coverageCalls).toBe(2) + expect(harness.syncCalls).toBeLessThan(10) + expect(harness.renderedParkedSets).not.toHaveLength(0) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + expect(harness.slotRenders).toBe(0) + + const settledSyncCalls = harness.syncCalls + const state = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...state.tabsByWorktree, + [harness.worktreeId]: state.tabsByWorktree[harness.worktreeId].map((tab) => + tab.id === TAB_IDS[1] ? { ...tab, ptyId: `${tab.ptyId}-replacement` } : tab + ) + } + }) + }) + + expect(harness.coverageCalls).toBe(3) + expect(harness.syncCalls).toBeGreaterThan(settledSyncCalls) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + expect(harness.slotRenders).toBe(0) + + const beforePendingSpawnSyncCalls = harness.syncCalls + const pendingSpawnState = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...pendingSpawnState.tabsByWorktree, + [harness.worktreeId]: pendingSpawnState.tabsByWorktree[harness.worktreeId].map((tab) => + tab.id === TAB_IDS[1] ? { ...tab, pendingActivationSpawn: 1 } : tab + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforePendingSpawnSyncCalls) + + const beforeTabOrderSyncCalls = harness.syncCalls + const tabOrderState = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...tabOrderState.tabsByWorktree, + [harness.worktreeId]: tabOrderState.tabsByWorktree[harness.worktreeId].toReversed() + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeTabOrderSyncCalls) + + const beforeActiveTabChangeSyncCalls = harness.syncCalls + const activeTabState = parkingStore.getState() + act(() => { + parkingStore.setState({ + groupsByWorktree: { + ...activeTabState.groupsByWorktree, + [harness.worktreeId]: activeTabState.groupsByWorktree[harness.worktreeId].map( + (group) => ({ + ...group, + activeTabId: `unified-${TAB_IDS[1]}` + }) + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeActiveTabChangeSyncCalls) + + const beforeGroupChangeSyncCalls = harness.syncCalls + const groupState = parkingStore.getState() + const nextGroupId = 'group-b' + act(() => { + parkingStore.setState({ + groupsByWorktree: { + ...groupState.groupsByWorktree, + [harness.worktreeId]: [ + ...groupState.groupsByWorktree[harness.worktreeId], + { + id: nextGroupId, + worktreeId: harness.worktreeId, + activeTabId: `unified-${TAB_IDS[1]}`, + tabOrder: [`unified-${TAB_IDS[1]}`], + recentTabIds: [`unified-${TAB_IDS[1]}`] + } + ] + }, + unifiedTabsByWorktree: { + ...groupState.unifiedTabsByWorktree, + [harness.worktreeId]: groupState.unifiedTabsByWorktree[harness.worktreeId].map((tab) => + tab.entityId === TAB_IDS[1] ? { ...tab, groupId: nextGroupId } : tab + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeGroupChangeSyncCalls) + + expect(renderProductionLayer(root, false)).toBeNull() + expect(harness.slotRenders).toBeGreaterThan(0) + + expect(renderProductionLayer(root)).toBeNull() + expect(harness.renderedParkedSets.at(-1)).toEqual([...TAB_IDS]) + }) + + it('settles equivalent publications after memo cache discard', () => { + harness.discardMemoCaches = true + root = createRoot(container) + + const thrown = renderProductionLayer(root) + + expect(thrown).toBeNull() + expect(harness.syncCalls).toBeLessThan(10) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx index 97cb2db3428..9a97a10745b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx @@ -31,6 +31,7 @@ vi.mock('../../store', async () => { const { create } = await import('zustand') const useAppStore = create(() => ({ pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, runtimeStatusByEnvironmentId: new Map(), settings: {} as Record, terminalLayoutsByTabId: {} as Record, diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx new file mode 100644 index 00000000000..f3ea986601f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx @@ -0,0 +1,251 @@ +/** @vitest-environment happy-dom */ +import { act, StrictMode, useEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const WORKTREE_ID = 'repo::/parked-watcher-sync' +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const FIRST_PTY_ID = `${WORKTREE_ID}@@session-1` +const OLD_SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const NEW_SECOND_PTY_ID = `${WORKTREE_ID}@@session-3` + +const harness = vi.hoisted(() => ({ + syncCalls: 0, + disposeCalls: 0, + reconciliationPtyReads: 0, + watchedPtyIds: new Set() +})) + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + ptyIdsByTabId: {} as Record, + runtimePaneTitlesByTabId: {} as Record>, + terminalLayoutsByTabId: {} as Record + })) + return { useAppStore } +}) + +vi.mock('./terminal-parked-tab-watchers', async () => { + const { useAppStore } = await import('../../store') + return { + disposeParkedTerminalWatchersForWorktree: () => { + harness.disposeCalls += 1 + harness.watchedPtyIds.clear() + }, + syncParkedTerminalTabWatchers: (args: { + tabs: readonly TerminalTab[] + parkedTabIds: ReadonlySet + }) => { + harness.syncCalls += 1 + const state = useAppStore.getState() + harness.watchedPtyIds.clear() + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const layoutPtyIds = Object.values( + state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + ) + for (const ptyId of layoutPtyIds.length > 0 ? layoutPtyIds : [tab.ptyId]) { + if (ptyId) { + harness.watchedPtyIds.add(ptyId) + } + } + } + } + } +}) + +import { useAppStore } from '../../store' +import { disposeParkedTerminalWatchersForWorktree } from './terminal-parked-tab-watchers' +import { + captureParkedTerminalPaneCandidates, + capturedPanesByTabId +} from './terminal-parked-watcher-registry' +import { useParkedTerminalWatcherSynchronization } from './use-parked-terminal-watcher-synchronization' + +const terminalTabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID }] as TerminalTab[] +const parkedTabIds = new Set([TAB_ID]) +const EMPTY_PARKED_TAB_IDS = new Set() +const firstUnparkedTabs = ['tab-a', 'tab-b', 'tab-c'].map( + (id) => ({ id, ptyId: `${WORKTREE_ID}@@${id}` }) as TerminalTab +) +const secondUnparkedTabs = ['tab-d', 'tab-e', 'tab-f'].map( + (id) => ({ id, ptyId: `${WORKTREE_ID}@@${id}` }) as TerminalTab +) + +function splitLayout(secondPtyId: string): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: FIRST_LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [FIRST_LEAF_ID]: FIRST_PTY_ID, + [SECOND_LEAF_ID]: secondPtyId + } + } +} + +function WatcherSynchronizationHarness(): null { + useParkedTerminalWatcherSynchronization({ + worktreeId: WORKTREE_ID, + terminalTabs, + inputsKey: JSON.stringify([[TAB_ID, FIRST_PTY_ID, null]]), + assignmentsKey: JSON.stringify([[TAB_ID, 'group-1', false]]), + parkedTabIds + }) + // Models the pre-fix sibling disposal effect that StrictMode replays after synchronization. + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(WORKTREE_ID), []) + return null +} + +function UnparkedWatcherSynchronizationHarness({ + worktreeId, + tabs +}: { + worktreeId: string + tabs: readonly TerminalTab[] +}): null { + useParkedTerminalWatcherSynchronization({ + worktreeId, + terminalTabs: tabs, + inputsKey: worktreeId, + assignmentsKey: worktreeId, + parkedTabIds: EMPTY_PARKED_TAB_IDS + }) + return null +} + +describe('parked terminal watcher synchronization', () => { + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + harness.syncCalls = 0 + harness.disposeCalls = 0 + harness.reconciliationPtyReads = 0 + harness.watchedPtyIds.clear() + useAppStore.setState({ + ptyIdsByTabId: { [TAB_ID]: [FIRST_PTY_ID, OLD_SECOND_PTY_ID] }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'active', 2: 'split' } }, + terminalLayoutsByTabId: { [TAB_ID]: splitLayout(OLD_SECOND_PTY_ID) } + }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + act(() => root?.unmount()) + root = undefined + capturedPanesByTabId.delete(TAB_ID) + container.remove() + }) + + it('reinstates parked watchers after StrictMode effect replay cleanup', () => { + root = createRoot(container) + + act(() => { + root?.render( + + + + ) + }) + + expect(harness.disposeCalls).toBeGreaterThanOrEqual(1) + expect(harness.syncCalls).toBe(2) + expect(harness.watchedPtyIds).toEqual(new Set([FIRST_PTY_ID, OLD_SECOND_PTY_ID])) + }) + + it('resynchronizes a parked split when only its detached PTY and layout change', () => { + root = createRoot(container) + act(() => { + root?.render() + }) + expect(harness.watchedPtyIds).toContain(OLD_SECOND_PTY_ID) + + act(() => { + useAppStore.setState({ + ptyIdsByTabId: { [TAB_ID]: [FIRST_PTY_ID, NEW_SECOND_PTY_ID] }, + terminalLayoutsByTabId: { [TAB_ID]: splitLayout(NEW_SECOND_PTY_ID) } + }) + }) + + expect(harness.syncCalls).toBe(2) + expect(harness.watchedPtyIds).toEqual(new Set([FIRST_PTY_ID, NEW_SECOND_PTY_ID])) + + act(() => { + useAppStore.setState({ + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ active', 2: 'new split title' } } + }) + }) + expect(harness.syncCalls).toBe(2) + + act(() => { + useAppStore.setState({ + terminalLayoutsByTabId: { + [TAB_ID]: { ...splitLayout(NEW_SECOND_PTY_ID), activeLeafId: SECOND_LEAF_ID } + } + }) + }) + expect(harness.syncCalls).toBe(3) + + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [ + { ptyId: FIRST_PTY_ID, paneId: 10, leafId: FIRST_LEAF_ID, drivesTabTitle: false }, + { ptyId: NEW_SECOND_PTY_ID, paneId: 11, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + act(() => { + root?.render() + }) + expect(harness.syncCalls).toBe(4) + }) + + it('does not scan unparked worktrees on an unrelated store write', () => { + const capturedPaneGet = vi.spyOn(capturedPanesByTabId, 'get') + const ptyIdsByTabId = new Proxy({} as Record, { + get(target, property, receiver) { + if (typeof property === 'string') { + harness.reconciliationPtyReads += 1 + } + return Reflect.get(target, property, receiver) + } + }) + useAppStore.setState({ ptyIdsByTabId }) + root = createRoot(container) + act(() => { + root?.render( + <> + + + + ) + }) + harness.reconciliationPtyReads = 0 + const syncCallsAfterMount = harness.syncCalls + + act(() => { + useAppStore.setState({ sortEpoch: 1 }) + }) + + expect(harness.reconciliationPtyReads).toBe(0) + expect(capturedPaneGet).not.toHaveBeenCalled() + expect(harness.syncCalls).toBe(syncCallsAfterMount) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts new file mode 100644 index 00000000000..1ee7ba8c2d0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts @@ -0,0 +1,167 @@ +import { useEffect, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + disposeParkedTerminalWatchersForWorktree, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' +import { capturedPanesByTabId } from './terminal-parked-watcher-registry' + +type TerminalParkingAssignment = { + groupId: string + isActiveInGroup: boolean +} + +const EMPTY_PTY_IDS: readonly string[] = [] + +type WatcherReconciliationStoreInputs = readonly ( + | readonly string[] + | TerminalLayoutSnapshot + | string + | null +)[] + +const EMPTY_WATCHER_RECONCILIATION_INPUTS: WatcherReconciliationStoreInputs = Object.freeze([]) + +export function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string { + return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn])) +} + +export function getTerminalParkingAssignmentsKey( + assignments: ReadonlyMap +): string { + return JSON.stringify( + Array.from(assignments, ([tabId, assignment]) => [ + tabId, + assignment.groupId, + assignment.isActiveInGroup + ]) + ) +} + +function getWatcherSynchronizationKey(args: { + worktreeId: string + inputsKey: string + assignmentsKey: string + parkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null + reconciliationKey: string +}): string { + return JSON.stringify([ + args.worktreeId, + args.inputsKey, + args.assignmentsKey, + Array.from(args.parkedTabIds), + Array.from(args.activationDeferredMountTabIds ?? []).sort(), + args.reconciliationKey + ]) +} + +function getCapturedPaneKey(terminalTabs: readonly TerminalTab[]): unknown[] { + return terminalTabs.map((tab) => { + const capture = capturedPanesByTabId.get(tab.id) + return [ + tab.id, + capture?.worktreeId ?? null, + capture?.panes.map((pane) => [pane.ptyId, pane.paneId, pane.leafId, pane.drivesTabTitle]) ?? + [] + ] + }) +} + +function getWatcherReconciliationKey( + terminalTabs: readonly TerminalTab[], + inputs: WatcherReconciliationStoreInputs +): string { + if (inputs.length === 0) { + return '' + } + const tabs = terminalTabs.map((tab, index) => { + const offset = index * 3 + const ptyIds = inputs[offset] as readonly string[] + const layout = inputs[offset + 1] as TerminalLayoutSnapshot | null + const titleSlotKey = inputs[offset + 2] as string + return [ + tab.id, + ptyIds, + layout?.root ?? null, + layout?.activeLeafId ?? null, + layout?.ptyIdsByLeafId ?? null, + titleSlotKey + ] + }) + return JSON.stringify([tabs, getCapturedPaneKey(terminalTabs)]) +} + +export function useParkedTerminalWatcherSynchronization(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + inputsKey: string + assignmentsKey: string + parkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null +}): void { + const { + worktreeId, + terminalTabs, + inputsKey, + assignmentsKey, + parkedTabIds, + activationDeferredMountTabIds + } = args + const reconciliationStoreInputs = useAppStore( + useShallow((state) => + // Why: an empty committed park set has no live watcher state for store writes to reconcile. + parkedTabIds.size === 0 + ? EMPTY_WATCHER_RECONCILIATION_INPUTS + : terminalTabs.flatMap((tab) => [ + state.ptyIdsByTabId[tab.id] ?? EMPTY_PTY_IDS, + state.terminalLayoutsByTabId[tab.id] ?? null, + Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}).join(',') + ]) + ) + ) as WatcherReconciliationStoreInputs + const reconciliationKey = getWatcherReconciliationKey(terminalTabs, reconciliationStoreInputs) + const synchronizationKey = getWatcherSynchronizationKey({ ...args, reconciliationKey }) + const synchronizationKeyRef = useRef(null) + + useEffect( + () => () => { + synchronizationKeyRef.current = null + disposeParkedTerminalWatchersForWorktree(worktreeId) + }, + [worktreeId] + ) + + useEffect(() => { + if (synchronizationKeyRef.current === synchronizationKey) { + return + } + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds, + // Why: activation-deferred tabs have no prior pane-owned title slot. + restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined + }) + // Why: capture cleanup mutates the registry during commit; store its post-commit key. + synchronizationKeyRef.current = getWatcherSynchronizationKey({ + worktreeId, + inputsKey, + assignmentsKey, + parkedTabIds, + activationDeferredMountTabIds, + reconciliationKey: getWatcherReconciliationKey(terminalTabs, reconciliationStoreInputs) + }) + }, [ + activationDeferredMountTabIds, + assignmentsKey, + inputsKey, + parkedTabIds, + reconciliationStoreInputs, + synchronizationKey, + terminalTabs, + worktreeId + ]) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 888f82237ee..2e1c97c5450 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -6,7 +6,9 @@ import type { TerminalTab } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ storeState: { pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, runtimeStatusByEnvironmentId: new Map(), + runtimePaneTitlesByTabId: {} as Record>, settings: {} as Record, terminalLayoutsByTabId: {} as Record }>, sleepingAgentSessionsByPaneKey: {} as Record< diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 6a7a0e1a498..f33023c9499 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -31,11 +31,12 @@ import { selectEvictionExemptTerminalTabLayoutKey } from './terminal-eviction-exempt-tabs' import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption' +import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' import { - canWatcherCoverParkedTerminalTab, - disposeParkedTerminalWatchersForWorktree, - syncParkedTerminalTabWatchers -} from './terminal-parked-tab-watchers' + getTerminalParkingAssignmentsKey, + getTerminalParkingInputsKey, + useParkedTerminalWatcherSynchronization +} from './use-parked-terminal-watcher-synchronization' type TerminalOverlayTabAssignment = { groupId: string @@ -86,6 +87,14 @@ export function useTerminalTabColdParking(args: { activityTerminalPortals, activationDeferredMountTabIds } = args + const terminalParkingInputsKey = getTerminalParkingInputsKey(terminalTabs) + const terminalParkingAssignmentsKey = getTerminalParkingAssignmentsKey(assignments) + const terminalParkingTabsDependency = coldParkTerminalPanes + ? terminalParkingInputsKey + : terminalTabs + const terminalParkingAssignmentsDependency = coldParkTerminalPanes + ? terminalParkingAssignmentsKey + : assignments const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false @@ -247,17 +256,18 @@ export function useTerminalTabColdParking(args: { timers.set(tabId, timer) } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- semantic keys own the tab and assignment dependencies. }, [ activityTerminalPortals, - assignments, isWorktreeActive, pendingStartupByTabId, - pairedRuntimeParkingEnvironmentIds, + runtimeStatusByEnvironmentId, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, terminalTabParkingRevision, - terminalTabs, + terminalParkingAssignmentsDependency, + terminalParkingTabsDependency, worktreeId ]) @@ -357,18 +367,14 @@ export function useTerminalTabColdParking(args: { // panes — watcher disposal therefore lands before any PTY data IPC can // reach a freshly remounted pane, and watcher start lands after the parked // pane's unmount capture. - useEffect(() => { - syncParkedTerminalTabWatchers({ - worktreeId, - tabs: terminalTabs, - parkedTabIds: parkedTerminalTabIds, - // Why: activation-deferred tabs have no prior pane-owned title slot; - // pull main's title-only snapshot when their watcher starts. - restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined - }) - }, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId]) - - useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + useParkedTerminalWatcherSynchronization({ + worktreeId, + terminalTabs, + assignmentsKey: terminalParkingAssignmentsKey, + inputsKey: terminalParkingInputsKey, + parkedTabIds: parkedTerminalTabIds, + activationDeferredMountTabIds + }) return parkedTerminalTabIds } diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index c49917cb24f..f7632869d3e 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -4,6 +4,9 @@ import path from 'node:path' const FILL_ROWS = 6_000 const FLOOD_ROWS = 4_000 +const TITLE_FRAME_INTERVAL_MS = 70 +const TITLE_FRAME_COUNT = 30 +const TITLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'` @@ -31,6 +34,29 @@ export function createPairedTerminalParkingFixture(): { "process.stdin.setEncoding('utf8')", "process.stdin.on('data', (data) => {", ' for (const command of data.split(/\\r\\n|\\r|\\n/).filter(Boolean)) {', + " if (command.startsWith('TITLE_START:')) {", + " const token = command.slice('TITLE_START:'.length)", + " process.stdout.write('\\u001b]0;⠋ Cursor Agent\\u0007')", + ' process.stdout.write(`TITLE_STARTED:${token}\\r\\n`)', + ' continue', + ' }', + " if (command.startsWith('TITLE_ANIMATE:')) {", + " const token = command.slice('TITLE_ANIMATE:'.length)", + ` const frames = ${JSON.stringify(TITLE_FRAMES)}`, + ' let frameIndex = 0', + ' const emitFrame = () => {', + ` if (frameIndex < ${TITLE_FRAME_COUNT}) {`, + ' process.stdout.write(`\\u001b]0;${frames[frameIndex % frames.length]} Cursor Agent\\u0007`)', + ' frameIndex += 1', + ` setTimeout(emitFrame, ${TITLE_FRAME_INTERVAL_MS})`, + ' return', + ' }', + " process.stdout.write('\\u001b]0;Cursor ready\\u0007')", + ' process.stdout.write(`TITLE_DONE:${token}\\r\\n`)', + ' }', + ` setTimeout(emitFrame, ${TITLE_FRAME_INTERVAL_MS})`, + ' continue', + ' }', " if (command.startsWith('FLOOD:')) {", " const token = command.slice('FLOOD:'.length)", ` for (let row = 0; row < ${FLOOD_ROWS}; row += 1) process.stdout.write(\`flood-${'${token}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`, diff --git a/tests/e2e/helpers/paired-terminal-parking-oracle.ts b/tests/e2e/helpers/paired-terminal-parking-oracle.ts index 070c73cceb1..c1a385b2162 100644 --- a/tests/e2e/helpers/paired-terminal-parking-oracle.ts +++ b/tests/e2e/helpers/paired-terminal-parking-oracle.ts @@ -12,6 +12,7 @@ import { import { expect } from './orca-app' import { verifyHiddenPairedTerminalOutputSuppression } from './paired-terminal-hidden-output-oracle' import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture' +import { verifyPairedTerminalTitleFanout } from './paired-terminal-title-fanout-oracle' import { getTerminalContent, waitForActivePanePtyId } from './terminal' const TARGET_WORKTREE_COUNT = 6 @@ -135,6 +136,7 @@ export async function runPairedTerminalParkingOracle( await expectHostTerminalsUnmounted(options.hostPage, seed.fallbackWorktreeId, remoteTabs) const hiddenFloodTokens = await verifyHiddenPairedTerminalOutputSuppression(page, remoteTabs) + await verifyPairedTerminalTitleFanout(page, remoteTabs) const baseline = await readPairedRetentionSample( page, remoteTabs.map((tab) => tab.tabId) diff --git a/tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts b/tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts new file mode 100644 index 00000000000..24b686997b7 --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts @@ -0,0 +1,310 @@ +import type { Page } from '@stablyai/playwright-test' +import type { RuntimeTerminalRead } from '../../../src/shared/runtime-types' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { expect } from './orca-app' + +const INITIAL_TITLE = '⠋ Cursor Agent' +const FINAL_TITLE = 'Cursor ready' + +type TitleTarget = { + tabId: string + terminal: string + worktreeId: string +} + +type TitleTransportEvent = { + decodedEnvelopeBytes: number + snapshotVersion: number + title: string + worktreeId: string +} + +type TitleStoreChange = { + title: string + worktreeId: string +} + +type TitleProbeSample = { + storeChanges: TitleStoreChange[] + transportEvents: TitleTransportEvent[] +} + +type BrowserTitleProbe = TitleProbeSample & { + lastTitles: Record + responseCount: number + subscription: null | { unsubscribe: () => void } + unsubscribeStore: null | (() => void) +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export async function verifyPairedTerminalTitleFanout( + page: Page, + targets: TitleTarget[] +): Promise { + const tokens = targets.map((_, index) => `TITLE_FANOUT_${index}_${Date.now()}`) + await installTitleProbe(page, targets) + try { + await expect.poll(() => readProbeResponseCount(page), { timeout: 30_000 }).toBeGreaterThan(0) + await resetTitleProbe(page, targets) + + await Promise.all( + targets.map((target, index) => + sendFixtureCommand(page, target, `TITLE_START:${tokens[index]}`) + ) + ) + await expectTitles(page, targets, INITIAL_TITLE) + await expect + .poll(async () => (await readTitleProbe(page)).transportEvents.length, { timeout: 30_000 }) + .toBeGreaterThanOrEqual(targets.length) + + await Promise.all( + targets.map((target, index) => + sendFixtureCommand(page, target, `TITLE_ANIMATE:${tokens[index]}`) + ) + ) + await expectFixtureCompletion(page, targets, tokens) + await expectTitles(page, targets, FINAL_TITLE) + await expect + .poll(async () => (await readTitleProbe(page)).transportEvents.length, { timeout: 30_000 }) + .toBeGreaterThanOrEqual(targets.length * 2) + + const sample = await readTitleProbe(page) + expectTitleFanoutSample(sample, targets) + } finally { + await disposeTitleProbe(page) + } +} + +async function installTitleProbe(page: Page, targets: TitleTarget[]): Promise { + await page.evaluate( + async ({ targets, capability }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store is unavailable') + } + const environmentId = Array.from( + store.getState().runtimeStatusByEnvironmentId.entries() + ).find(([, entry]) => entry.status?.capabilities?.includes(capability))?.[0] + if (!environmentId) { + throw new Error('Paired runtime environment is unavailable') + } + const titleFor = ( + state: ReturnType, + target: (typeof targets)[number] + ) => + state.tabsByWorktree[target.worktreeId]?.find((tab) => tab.id === target.tabId)?.title ?? + null + const probe = { + responseCount: 0, + storeChanges: [] as TitleStoreChange[], + transportEvents: [] as TitleTransportEvent[], + lastTitles: Object.fromEntries( + targets.map((target) => [target.worktreeId, titleFor(store.getState(), target)]) + ), + subscription: null as null | { unsubscribe: () => void }, + unsubscribeStore: null as null | (() => void) + } + probe.unsubscribeStore = store.subscribe((state) => { + for (const target of targets) { + const title = titleFor(state, target) + if (title === null || title === probe.lastTitles[target.worktreeId]) { + continue + } + probe.lastTitles[target.worktreeId] = title + probe.storeChanges.push({ worktreeId: target.worktreeId, title }) + } + }) + probe.subscription = await window.api.runtimeEnvironments.subscribe( + { selector: environmentId, method: 'session.tabs.subscribeAll', params: {} }, + { + onResponse: (response) => { + probe.responseCount += 1 + if (!response.ok) { + return + } + const result = response.result as { + type?: string + worktree?: string + snapshotVersion?: number + tabs?: { type?: string; title?: string }[] + snapshots?: { + worktree?: string + snapshotVersion?: number + tabs?: { type?: string; title?: string }[] + }[] + } + const snapshots = result.type === 'snapshots' ? (result.snapshots ?? []) : [result] + const decodedEnvelopeBytes = new TextEncoder().encode( + JSON.stringify(response) + ).byteLength + for (const snapshot of snapshots) { + if (!targets.some((target) => target.worktreeId === snapshot.worktree)) { + continue + } + const title = snapshot.tabs?.find((tab) => tab.type === 'terminal')?.title + if ( + typeof snapshot.worktree === 'string' && + typeof snapshot.snapshotVersion === 'number' && + typeof title === 'string' + ) { + probe.transportEvents.push({ + worktreeId: snapshot.worktree, + snapshotVersion: snapshot.snapshotVersion, + title, + decodedEnvelopeBytes + }) + } + } + } + } + ) + Object.assign(globalThis, { __pairedTitleFanoutProbe: probe }) + }, + { targets, capability: TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } + ) +} + +async function resetTitleProbe(page: Page, targets: TitleTarget[]): Promise { + await page.evaluate((targets) => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + throw new Error('Paired title fanout probe is unavailable') + } + probe.transportEvents.length = 0 + probe.storeChanges.length = 0 + const state = window.__store?.getState() + for (const target of targets) { + probe.lastTitles[target.worktreeId] = + state?.tabsByWorktree[target.worktreeId]?.find((tab) => tab.id === target.tabId)?.title ?? + null + } + }, targets) +} + +async function readProbeResponseCount(page: Page): Promise { + return page.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + throw new Error('Paired title fanout probe is unavailable') + } + return probe.responseCount + }) +} + +async function readTitleProbe(page: Page): Promise { + return page.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + throw new Error('Paired title fanout probe is unavailable') + } + return { + storeChanges: [...probe.storeChanges], + transportEvents: [...probe.transportEvents] + } + }) +} + +async function disposeTitleProbe(page: Page): Promise { + await page + .evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + return + } + probe.subscription?.unsubscribe() + probe.unsubscribeStore?.() + delete (globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: unknown }) + .__pairedTitleFanoutProbe + }) + .catch(() => undefined) +} + +async function sendFixtureCommand(page: Page, target: TitleTarget, text: string): Promise { + await callRuntime(page, 'terminal.send', { + terminal: target.terminal, + text, + enter: true, + client: { id: 'paired-title-fanout-e2e', type: 'desktop' } + }) +} + +async function expectFixtureCompletion( + page: Page, + targets: TitleTarget[], + tokens: string[] +): Promise { + await expect + .poll( + async () => + Promise.all( + targets.map(async (target, index) => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + page, + 'terminal.read', + { terminal: target.terminal, limit: 1_000 } + ) + return result.terminal.tail.join('\n').includes(`TITLE_DONE:${tokens[index]}`) + }) + ), + { timeout: 30_000 } + ) + .toEqual(Array(targets.length).fill(true)) +} + +async function expectTitles(page: Page, targets: TitleTarget[], expected: string): Promise { + await expect + .poll( + () => + page.evaluate( + ({ targets, expected }) => + targets.map( + (target) => + window.__store + ?.getState() + .tabsByWorktree[target.worktreeId]?.find((tab) => tab.id === target.tabId) + ?.title === expected + ), + { targets, expected } + ), + { timeout: 30_000 } + ) + .toEqual(Array(targets.length).fill(true)) +} + +function expectTitleFanoutSample(sample: TitleProbeSample, targets: TitleTarget[]): void { + expect(sample.transportEvents).toHaveLength(targets.length * 2) + expect(sample.storeChanges).toHaveLength(targets.length * 2) + for (const target of targets) { + const transport = sample.transportEvents.filter( + (event) => event.worktreeId === target.worktreeId + ) + expect(transport.map((event) => event.title)).toEqual([INITIAL_TITLE, FINAL_TITLE]) + expect(transport[1]?.snapshotVersion).toBeGreaterThan(transport[0]?.snapshotVersion ?? 0) + expect(transport.every((event) => event.decodedEnvelopeBytes > 0)).toBe(true) + expect( + sample.storeChanges + .filter((change) => change.worktreeId === target.worktreeId) + .map((change) => change.title) + ).toEqual([INITIAL_TITLE, FINAL_TITLE]) + } +} diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts new file mode 100644 index 00000000000..48dd86e318d --- /dev/null +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -0,0 +1,772 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../src/shared/runtime-types' +import { detectAgentStatusFromTitle } from '../../src/shared/agent-detection' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../src/shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '../../src/renderer/src/lib/agent-status' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS +} from '../../src/main/runtime/mobile-session-tabs-agent-status-heartbeat' +import { + applyFreshWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from '../../src/renderer/src/runtime/web-session-tabs-sync' + +vi.mock('../../src/renderer/src/store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const ENVIRONMENT_ID = 'paired-runtime' +const WORKTREE_COUNT = 24 +const DECORATIVE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map + ptysById: Map + ptyDelayedForegroundSnapshotTitleObservations: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void +} + +type FanoutCounters = { + hostPublications: number + serializedBytes: number + rendererApplyCalls: number + rendererStoreMutations: number + rawTerminalChunks: number +} + +type FanoutEvidence = { + publishedByWorktree: Map + rawChunksByPty: Map +} + +function makeViewerState(): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: 'workspace-0', + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +function seedWorktree(runtime: OrcaRuntimeService, index: number): string { + const worktreeId = `workspace-${index}` + const ptyId = `pty-${index}` + const tabId = `host-tab-${index}` + const leafId = `00000000-0000-4000-8000-${index.toString().padStart(12, '0')}` + runtime.registerPty(ptyId, worktreeId) + ;(runtime as unknown as RuntimeInternals).mobileSessionTabsByWorktree.set(worktreeId, { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: `group-${index}`, + activeTabId: `${tabId}::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${tabId}::${leafId}`, + parentTabId: tabId, + leafId, + ptyId, + title: 'Cursor Agent', + parentLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + }, + isActive: true + } + ] + }) + return ptyId +} + +function resetCounters(counters: FanoutCounters): void { + counters.hostPublications = 0 + counters.serializedBytes = 0 + counters.rendererApplyCalls = 0 + counters.rendererStoreMutations = 0 + counters.rawTerminalChunks = 0 +} + +function resetEvidence(evidence: FanoutEvidence): void { + evidence.publishedByWorktree.clear() + evidence.rawChunksByPty.clear() +} + +function recordByKey(map: Map, key: string, value: T): void { + const entries = map.get(key) ?? [] + entries.push(value) + map.set(key, entries) +} + +describe('real PTY decorative session-tabs fanout', () => { + beforeEach(() => { + vi.useFakeTimers() + resetWebSessionTabsSnapshotFreshnessForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('bounds host publication and renderer application across remote worktrees', () => { + const runtime = new OrcaRuntimeService() + const ptyIds = Array.from({ length: WORKTREE_COUNT }, (_, index) => + seedWorktree(runtime, index) + ) + const counters: FanoutCounters = { + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: 0 + } + const evidence: FanoutEvidence = { + publishedByWorktree: new Map(), + rawChunksByPty: new Map() + } + let viewerState = makeViewerState() + const dataUnsubscribes = ptyIds.map((ptyId) => + runtime.subscribeToTerminalData(ptyId, (data) => { + counters.rawTerminalChunks += 1 + recordByKey(evidence.rawChunksByPty, ptyId, data) + }) + ) + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + counters.hostPublications += 1 + counters.serializedBytes += Buffer.byteLength(JSON.stringify(snapshot)) + recordByKey(evidence.publishedByWorktree, snapshot.worktree, structuredClone(snapshot)) + counters.rendererApplyCalls += 1 + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + if (patch !== viewerState) { + counters.rendererStoreMutations += 1 + viewerState = { ...viewerState, ...patch } + } + }) + + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(counters.hostPublications).toBe(WORKTREE_COUNT) + expect(counters.rendererStoreMutations).toBe(WORKTREE_COUNT) + resetCounters(counters) + resetEvidence(evidence) + + for (let cycle = 0; cycle < 4; cycle += 1) { + for (const frame of DECORATIVE_FRAMES) { + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, `\x1b]0;${frame} Cursor Agent\x07`, Date.now()) + } + vi.advanceTimersByTime(30) + } + } + vi.advanceTimersByTime(50) + + expect(counters).toEqual({ + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: WORKTREE_COUNT * DECORATIVE_FRAMES.length * 4 + }) + for (const ptyId of ptyIds) { + expect(evidence.rawChunksByPty.get(ptyId)).toEqual( + Array.from({ length: 4 }, () => + DECORATIVE_FRAMES.map((frame) => `\x1b]0;${frame} Cursor Agent\x07`) + ).flat() + ) + } + expect(evidence.publishedByWorktree.size).toBe(0) + + resetCounters(counters) + resetEvidence(evidence) + for (let cycle = 0; cycle < 4; cycle += 1) { + for (const frame of DECORATIVE_FRAMES) { + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', Date.now()) + runtime.onPtyData(ptyId, `\x1b]0;${frame} Cursor Agent\x07`, Date.now()) + } + vi.advanceTimersByTime(30) + } + } + vi.advanceTimersByTime(50) + expect(counters).toEqual({ + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: WORKTREE_COUNT * DECORATIVE_FRAMES.length * 8 + }) + for (const ptyId of ptyIds) { + expect(evidence.rawChunksByPty.get(ptyId)).toEqual( + Array.from({ length: 4 }, () => + DECORATIVE_FRAMES.flatMap((frame) => [ + '\x1b]0;Cursor Agent\x07', + `\x1b]0;${frame} Cursor Agent\x07` + ]) + ).flat() + ) + } + expect(evidence.publishedByWorktree.size).toBe(0) + + resetCounters(counters) + resetEvidence(evidence) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;Cursor ready\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(counters.hostPublications).toBe(WORKTREE_COUNT) + expect(counters.rendererApplyCalls).toBe(WORKTREE_COUNT) + expect(counters.rendererStoreMutations).toBe(WORKTREE_COUNT) + expect(counters.serializedBytes).toBeGreaterThan(0) + for (let index = 0; index < WORKTREE_COUNT; index += 1) { + const worktreeId = `workspace-${index}` + const snapshots = evidence.publishedByWorktree.get(worktreeId) + expect(snapshots).toHaveLength(1) + const terminal = snapshots?.[0]?.tabs[0] + expect(terminal).toMatchObject({ + type: 'terminal', + parentTabId: `host-tab-${index}`, + leafId: `00000000-0000-4000-8000-${index.toString().padStart(12, '0')}`, + ptyId: `pty-${index}`, + title: 'Cursor ready' + }) + expect(terminal?.type === 'terminal' ? terminal.agentStatus?.state : undefined).toBe('done') + expect(viewerState.tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ title: 'Cursor ready', worktreeId }) + ]) + expect( + detectAgentStatusFromTitle(viewerState.tabsByWorktree[worktreeId]?.[0]?.title ?? '') + ).toBe('idle') + expect(evidence.rawChunksByPty.get(`pty-${index}`)).toEqual(['\x1b]0;Cursor ready\x07']) + } + + resetCounters(counters) + resetEvidence(evidence) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, 'visible output\r\n', Date.now()) + } + vi.advanceTimersByTime(50) + expect(counters).toEqual({ + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: WORKTREE_COUNT + }) + for (const ptyId of ptyIds) { + expect(evidence.rawChunksByPty.get(ptyId)).toEqual(['visible output\r\n']) + } + expect(evidence.publishedByWorktree.size).toBe(0) + + unsubscribe() + for (const dispose of dataUnsubscribes) { + dispose() + } + }) + + it.each([ + ['build ⠁', 'build ⠂'], + ['Codex working task ⠁', 'Codex working task ⠂'] + ])('publishes meaningful real-title changes from %j to %j', (firstTitle, secondTitle) => { + const runtime = new OrcaRuntimeService() + seedWorktree(runtime, 0) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => published.push(snapshot)) + + runtime.onPtyData('pty-0', `\x1b]0;${firstTitle}\x07`, Date.now()) + vi.advanceTimersByTime(50) + published.length = 0 + runtime.onPtyData('pty-0', `\x1b]0;${secondTitle}\x07`, Date.now()) + vi.advanceTimersByTime(50) + + expect(published).toHaveLength(1) + expect(published[0]?.tabs[0]).toMatchObject({ title: secondTitle, ptyId: 'pty-0' }) + unsubscribe() + }) + + it('globally caps decorative freshness while every working status stays fresh', () => { + const runtime = new OrcaRuntimeService() + const firstWorkingTitleAt = Date.now() + const ptyIds = Array.from({ length: WORKTREE_COUNT }, (_, index) => + seedWorktree(runtime, index) + ) + const publications: { + worktreeId: string + at: number + updatedAt: number + stateStartedAt: number + }[] = [] + let viewerState = makeViewerState() + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + const terminal = snapshot.tabs[0] + if (terminal?.type !== 'terminal') { + return + } + publications.push({ + worktreeId: snapshot.worktree, + at: Date.now(), + updatedAt: terminal.agentStatus?.updatedAt ?? 0, + stateStartedAt: terminal.agentStatus?.stateStartedAt ?? 0 + }) + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + viewerState = { ...viewerState, ...patch } + }) + + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(50) + const initialStateStartedAtByWorktree = new Map( + publications.map(({ worktreeId, stateStartedAt }) => [worktreeId, stateStartedAt]) + ) + publications.length = 0 + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - 51) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠙ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(publications).toEqual([]) + + vi.advanceTimersByTime(1) + const heartbeatStartedAt = Date.now() + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠹ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * WORKTREE_COUNT + 50) + + expect(publications.map(({ worktreeId }) => worktreeId).sort()).toEqual( + Array.from({ length: WORKTREE_COUNT }, (_, index) => `workspace-${index}`).sort() + ) + for (let index = 1; index < publications.length; index += 1) { + expect(publications[index]!.at - publications[index - 1]!.at).toBeGreaterThanOrEqual( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + } + for (const publication of publications) { + expect(publication.updatedAt).toBeGreaterThanOrEqual(heartbeatStartedAt) + expect(Date.now() - publication.updatedAt).toBeLessThan(AGENT_STATUS_STALE_AFTER_MS) + expect(publication.stateStartedAt).toBe( + initialStateStartedAtByWorktree.get(publication.worktreeId) + ) + } + publications.length = 0 + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠸ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * WORKTREE_COUNT + 50) + expect(publications).toHaveLength(WORKTREE_COUNT) + expect(Date.now() - firstWorkingTitleAt).toBeGreaterThan(AGENT_STATUS_STALE_AFTER_MS) + const viewerAgentStatuses = Object.values(viewerState.agentStatusByPaneKey) + expect(viewerAgentStatuses).toHaveLength(WORKTREE_COUNT) + expect( + viewerAgentStatuses.every( + (entry) => + entry.state === 'working' && + isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + ) + ).toBe(true) + + publications.length = 0 + const completionAt = Date.now() + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;Cursor ready\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(WORKTREE_COUNT) + expect(publications.every(({ updatedAt }) => updatedAt === completionAt)).toBe(true) + unsubscribe() + }) + + it.each([ + { + agent: 'pi' as const, + firstTitle: '⠋ π - project', + heartbeatTitles: ['⠋ π - project', '⠙ π - project'], + expectedTitle: '⠋ Pi' + }, + { + agent: 'grok-build' as const, + firstTitle: '⠋ - Waiting for response… - grok', + heartbeatTitles: ['⠴ - Thinking - grok', '⠦ - Sleep 2s then echo hello… - grok'], + expectedTitle: '⠋ Grok' + } + ])('renews exact and normalized $agent frames beyond the viewer stale boundary', (testCase) => { + const runtime = new OrcaRuntimeService() + const firstWorkingTitleAt = Date.now() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + internals.ptysById.get(ptyId)!.launchAgent = testCase.agent + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + seededTab.agentStatus = { + state: 'working', + prompt: 'task', + updatedAt: firstWorkingTitleAt, + stateStartedAt: firstWorkingTitleAt, + agentType: testCase.agent, + paneKey: seededTab.id, + stateHistory: [] + } + const publications: RuntimeMobileSessionTabsResult[] = [] + let viewerState = makeViewerState() + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + viewerState = { ...viewerState, ...patch } + }) + + runtime.onPtyData(ptyId, `\x1b]0;${testCase.firstTitle}\x07`, Date.now()) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const initialTerminal = publications[0]?.tabs[0] + const initialAgentStatus = + initialTerminal?.type === 'terminal' ? initialTerminal.agentStatus : undefined + expect(initialAgentStatus?.state).toBe('working') + publications.length = 0 + + for (const title of testCase.heartbeatTitles) { + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, `\x1b]0;${title}\x07`, Date.now()) + vi.advanceTimersByTime(50) + } + + expect(publications).toHaveLength(2) + expect( + publications.every((snapshot) => { + const terminal = snapshot.tabs[0] + return terminal?.type === 'terminal' && terminal.title === testCase.expectedTitle + }) + ).toBe(true) + const finalTerminal = publications.at(-1)?.tabs[0] + const finalAgentStatus = + finalTerminal?.type === 'terminal' ? finalTerminal.agentStatus : undefined + expect(finalAgentStatus?.updatedAt).toBeGreaterThan(initialAgentStatus!.updatedAt) + expect(finalAgentStatus?.stateStartedAt).toBe(initialAgentStatus?.stateStartedAt) + expect(finalAgentStatus?.prompt).toBe('') + expect(finalAgentStatus?.agentType).toBe(initialAgentStatus?.agentType) + const acknowledgedAt = initialAgentStatus!.updatedAt + 1 + expect(acknowledgedAt).toBeGreaterThanOrEqual(finalAgentStatus!.stateStartedAt) + expect(Date.now() - firstWorkingTitleAt).toBeGreaterThan(AGENT_STATUS_STALE_AFTER_MS) + const viewerAgentStatus = Object.values(viewerState.agentStatusByPaneKey)[0] + expect(viewerAgentStatus?.state).toBe('working') + expect( + isExplicitAgentStatusFresh(viewerAgentStatus!, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + ).toBe(true) + + publications.length = 0 + vi.advanceTimersByTime(1) + runtime.onPtyData(ptyId, '\x1b]0;bash\x07', Date.now()) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const completedTerminal = publications[0]?.tabs[0] + const completedStatus = + completedTerminal?.type === 'terminal' ? completedTerminal.agentStatus : undefined + expect(completedStatus).toMatchObject({ state: 'done', prompt: '' }) + expect(completedStatus?.stateStartedAt).toBeGreaterThan(finalAgentStatus!.stateStartedAt) + unsubscribe() + }) + + it('does not publish a stored working row over a later permission title', () => { + const runtime = new OrcaRuntimeService() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + seededTab.agentStatus = { + state: 'working', + prompt: 'previous task', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'cursor', + paneKey: seededTab.id, + stateHistory: [] + } + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + vi.advanceTimersByTime(50) + publications.length = 0 + vi.advanceTimersByTime(1) + const permissionAt = Date.now() + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent waiting\x07', permissionAt) + vi.advanceTimersByTime(50) + + const terminal = publications.at(-1)?.tabs[0] + const status = terminal?.type === 'terminal' ? terminal.agentStatus : undefined + expect(status).toMatchObject({ + state: 'blocked', + prompt: '', + updatedAt: permissionAt, + stateStartedAt: permissionAt + }) + unsubscribe() + }) + + it('does not publish a prior done row over a newer working title', () => { + const runtime = new OrcaRuntimeService() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + seededTab.agentStatus = { + state: 'working', + prompt: 'first task', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'cursor', + paneKey: seededTab.id, + stateHistory: [] + } + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + vi.advanceTimersByTime(50) + vi.advanceTimersByTime(1) + runtime.onPtyData(ptyId, '\x1b]0;bash\x07', Date.now()) + vi.advanceTimersByTime(50) + vi.advanceTimersByTime(1) + const doneAt = Date.now() + seededTab.agentStatus = { + state: 'done', + prompt: 'first task', + updatedAt: doneAt, + stateStartedAt: doneAt, + agentType: 'cursor', + paneKey: seededTab.id, + stateHistory: [] + } + publications.length = 0 + + vi.advanceTimersByTime(1) + const nextWorkingAt = Date.now() + runtime.onPtyData(ptyId, '\x1b]0;⠙ Cursor Agent\x07', nextWorkingAt) + vi.advanceTimersByTime(50) + + const terminal = publications.at(-1)?.tabs[0] + const status = terminal?.type === 'terminal' ? terminal.agentStatus : undefined + expect(status).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: nextWorkingAt, + stateStartedAt: nextWorkingAt + }) + unsubscribe() + }) + + it('holds decorative heartbeats behind unresolved foreground ownership', async () => { + let resolveForegroundProcess: (agent: string | null) => void = () => undefined + const foregroundProcess = new Promise((resolve) => { + resolveForegroundProcess = resolve + }) + const getForegroundProcess = vi.fn(() => foregroundProcess) + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-0' }), + write: () => true, + kill: () => true, + getForegroundProcess + }) + const ptyId = seedWorktree(runtime, 0) + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Pi\x07', Date.now()) + runtime.onPtyData(ptyId, '\x1b]0;⠙ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(50) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(publications).toHaveLength(0) + + resolveForegroundProcess('pi') + await vi.advanceTimersByTimeAsync(50) + expect(publications).toHaveLength(1) + expect(publications[0]?.tabs[0]).toMatchObject({ + type: 'terminal', + title: '⠋ Pi', + agentStatus: { state: 'working' } + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠹ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(50) + expect(publications).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, '\x1b]0;⠸ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(50) + expect(publications).toHaveLength(2) + unsubscribe() + }) + + it('clears delayed ownership markers when tracked terminal state resets', async () => { + let resolveForegroundProcess: (agent: string | null) => void = () => undefined + const foregroundProcess = new Promise((resolve) => { + resolveForegroundProcess = resolve + }) + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-0' }), + write: () => true, + kill: () => true, + getForegroundProcess: () => foregroundProcess + }) + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(0) + expect(internals.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId)).toBe(true) + + internals.resetTrackedTerminalStateForProviderGeneration(ptyId) + expect(internals.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId)).toBe(false) + resolveForegroundProcess(null) + await vi.advanceTimersByTimeAsync(0) + }) + + it('renews retained hook status without resetting its state start', () => { + const runtime = new OrcaRuntimeService() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + runtime.registerPty(ptyId, 'workspace-0', null, { + tabId: seededTab.parentTabId, + leafId: seededTab.leafId + }) + const publications: RuntimeMobileSessionTabsResult[] = [] + let viewerState = makeViewerState() + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + viewerState = { ...viewerState, ...patch } + }) + + runtime.onPtyData( + ptyId, + '\x1b]9999;{"state":"working","prompt":"retained work","agentType":"codex"}\x07', + Date.now() + ) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const initialTerminal = publications[0]?.tabs[0] + const initialStatus = + initialTerminal?.type === 'terminal' ? initialTerminal.agentStatus : undefined + expect(initialStatus).toMatchObject({ state: 'working', prompt: 'retained work' }) + publications.length = 0 + + vi.advanceTimersByTime(10) + runtime.onPtyData(ptyId, '\x1b]0;⠋ Codex working\x07', Date.now()) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const titledTerminal = publications[0]?.tabs[0] + const titledStatus = + titledTerminal?.type === 'terminal' ? titledTerminal.agentStatus : undefined + expect(titledStatus).toMatchObject({ state: 'working', prompt: 'retained work' }) + expect(titledStatus?.stateStartedAt).toBe(initialStatus?.stateStartedAt) + publications.length = 0 + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, '\x1b]0;⠙ Codex working\x07', Date.now()) + vi.advanceTimersByTime(50) + + expect(publications).toHaveLength(1) + const refreshedTerminal = publications[0]?.tabs[0] + const refreshedStatus = + refreshedTerminal?.type === 'terminal' ? refreshedTerminal.agentStatus : undefined + expect(refreshedStatus).toMatchObject({ state: 'working', prompt: 'retained work' }) + expect(refreshedStatus?.updatedAt).toBeGreaterThan(initialStatus!.updatedAt) + expect(refreshedStatus?.stateStartedAt).toBe(initialStatus?.stateStartedAt) + expect(initialStatus!.updatedAt + 1).toBeGreaterThanOrEqual(refreshedStatus!.stateStartedAt) + + publications.length = 0 + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, '\x1b]0;⠹ Codex working\x07', Date.now()) + vi.advanceTimersByTime(50) + + expect(publications).toHaveLength(1) + const expiredHookTerminal = publications[0]?.tabs[0] + const titleOnlyStatus = + expiredHookTerminal?.type === 'terminal' ? expiredHookTerminal.agentStatus : undefined + expect(titleOnlyStatus).toMatchObject({ state: 'working', prompt: '' }) + expect(titleOnlyStatus?.updatedAt).toBeGreaterThan(refreshedStatus!.updatedAt) + expect(titleOnlyStatus?.stateStartedAt).toBe(initialStatus?.stateStartedAt) + expect(Date.now() - initialStatus!.updatedAt).toBeGreaterThan(AGENT_STATUS_STALE_AFTER_MS) + const viewerStatus = Object.values(viewerState.agentStatusByPaneKey)[0] + expect(viewerStatus?.prompt).toBe('') + expect(isExplicitAgentStatusFresh(viewerStatus!, Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe( + true + ) + unsubscribe() + }) +}) diff --git a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts new file mode 100644 index 00000000000..2c60fe19082 --- /dev/null +++ b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../src/shared/runtime-types' + +const PTY_ID = 'pty-0' +const WORKTREE_ID = 'workspace-0' +const TAB_ID = 'host-tab-0' +const LEAF_ID = '00000000-0000-4000-8000-000000000000' + +type TerminalTab = Extract + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map + ptysById: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void +} + +type Harness = { + internals: RuntimeInternals + publications: RuntimeMobileSessionTabsResult[] + runtime: OrcaRuntimeService + tab: TerminalTab + unsubscribe: () => void +} + +function createHarness(): Harness { + const runtime = new OrcaRuntimeService() + runtime.registerPty(PTY_ID, WORKTREE_ID) + const tab: TerminalTab = { + type: 'terminal', + id: `${TAB_ID}::${LEAF_ID}`, + parentTabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + title: 'Cursor Agent', + parentLayout: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }, + isActive: true + } + const internals = runtime as unknown as RuntimeInternals + internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-0', + activeTabId: tab.id, + activeTabType: 'terminal', + tabs: [tab] + }) + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + return { internals, publications, runtime, tab, unsubscribe } +} + +function setRichStatus( + tab: TerminalTab, + args: { agentType?: 'claude' | 'cursor'; prompt: string; at: number } +): void { + tab.agentStatus = { + state: 'working', + prompt: args.prompt, + updatedAt: args.at, + stateStartedAt: args.at, + agentType: args.agentType ?? 'cursor', + paneKey: tab.id, + stateHistory: [] + } +} + +function emitTitle(harness: Harness, title: string, at = Date.now()): void { + harness.runtime.onPtyData(PTY_ID, `\x1b]0;${title}\x07`, at) + vi.advanceTimersByTime(50) +} + +function latestStatus(harness: Harness) { + const terminal = harness.publications.at(-1)?.tabs[0] + return terminal?.type === 'terminal' ? terminal.agentStatus : undefined +} + +describe('session-tabs rich-status boundaries', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('does not renew a previous task across a done-to-working title boundary', () => { + const harness = createHarness() + const firstWorkingAt = Date.now() + setRichStatus(harness.tab, { prompt: 'previous task', at: firstWorkingAt }) + emitTitle(harness, '⠋ Cursor Agent', firstWorkingAt) + vi.advanceTimersByTime(1) + emitTitle(harness, 'bash') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const nextWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', nextWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: nextWorkingAt, + stateStartedAt: nextWorkingAt + }) + harness.unsubscribe() + }) + + it('preserves the same task across a neutral title', () => { + const harness = createHarness() + const workingAt = Date.now() + setRichStatus(harness.tab, { prompt: 'current task', at: workingAt }) + emitTitle(harness, '⠋ Cursor Agent', workingAt) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + emitTitle(harness, 'Terminal') + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: 'current task', + updatedAt: workingAt, + stateStartedAt: workingAt + }) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + const resumedWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', resumedWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: 'current task', + updatedAt: resumedWorkingAt, + stateStartedAt: workingAt + }) + harness.unsubscribe() + }) + + it('does not resurrect a task after neutral then shell titles', () => { + const harness = createHarness() + setRichStatus(harness.tab, { prompt: 'completed task', at: Date.now() }) + emitTitle(harness, '⠋ Cursor Agent') + vi.advanceTimersByTime(1) + emitTitle(harness, 'Terminal') + vi.advanceTimersByTime(1) + emitTitle(harness, 'bash') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const nextWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', nextWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: nextWorkingAt, + stateStartedAt: nextWorkingAt + }) + harness.unsubscribe() + }) + + it('does not carry a stale question into the next title interval', () => { + const harness = createHarness() + const firstWorkingAt = Date.now() + harness.tab.agentStatus = { + state: 'waiting', + prompt: 'previous question', + interactivePrompt: '{"question":"previous question"}', + updatedAt: firstWorkingAt, + stateStartedAt: firstWorkingAt, + agentType: 'cursor', + paneKey: harness.tab.id, + stateHistory: [] + } + emitTitle(harness, 'Cursor Agent needs confirmation', firstWorkingAt) + vi.advanceTimersByTime(1) + emitTitle(harness, 'bash') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + emitTitle(harness, '⠋ Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'working', prompt: '' }) + expect(latestStatus(harness)).not.toHaveProperty('interactivePrompt') + harness.unsubscribe() + }) + + it('keeps a renderer question visible under a shell title until the next agent interval', () => { + const harness = createHarness() + const questionAt = Date.now() + harness.tab.agentStatus = { + state: 'waiting', + prompt: 'current question', + interactivePrompt: '{"question":"current question"}', + updatedAt: questionAt, + stateStartedAt: questionAt, + agentType: 'cursor', + paneKey: harness.tab.id, + stateHistory: [] + } + + emitTitle(harness, 'bash') + expect(latestStatus(harness)).toMatchObject({ + state: 'waiting', + interactivePrompt: '{"question":"current question"}' + }) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + emitTitle(harness, '⠋ Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'working', prompt: '' }) + expect(latestStatus(harness)).not.toHaveProperty('interactivePrompt') + harness.unsubscribe() + }) + + it('keeps a retained OSC question visible under a shell title until the next agent interval', () => { + const harness = createHarness() + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: harness.tab.parentTabId, + leafId: harness.tab.leafId + }) + harness.runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"waiting","prompt":"current question","interactivePrompt":"question-payload","agentType":"cursor"}\x07', + Date.now() + ) + + emitTitle(harness, 'bash') + expect(latestStatus(harness)).toMatchObject({ + state: 'waiting', + interactivePrompt: 'question-payload' + }) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + emitTitle(harness, '⠋ Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'working', prompt: '' }) + expect(latestStatus(harness)).not.toHaveProperty('interactivePrompt') + harness.unsubscribe() + }) + + it('does not resurrect retained status after an identity-only owner change', () => { + const harness = createHarness() + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: harness.tab.parentTabId, + leafId: harness.tab.leafId + }) + harness.runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"retained task","agentType":"codex"}\x07', + Date.now() + ) + vi.advanceTimersByTime(50) + emitTitle(harness, '⠋ Codex working') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + emitTitle(harness, 'Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'done', prompt: '' }) + harness.unsubscribe() + }) + + it('does not renew rich status from a replaced provider generation', () => { + const harness = createHarness() + const firstWorkingAt = Date.now() + setRichStatus(harness.tab, { prompt: 'previous provider task', at: firstWorkingAt }) + emitTitle(harness, '⠋ Cursor Agent', firstWorkingAt) + harness.tab.agentStatus!.updatedAt = Date.now() + harness.internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const replacementWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', replacementWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: replacementWorkingAt, + stateStartedAt: replacementWorkingAt + }) + harness.unsubscribe() + }) + + it('keeps invalidation across an identity-only owner change', () => { + const harness = createHarness() + setRichStatus(harness.tab, { + prompt: 'previous owner task', + at: Date.now(), + agentType: 'claude' + }) + emitTitle(harness, '⠋ Claude working') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const identityChangeAt = Date.now() + emitTitle(harness, 'Cursor Agent', identityChangeAt) + expect(harness.internals.ptysById.get(PTY_ID)?.lastAgentStatusRichInvalidatedAtEpochMs).toBe( + identityChangeAt + ) + expect(latestStatus(harness)).toBeUndefined() + harness.unsubscribe() + }) +}) From 1fafbe65f50b895a49db9e38ca4aba9347f83ef3 Mon Sep 17 00:00:00 2001 From: ax-dfcorp Date: Tue, 11 Aug 2026 06:32:37 +0900 Subject: [PATCH 003/211] fix(sidebar): dispatch workspace delete intent after menu close (#11646) * fix(sidebar): keep delayed workspace delete target stable * fix(sidebar): report a stale workspace list instead of a silent delete no-op runWorktreeDelete fails closed when the clicked row is no longer in the store (concurrent delete, state reset, or a runtime re-pair that drops live rows). The guard is right, but it returned with no feedback, so Delete looked broken. Report the miss with the stale-list toast runWorktreeBatchDelete already uses, extracted to a shared module so both paths share one description string. * fix(sidebar): dispatch delete intent after menu close * fix(sidebar): validate batch delete identities * fix(sidebar): preserve delete identity through confirmation * test(sidebar): follow delete status boundary extraction * fix(sidebar): bound delete status hydration * test(sidebar): register current parallel delete targets --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- ...rktreeDialog.host-context-boundary.test.ts | 22 +- .../sidebar/DeleteWorktreeDialog.test.tsx | 74 ++++- .../sidebar/DeleteWorktreeDialog.tsx | 82 ++--- .../sidebar/WorktreeContextMenu.tsx | 58 ++-- .../sidebar/delete-worktree-flow.test.ts | 177 ++++++++++- .../sidebar/delete-worktree-flow.ts | 296 ++++-------------- .../delete-worktree-parallel-flow.test.ts | 21 +- .../sidebar/stale-workspace-list-toast.ts | 33 ++ .../use-confirmed-worktree-delete-targets.ts | 45 +++ .../use-delete-worktree-status-hydration.ts | 60 ++++ ...orktree-context-menu-delete-intent.test.ts | 76 +++++ .../worktree-context-menu-delete-intent.ts | 38 +++ .../sidebar/worktree-delete-execution.ts | 194 ++++++++++++ .../sidebar/worktree-delete-request.ts | 65 ++++ src/renderer/src/i18n/locales/en.json | 3 +- 15 files changed, 904 insertions(+), 340 deletions(-) create mode 100644 src/renderer/src/components/sidebar/stale-workspace-list-toast.ts create mode 100644 src/renderer/src/components/sidebar/use-confirmed-worktree-delete-targets.ts create mode 100644 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts create mode 100644 src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts create mode 100644 src/renderer/src/components/sidebar/worktree-delete-execution.ts create mode 100644 src/renderer/src/components/sidebar/worktree-delete-request.ts diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts index 30066815370..27a5f100654 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const SOURCE = readFileSync(join(__dirname, 'DeleteWorktreeDialog.tsx'), 'utf8') +const SOURCE = readFileSync(join(__dirname, 'use-delete-worktree-status-hydration.ts'), 'utf8') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -14,15 +14,21 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('DeleteWorktreeDialog host-context boundaries', () => { it('preloads git status from the selected worktree owner instead of the focused host', () => { - const effect = sourceBetween( - SOURCE, - 'const statusTargets = deleteTargets.filter(', - 'return () => {' - ) + const effect = sourceBetween(SOURCE, 'const targets = deleteTargets.filter(', 'return () => {') expect(effect).toContain('getSettingsForWorktreeRuntimeOwner') expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo') - expect(effect).toContain('item.id') - expect(effect).not.toContain('settings,\n worktreeId: item.id') + expect(effect).toContain('target.id') + expect(effect).not.toContain('settings,\n worktreeId: target.id') + }) + + it('does not restart pending status requests when one target hydrates', () => { + const effect = sourceBetween(SOURCE, 'useEffect(() => {', '}, [') + + expect(SOURCE).not.toContain('useAppStore((state) => state.gitStatusByWorktree)') + expect(effect).toContain('useAppStore.getState().gitStatusByWorktree') + expect(effect).toContain('const controller = new AbortController()') + expect(effect).toContain('{ signal: controller.signal }') + expect(effect).toContain('controller.abort()') }) }) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 3106deda4c7..65700ef574b 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -43,7 +43,9 @@ vi.mock('@/store', () => ({ })) vi.mock('@/store/selectors', () => ({ - useAllWorktrees: () => mocks.state.allWorktrees() + useAllWorktrees: () => mocks.state.allWorktrees(), + getWorktreeMapFromState: () => + new Map(mocks.state.allWorktrees().map((worktree) => [worktree.id, worktree])) })) vi.mock('@/components/ui/dialog', () => ({ @@ -91,7 +93,12 @@ vi.mock('./active-worktree-focus-after-delete', () => ({ prepareActiveWorktreeFocusAfterDelete: () => vi.fn() })) +vi.mock('./stale-workspace-list-toast', () => ({ + showWorkspaceListChangedToast: vi.fn() +})) + import { runWorktreeDeletesInParallel } from './delete-worktree-flow' +import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' function makeWorktree(id: string, path: string): Worktree { return { @@ -153,7 +160,11 @@ describe('DeleteWorktreeDialog lineage copy', () => { it('shows child-delete copy and only a delete-all action when the workspace has children', async () => { const parent = makeWorktree('Parent workspace', '/workspaces/parent') const child = makeWorktree('Child workspace', '/workspaces/child') - mocks.state.modalData = { worktreeId: parent.id } + mocks.state.modalData = { + worktreeId: parent.id, + worktreeDeleteIdentities: [{ id: parent.id, instanceId: parent.instanceId }], + lineageDeleteIdentities: [child, parent].map(({ id, instanceId }) => ({ id, instanceId })) + } mocks.state.allWorktrees.mockReturnValue([parent, child]) mocks.state.worktreeLineageById = { [child.id]: makeLineage(child, parent) @@ -295,7 +306,11 @@ describe('DeleteWorktreeDialog lineage copy', () => { it('notifies the dialog caller after a toast force delete succeeds', async () => { const workspace = makeWorktree('Workspace', '/workspaces/workspace') const onDeleted = vi.fn() - mocks.state.modalData = { worktreeId: workspace.id, onDeleted } + mocks.state.modalData = { + worktreeId: workspace.id, + worktreeDeleteIdentities: [{ id: workspace.id, instanceId: workspace.instanceId }], + onDeleted + } mocks.state.allWorktrees.mockReturnValue([workspace]) const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') @@ -318,4 +333,57 @@ describe('DeleteWorktreeDialog lineage copy', () => { expect(onDeleted).toHaveBeenCalledWith([workspace.id]) }) + + it('rejects confirmation when the workspace instance changed after the dialog opened', async () => { + const original = makeWorktree('Workspace', '/workspaces/original') + const replacement = { ...original, instanceId: 'replacement-instance' } + mocks.state.modalData = { + worktreeId: original.id, + worktreeDeleteIdentities: [{ id: original.id, instanceId: original.instanceId }] + } + mocks.state.allWorktrees.mockReturnValue([original]) + + const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') + renderToStaticMarkup() + mocks.state.allWorktrees.mockReturnValue([replacement]) + + const deleteButton = mocks.buttonProps.find((props) => props.variant === 'destructive') as + | { onClick?: (event: never) => void } + | undefined + deleteButton?.onClick?.(undefined as never) + + expect(showWorkspaceListChangedToast).toHaveBeenCalledOnce() + expect(mocks.state.closeModal).toHaveBeenCalledOnce() + expect(runWorktreeDeletesInParallel).not.toHaveBeenCalled() + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + }) + + it('rejects lineage confirmation when a descendant instance changed', async () => { + const parent = makeWorktree('Parent workspace', '/workspaces/parent') + const child = makeWorktree('Child workspace', '/workspaces/child') + const replacement = { ...child, instanceId: 'replacement-instance' } + mocks.state.modalData = { + worktreeId: parent.id, + worktreeDeleteIdentities: [{ id: parent.id, instanceId: parent.instanceId }], + lineageDeleteIdentities: [child, parent].map(({ id, instanceId }) => ({ id, instanceId })) + } + mocks.state.allWorktrees.mockReturnValue([parent, child]) + mocks.state.worktreeLineageById = { + [child.id]: makeLineage(child, parent) + } + + const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') + renderToStaticMarkup() + mocks.state.allWorktrees.mockReturnValue([parent, replacement]) + + const deleteButton = mocks.buttonProps.find((props) => props.variant === 'destructive') as + | { onClick?: () => void } + | undefined + deleteButton?.onClick?.() + + expect(showWorkspaceListChangedToast).toHaveBeenCalledOnce() + expect(mocks.state.closeModal).toHaveBeenCalledOnce() + expect(runWorktreeDeletesInParallel).not.toHaveBeenCalled() + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 6bbe0581c9a..568faf329b4 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -9,9 +9,6 @@ import { import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { toast } from 'sonner' -import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' @@ -30,6 +27,8 @@ import { isFolderWorkspaceDelete as getIsFolderWorkspaceDelete } from './delete-worktree-dialog-copy' import { translate } from '@/i18n/i18n' +import { useDeleteWorktreeStatusHydration } from './use-delete-worktree-status-hydration' +import { useConfirmedWorktreeDeleteTargets } from './use-confirmed-worktree-delete-targets' const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -43,9 +42,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const updateSettings = useAppStore((s) => s.updateSettings) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const settings = useAppStore((s) => s.settings) const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) - const setGitStatus = useAppStore((s) => s.setGitStatus) const isOpen = activeModal === 'delete-worktree' const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' @@ -58,6 +55,12 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { : [], [modalData.worktreeIds, worktreeId] ) + const { worktreeDeleteIdentities, lineageDeleteIdentities, resolveConfirmedTargets } = + useConfirmedWorktreeDeleteTargets({ + worktreeIdentityData: modalData.worktreeDeleteIdentities, + lineageIdentityData: modalData.lineageDeleteIdentities, + closeModal + }) const onDeleted = typeof modalData.onDeleted === 'function' ? (modalData.onDeleted as (worktreeIds: string[]) => void) @@ -146,6 +149,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { repoMap }) }, [deleteStateByWorktreeId, deleteTargets, gitStatusByWorktree, repoMap]) + useDeleteWorktreeStatusHydration({ isOpen, deleteTargets, repoMap }) if (!isOpen && dontAskAgain) { // Why: this checkbox is a one-shot dialog intent; reset it as soon as the @@ -170,47 +174,6 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { worktrees.length ]) - useEffect(() => { - if (!isOpen) { - return - } - const statusTargets = deleteTargets.filter( - (item) => - !item.isMainWorktree && - !getIsFolderWorkspaceDelete(repoMap, item) && - gitStatusByWorktree[item.id] === undefined - ) - if (statusTargets.length === 0) { - return - } - let cancelled = false - for (const item of statusTargets) { - void getRuntimeGitStatus({ - // Why: delete warnings inspect git state for the selected workspace; - // a later focused-host switch must not make this preload query another host. - settings: getSettingsForWorktreeRuntimeOwner( - { repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo }, - item.id - ), - worktreeId: item.id, - worktreePath: item.path, - connectionId: getConnectionId(item.id) ?? undefined - }) - .then((status) => { - if (!cancelled) { - setGitStatus(item.id, status) - } - }) - .catch(() => { - // Best-effort only: delete itself still performs the authoritative - // backend check and will surface failures through the normal toast. - }) - } - return () => { - cancelled = true - } - }, [deleteTargets, gitStatusByWorktree, isOpen, repoMap, repos, setGitStatus, settings]) - const handleOpenChange = useCallback( (open: boolean) => { if (open) { @@ -254,6 +217,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { if (worktreeIds.length === 0) { return } + const currentWorktrees = resolveConfirmedTargets(worktreeDeleteIdentities, worktreeIds.length) + if (!currentWorktrees) { + return + } // Why: force-delete is a recovery path taken after a failed first delete. // Saving "don't ask again" from that state would conflate the recovery // action with a broader preference. Only persist the preference on the @@ -303,7 +270,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { // Why: this modal is the destructive confirmation for the workspace // folder. Running a non-force remove here just turns dirty files into // a redundant Force Delete toast after the user already confirmed. - const deletePromise = runWorktreeDeletesInParallel(worktrees, { + const deletePromise = runWorktreeDeletesInParallel(currentWorktrees, { force: true, onForceDeleted: handleForceDeletedFromToast }) @@ -326,8 +293,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { persistDontAskAgainPreference, removeWorktree, worktreeIds.length, + worktreeDeleteIdentities, worktreeId, - worktrees + resolveConfirmedTargets ] ) @@ -335,9 +303,16 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { if (lineageDelete.deleteAllTargets.length <= 1) { return } + const currentTargets = resolveConfirmedTargets( + lineageDeleteIdentities, + lineageDelete.deleteAllTargets.length + ) + if (!currentTargets) { + return + } // Why: the lineage modal confirms every affected workspace up front, so // dirty child workspaces should not create per-workspace force prompts. - const deletePromise = runWorktreeDeletesInParallel(lineageDelete.deleteAllTargets, { + const deletePromise = runWorktreeDeletesInParallel(currentTargets, { force: true, onForceDeleted: handleForceDeletedFromToast }) @@ -349,7 +324,14 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { onDeleted?.(deletedIds) } }) - }, [closeModal, handleForceDeletedFromToast, lineageDelete.deleteAllTargets, onDeleted]) + }, [ + closeModal, + handleForceDeletedFromToast, + lineageDelete.deleteAllTargets.length, + lineageDeleteIdentities, + onDeleted, + resolveConfirmedTargets + ]) return ( diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 1063c5145e5..be61dd64d3d 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -40,7 +40,10 @@ import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' -import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' +import { + deferWorktreeContextMenuDeleteIntent, + type WorktreeContextMenuDeleteIntent +} from './worktree-context-menu-delete-intent' import { runSleepWorktrees } from './sleep-worktree-flow' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' @@ -62,11 +65,7 @@ import { import { WorkspaceSleepMenuItems } from './WorkspaceSleepMenuItems' import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' import { translate } from '@/i18n/i18n' -import { - folderWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../../../../shared/workspace-scope' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' type Props = { worktree: Worktree @@ -332,8 +331,6 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const projectGroups = useAppStore((s) => s.projectGroups) const createProjectGroup = useAppStore((s) => s.createProjectGroup) const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace) - const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) const repo = useRepoById(worktree.repoId) const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const [menuOpen, setMenuOpen] = useState(false) @@ -674,47 +671,30 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ }, [sleepWorktreesAfterMenuClose, subtreeSleepableWorktrees]) const handleDelete = useCallback(() => { - // Folder mode handled inline because it routes to a different modal; - // standard delete delegates to the shared runWorktreeDelete helper. const restoreSidebarPosition = preserveDeleteSiblingPosition(scopeRef.current) scopeRef.current ?.closest('[data-worktree-sidebar]') ?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) - setMenuOpenState(false) - // Why: Delete can remove the active row and remount the sidebar. Run it - // after menu close for the same reason as Sleep above. - window.setTimeout(() => { - if (isMultiContext) { - runWorktreeBatchDelete(batchDeleteWorktrees.map((item) => item.id)) - restoreSidebarPosition() - return - } - if (folderWorkspaceId) { - void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => { - if ( - deleted && - useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) - ) { - setActiveWorktree(null) + const intent: WorktreeContextMenuDeleteIntent = isMultiContext + ? { + kind: 'batch', + worktrees: batchDeleteWorktrees.map(({ id, instanceId }) => ({ id, instanceId })) + } + : folderWorkspaceId + ? { kind: 'folder', folderWorkspaceId } + : { + kind: 'worktree', + worktree: { id: worktree.id, instanceId: worktree.instanceId } } - }) - restoreSidebarPosition() - return - } - // Why delegate to runWorktreeDelete: keeps the delete-vs-project-removal - // decision tree (and its rationale) in one place shared with command - // surfaces and the memory popover's inline Delete action. - runWorktreeDelete(worktree.id) - restoreSidebarPosition() - }, 50) + deferWorktreeContextMenuDeleteIntent(intent, restoreSidebarPosition) + setMenuOpenState(false) }, [ batchDeleteWorktrees, - deleteFolderWorkspace, folderWorkspaceId, isMultiContext, - setActiveWorktree, setMenuOpenState, - worktree.id + worktree.id, + worktree.instanceId ]) const handleOpenParent = useCallback(() => { diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index 941c4d38eaa..9436f67bfcf 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -74,7 +74,11 @@ vi.mock('./delete-worktree-failure-toast', () => ({ import { toast } from 'sonner' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' -import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' +import { + runWorktreeBatchDelete, + runWorktreeDelete, + runWorktreeDeletesInParallel +} from './delete-worktree-flow' function setWorktrees( worktrees: { @@ -101,7 +105,7 @@ function setWorktrees( ) } -describe('runWorktreeBatchDelete', () => { +describe('delete worktree flow', () => { beforeEach(() => { mocks.state.settings = { skipDeleteWorktreeConfirm: false } mocks.state.clearWorktreeDeleteState.mockClear() @@ -129,6 +133,10 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalledWith('main') expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeIds: ['wt-1', 'wt-2'], + worktreeDeleteIdentities: [ + { id: 'wt-1', instanceId: 'wt-1-instance' }, + { id: 'wt-2', instanceId: 'wt-2-instance' } + ], allowSkipConfirm: false }) }) @@ -139,7 +147,10 @@ describe('runWorktreeBatchDelete', () => { const started = runWorktreeBatchDelete(['main', 'wt-1']) expect(started).toBe(true) - expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeId: 'wt-1', + worktreeDeleteIdentities: [{ id: 'wt-1', instanceId: 'wt-1-instance' }] + }) }) it('treats duplicate selected ids as one delete target', () => { @@ -149,7 +160,85 @@ describe('runWorktreeBatchDelete', () => { expect(started).toBe(true) expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledTimes(1) - expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeId: 'wt-1', + worktreeDeleteIdentities: [{ id: 'wt-1', instanceId: 'wt-1-instance' }] + }) + }) + + it('rejects the whole batch when a selected path belongs to a different instance', () => { + setWorktrees([ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'instance-2' } + ]) + + const started = runWorktreeBatchDelete([ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'replaced-instance' } + ]) + + expect(started).toBe(false) + expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled() + expect(mocks.state.openModal).not.toHaveBeenCalled() + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenCalledWith( + 'Workspace list changed', + expect.objectContaining({ + description: 'Refresh Space and try again if the workspace list looks stale.' + }) + ) + }) + + it('opens batch confirmation when every selected instance is still current', () => { + setWorktrees([ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'instance-2' } + ]) + + const started = runWorktreeBatchDelete([ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'instance-2' } + ]) + + expect(started).toBe(true) + expect(toast.info).not.toHaveBeenCalled() + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeIds: ['wt-1', 'wt-2'], + worktreeDeleteIdentities: [ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'instance-2' } + ], + allowSkipConfirm: false + }) + }) + + it('revalidates each queued instance immediately before execution', async () => { + setWorktrees([ + { id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' }, + { id: 'wt-2', instanceId: 'instance-2', path: '/workspaces/second' } + ]) + const targets = Array.from(mocks.state.worktreeMap.values()) + let finishFirst!: (result: { ok: true }) => void + mocks.state.removeWorktree.mockImplementationOnce( + () => new Promise((resolve) => (finishFirst = resolve)) + ) + + const deletion = runWorktreeDeletesInParallel(targets) + await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)) + setWorktrees([ + { id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' }, + { id: 'wt-2', instanceId: 'replacement-instance', path: '/workspaces/second' } + ]) + finishFirst({ ok: true }) + + await expect(deletion).resolves.toEqual(['wt-1']) + expect(mocks.state.removeWorktree).not.toHaveBeenCalledWith('wt-2', false) + expect(toast.info).toHaveBeenCalledWith( + 'Workspace list changed', + expect.objectContaining({ + description: 'Refresh Space and try again if the workspace list looks stale.' + }) + ) }) it('keeps batch deletes behind confirmation when confirmation is skipped', () => { @@ -166,6 +255,10 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.removeWorktree).not.toHaveBeenCalled() expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeIds: ['wt-1', 'wt-2'], + worktreeDeleteIdentities: [ + { id: 'wt-1', instanceId: 'wt-1-instance' }, + { id: 'wt-2', instanceId: 'wt-2-instance' } + ], allowSkipConfirm: false, onDeleted }) @@ -271,6 +364,11 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.removeWorktree).not.toHaveBeenCalled() expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'parent', + worktreeDeleteIdentities: [{ id: 'parent', instanceId: 'parent-instance' }], + lineageDeleteIdentities: [ + { id: 'child', instanceId: 'child-instance' }, + { id: 'parent', instanceId: 'parent-instance' } + ], allowSkipConfirm: false }) }) @@ -297,10 +395,80 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.removeWorktree).not.toHaveBeenCalled() expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'parent', + worktreeDeleteIdentities: [{ id: 'parent', instanceId: 'parent-instance' }], + lineageDeleteIdentities: [ + { id: 'child', instanceId: 'child-instance' }, + { id: 'parent', instanceId: 'parent-instance' } + ], allowSkipConfirm: false }) }) + it('reports a stale list instead of silently dropping a delete whose row vanished', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([]) + + runWorktreeDelete('wt-1') + + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(mocks.state.openModal).not.toHaveBeenCalled() + expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenCalledWith( + 'Workspace list changed', + expect.objectContaining({ + description: 'Refresh Space and try again if the workspace list looks stale.' + }) + ) + }) + + it('rejects a delayed delete when the path now belongs to a different instance', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', instanceId: 'instance-2' }]) + + runWorktreeDelete('wt-1', { expectedInstanceId: 'instance-1' }) + + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(mocks.state.openModal).not.toHaveBeenCalled() + expect(toast.info).toHaveBeenCalledWith( + 'Workspace list changed', + expect.objectContaining({ + description: 'Refresh Space and try again if the workspace list looks stale.' + }) + ) + }) + + it('runs a delayed delete when the captured instance is still current', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', instanceId: 'instance-1', displayName: 'one' }]) + + runWorktreeDelete('wt-1', { expectedInstanceId: 'instance-1' }) + + expect(toast.info).not.toHaveBeenCalled() + expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false) + }) + + // Why: the delete-current-workspace shortcut (useIpcEvents) forwards whatever workspace is + // active, and a folder workspace is never in the worktree map — claiming it vanished would lie. + it('stays silent for a folder workspace, which this funnel does not route', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([]) + + runWorktreeDelete('folder:11111111-2222-3333-4444-555555555555') + + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(toast.info).not.toHaveBeenCalled() + }) + + it('does not report a stale list when the workspace is still present', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', displayName: 'one' }]) + + runWorktreeDelete('wt-1') + + expect(toast.info).not.toHaveBeenCalled() + expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false) + }) + it('opens project removal confirmation for a primary workspace', () => { mocks.state.settings = { skipDeleteWorktreeConfirm: true } setWorktrees([ @@ -334,6 +502,7 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.removeWorktree).not.toHaveBeenCalled() expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1', + worktreeDeleteIdentities: [{ id: 'wt-1', instanceId: 'wt-1-instance' }], allowSkipConfirm: false, onDeleted }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index 6259789187b..2daca622729 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -1,221 +1,46 @@ -import { toast } from 'sonner' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getWorktreeMapFromState } from '@/store/selectors' import { findRepoForHost } from '@/store/slices/repo-host-identity' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' -import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' +import { + showNoDeletableWorkspacesToast, + showWorkspaceListChangedToast +} from './stale-workspace-list-toast' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { - isPathInsideOrEqual, - normalizeRuntimePathForComparison -} from '../../../../shared/cross-platform-path' -import type { Worktree } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' + resolveWorktreeBatchDeleteTargets, + toWorktreeDeleteIdentities, + type WorktreeBatchDeleteOptions, + type WorktreeDeleteIdentity, + type WorktreeDeleteOptions +} from './worktree-delete-request' +import { + runWorktreeDeletesInParallel, + runWorktreeDeleteWithToast +} from './worktree-delete-execution' -type WorktreeBatchDeleteOptions = { - forceConfirm?: boolean - onDeleted?: (worktreeIds: string[]) => void -} - -type WorktreeDeleteWithToastOptions = { - force?: boolean - onForceDeleted?: (worktreeId: string) => void - // Why: batch deletes suppress the per-delete focus handoff to focus one survivor after the batch (see runWorktreeDeletesInParallel). - focusSuccessorOnDelete?: boolean -} - -// Why: a failed delete usually means unresolved changes, so land on the diff panel, not just focus the worktree. -function viewWorktreeDiff(worktreeId: string): void { - activateAndRevealWorktree(worktreeId) - const state = useAppStore.getState() - state.setRightSidebarTab('source-control') - state.setRightSidebarOpen(true) -} - -function isStrictDescendantPath(parentPath: string, childPath: string): boolean { - return ( - normalizeRuntimePathForComparison(parentPath) !== - normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) - ) -} - -export async function runWorktreeDeletesInParallel( - targets: readonly Pick[], - options: WorktreeDeleteWithToastOptions = {} -): Promise { - // Why: refresh races can leave duplicate rows, but a destructive command must run once per identity. - const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values()) - // Why: capture the viewed workspace before any delete so we can focus one survivor after the batch settles, not per delete. - const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId - const commitBatchFocus = activeWorktreeIdBefore - ? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore) - : null - // Why: mark every target deleting up front for immediate in-flight feedback, even though deletes serialize per repo. - useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id)) - // Why: worktree remove/prune/branch -D race on shared ref locks; group by repoId to serialize per repo (cross-repo stays parallel). - const groups = new Map() - for (const target of uniqueTargets) { - const group = groups.get(target.repoId) - if (group) { - group.push(target) - } else { - groups.set(target.repoId, [target]) - } - } - for (const group of groups.values()) { - // Why: delete nested children first — else the parent delete is rejected while it still contains a registered worktree. - group.sort((a, b) => b.path.length - a.path.length) - } - const groupResults = await Promise.all( - Array.from(groups.values()).map(async (group) => { - const deletedInGroup: string[] = [] - const failedInGroup: (typeof group)[number][] = [] - for (const target of group) { - if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { - useAppStore.getState().clearWorktreeDeleteState(target.id) - continue - } - const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { - ...options, - focusSuccessorOnDelete: false - }) - if (deleted) { - deletedInGroup.push(target.id) - } else { - // Why: after a descendant delete fails, deleting an ancestor can still remove that child from disk (it lives under the parent). - failedInGroup.push(target) - } - } - return deletedInGroup - }) - ) - const deletedSet = new Set(groupResults.flat()) - // Why: focus a survivor once after the batch settles — an intermediate focus could spawn a terminal in a to-be-deleted workspace. - if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) { - commitBatchFocus?.() - } - return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) -} - -/** - * Shared delete-with-toast flow for both DeleteWorktreeDialog (confirm) and - * WorktreeContextMenu (skip-confirm), so both entry points behave identically. - * - * A renderer-layer helper (not a store action) to keep UI concerns out of the store slice. - */ -export function runWorktreeDeleteWithToast( - worktreeId: string, - worktreeName: string, - options: WorktreeDeleteWithToastOptions = {} -): Promise { - const removeWorktree = useAppStore.getState().removeWorktree - const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) - const focusSuccessor = options.focusSuccessorOnDelete !== false - - return removeWorktree(worktreeId, options.force === true) - .then((result) => { - if (result.ok) { - // Why: keep the user on a live workspace instead of the Landing screen when they delete the one they were viewing. - if (focusSuccessor) { - commitFocus() - } - return true - } - const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId] - const canForceDelete = state?.canForceDelete ?? false - const hasKnownChanges = - (useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0 - showDeleteWorktreeFailureToast({ - error: result.error, - canForceDelete, - forceDeleteReason: state?.forceDeleteReason ?? null, - lockReason: state?.lockReason ?? null, - hasKnownChanges, - onViewChanges: () => viewWorktreeDiff(worktreeId), - onForceDelete: () => { - // Why: recapture at click time — the user may have navigated away while the toast was open, so focus only hands off if still viewed. - const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) - // Why (#11960): the user clicked Force Delete on a failure toast, so this - // retry may waive the PTY-stop proof the first attempt could not satisfy. - const forceRemoval = useAppStore - .getState() - .removeWorktree(worktreeId, true, { allowUnverifiedPtyStop: true }) - forceRemoval - .then((forceResult) => { - if (!forceResult.ok) { - toast.error( - translate( - 'auto.components.sidebar.delete.worktree.flow.4f3876c0f5', - 'Force delete failed' - ), - { - description: forceResult.error, - action: { - label: translate( - 'auto.components.sidebar.delete.worktree.flow.7488ed8711', - 'View' - ), - onClick: () => viewWorktreeDiff(worktreeId) - } - } - ) - return - } - commitForceFocus() - options.onForceDeleted?.(worktreeId) - }) - .catch((err: unknown) => { - toast.error( - translate( - 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', - 'Failed to delete workspace' - ), - { - description: err instanceof Error ? err.message : String(err), - action: { - label: translate( - 'auto.components.sidebar.delete.worktree.flow.7488ed8711', - 'View' - ), - onClick: () => viewWorktreeDiff(worktreeId) - } - } - ) - }) - }, - worktreeId, - worktreeName - }) - return false - }) - .catch((err: unknown) => { - toast.error( - translate( - 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', - 'Failed to delete workspace' - ), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return false - }) -} +export { runWorktreeDeletesInParallel, runWorktreeDeleteWithToast } /** * Shared funnel for the standard (non-folder) delete decision tree (WorktreeContextMenu, * MemoryStatusSegment); branches on the `skipDeleteWorktreeConfirm` preference. * - * The missing-record guard is defense-in-depth: refuse to act if the record vanished - * between render and click (concurrent delete or state reset). + * The missing-record and instance guards reject stale actions after concurrent state changes. */ -export function runWorktreeDelete(worktreeId: string): void { +export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOptions = {}): void { const state = useAppStore.getState() const target = getWorktreeMapFromState(state).get(worktreeId) ?? null - if (!target) { + const instanceChanged = + Object.hasOwn(options, 'expectedInstanceId') && + target?.instanceId !== options.expectedInstanceId + if (!target || instanceChanged) { + // Why: folder workspaces are never in the worktree map — their callers own that route, so a + // miss there is a routing gap, not a stale list, and must not claim the workspace is gone. + if (parseWorkspaceKey(worktreeId)?.type !== 'folder') { + showWorkspaceListChangedToast() + } return } if (target.isMainWorktree) { @@ -254,9 +79,12 @@ export function runWorktreeDelete(worktreeId: string): void { return } - const hasLineageChildren = - getWorkspaceDeleteLineage(target, getAllWorktreesFromState(state), state.worktreeLineageById) - .descendants.length > 0 + const deleteLineage = getWorkspaceDeleteLineage( + target, + getAllWorktreesFromState(state), + state.worktreeLineageById + ) + const hasLineageChildren = deleteLineage.descendants.length > 0 const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false if (skipConfirm && !hasLineageChildren) { void runWorktreeDeleteWithToast(worktreeId, target.displayName) @@ -264,33 +92,32 @@ export function runWorktreeDelete(worktreeId: string): void { } state.openModal('delete-worktree', { worktreeId, + worktreeDeleteIdentities: toWorktreeDeleteIdentities([target]), + ...(hasLineageChildren + ? { + lineageDeleteIdentities: toWorktreeDeleteIdentities(deleteLineage.deleteAllTargets) + } + : {}), ...(hasLineageChildren ? { allowSkipConfirm: false } : {}) }) } export function runWorktreeBatchDelete( - worktreeIds: readonly string[], + requestedWorktrees: readonly string[] | readonly WorktreeDeleteIdentity[], options: WorktreeBatchDeleteOptions = {} ): boolean { const state = useAppStore.getState() - const worktreeMap = getWorktreeMapFromState(state) - const targets = Array.from(new Set(worktreeIds)) - .map((id) => worktreeMap.get(id) ?? null) - .filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree) + const targets = resolveWorktreeBatchDeleteTargets( + requestedWorktrees, + getWorktreeMapFromState(state) + ) + if (!targets) { + showWorkspaceListChangedToast() + return false + } if (targets.length === 0) { - toast.info( - translate( - 'auto.components.sidebar.delete.worktree.flow.7243145cd6', - 'No deletable workspaces selected' - ), - { - description: translate( - 'auto.components.sidebar.delete.worktree.flow.b81b4e40ca', - 'Refresh Space and try again if the workspace list looks stale.' - ) - } - ) + showNoDeletableWorkspacesToast() return false } @@ -299,13 +126,15 @@ export function runWorktreeBatchDelete( } // Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step. - const singleTargetHasLineageChildren = - targets.length === 1 && - getWorkspaceDeleteLineage( - targets[0], - getAllWorktreesFromState(state), - state.worktreeLineageById - ).descendants.length > 0 + const singleTargetLineage = + targets.length === 1 + ? getWorkspaceDeleteLineage( + targets[0], + getAllWorktreesFromState(state), + state.worktreeLineageById + ) + : null + const singleTargetHasLineageChildren = (singleTargetLineage?.descendants.length ?? 0) > 0 const skipConfirm = !options.forceConfirm && targets.length === 1 && @@ -325,6 +154,14 @@ export function runWorktreeBatchDelete( if (targets.length === 1) { state.openModal('delete-worktree', { worktreeId: targets[0].id, + worktreeDeleteIdentities: toWorktreeDeleteIdentities(targets), + ...(singleTargetHasLineageChildren && singleTargetLineage + ? { + lineageDeleteIdentities: toWorktreeDeleteIdentities( + singleTargetLineage.deleteAllTargets + ) + } + : {}), ...(options.forceConfirm || singleTargetHasLineageChildren ? { allowSkipConfirm: false } : {}), @@ -335,6 +172,7 @@ export function runWorktreeBatchDelete( state.openModal('delete-worktree', { worktreeIds: targets.map((target) => target.id), + worktreeDeleteIdentities: toWorktreeDeleteIdentities(targets), allowSkipConfirm: false, ...(options.onDeleted ? { onDeleted: options.onDeleted } : {}) }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index 817a02f2040..8def3dedde2 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -49,6 +49,14 @@ vi.mock('sonner', () => ({ import { toast } from 'sonner' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' +function runDeletesForCurrentWorktrees( + targets: Parameters[0], + options?: Parameters[1] +) { + mocks.state.worktreeMap = new Map(targets.map((target) => [target.id, target])) + return runWorktreeDeletesInParallel(targets, options) +} + function deferredDeleteResult(): { promise: Promise<{ ok: true }> resolve: (value: { ok: true }) => void @@ -65,6 +73,7 @@ describe('runWorktreeDeletesInParallel', () => { mocks.state.removeWorktree.mockClear().mockResolvedValue({ ok: true }) mocks.state.clearWorktreeDeleteState.mockClear() mocks.state.markWorktreesDeleting.mockClear() + mocks.state.worktreeMap = new Map() mocks.state.deleteStateByWorktreeId = {} vi.mocked(toast.error).mockClear() vi.mocked(toast.info).mockClear() @@ -77,7 +86,7 @@ describe('runWorktreeDeletesInParallel', () => { .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) - const deleted = runWorktreeDeletesInParallel([ + const deleted = runDeletesForCurrentWorktrees([ { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' }, { id: 'wt-2', displayName: 'two', repoId: 'repo-b', path: '/workspaces/two' } ]) @@ -98,7 +107,7 @@ describe('runWorktreeDeletesInParallel', () => { const childDelete = deferredDeleteResult() mocks.state.removeWorktree.mockReturnValueOnce(childDelete.promise) - const deleted = runWorktreeDeletesInParallel([ + const deleted = runDeletesForCurrentWorktrees([ { id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' }, { id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' } ]) @@ -124,7 +133,7 @@ describe('runWorktreeDeletesInParallel', () => { }) it('deletes nested workspaces before their parent within the same repo', async () => { - await runWorktreeDeletesInParallel([ + await runDeletesForCurrentWorktrees([ { id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' }, { id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' } ]) @@ -134,7 +143,7 @@ describe('runWorktreeDeletesInParallel', () => { }) it('passes confirmed force to each delete', async () => { - await runWorktreeDeletesInParallel( + await runDeletesForCurrentWorktrees( [ { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' }, { id: 'wt-2', displayName: 'two', repoId: 'repo-b', path: '/workspaces/two' } @@ -157,7 +166,7 @@ describe('runWorktreeDeletesInParallel', () => { .mockResolvedValueOnce({ ok: true }) .mockResolvedValueOnce({ ok: false, error: 'selector_not_found' }) - await expect(runWorktreeDeletesInParallel([target, target])).resolves.toEqual(['wt-1']) + await expect(runDeletesForCurrentWorktrees([target, target])).resolves.toEqual(['wt-1']) expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1']) expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) @@ -176,7 +185,7 @@ describe('runWorktreeDeletesInParallel', () => { }) await expect( - runWorktreeDeletesInParallel([ + runDeletesForCurrentWorktrees([ { id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' }, { id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' } ]) diff --git a/src/renderer/src/components/sidebar/stale-workspace-list-toast.ts b/src/renderer/src/components/sidebar/stale-workspace-list-toast.ts new file mode 100644 index 00000000000..890d4c91a60 --- /dev/null +++ b/src/renderer/src/components/sidebar/stale-workspace-list-toast.ts @@ -0,0 +1,33 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +// Why: keys keep their original `delete.worktree.flow` namespace so the existing +// translations are not orphaned by the move out of that module. +function staleWorkspaceListToast(title: string): void { + toast.info(title, { + description: translate( + 'auto.components.sidebar.delete.worktree.flow.b81b4e40ca', + 'Refresh Space and try again if the workspace list looks stale.' + ) + }) +} + +/** A delete target changed or vanished after the action was selected. */ +export function showWorkspaceListChangedToast(): void { + staleWorkspaceListToast( + translate( + 'auto.components.sidebar.delete.worktree.flow.workspaceListChanged', + 'Workspace list changed' + ) + ) +} + +/** A multi-select delete whose selection resolved to nothing deletable. */ +export function showNoDeletableWorkspacesToast(): void { + staleWorkspaceListToast( + translate( + 'auto.components.sidebar.delete.worktree.flow.7243145cd6', + 'No deletable workspaces selected' + ) + ) +} diff --git a/src/renderer/src/components/sidebar/use-confirmed-worktree-delete-targets.ts b/src/renderer/src/components/sidebar/use-confirmed-worktree-delete-targets.ts new file mode 100644 index 00000000000..f2e0dd7929b --- /dev/null +++ b/src/renderer/src/components/sidebar/use-confirmed-worktree-delete-targets.ts @@ -0,0 +1,45 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import { getWorktreeMapFromState } from '@/store/selectors' +import { + readWorktreeDeleteIdentities, + resolveWorktreeBatchDeleteTargets, + type WorktreeDeleteIdentity +} from './worktree-delete-request' +import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' + +export function useConfirmedWorktreeDeleteTargets({ + worktreeIdentityData, + lineageIdentityData, + closeModal +}: { + worktreeIdentityData: unknown + lineageIdentityData: unknown + closeModal: () => void +}) { + const worktreeDeleteIdentities = useMemo( + () => readWorktreeDeleteIdentities(worktreeIdentityData), + [worktreeIdentityData] + ) + const lineageDeleteIdentities = useMemo( + () => readWorktreeDeleteIdentities(lineageIdentityData), + [lineageIdentityData] + ) + const resolveConfirmedTargets = useCallback( + (identities: readonly WorktreeDeleteIdentity[], expectedCount: number) => { + const targets = resolveWorktreeBatchDeleteTargets( + identities, + getWorktreeMapFromState(useAppStore.getState()) + ) + if (!targets || targets.length !== expectedCount) { + showWorkspaceListChangedToast() + closeModal() + return null + } + return targets + }, + [closeModal] + ) + + return { worktreeDeleteIdentities, lineageDeleteIdentities, resolveConfirmedTargets } +} diff --git a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts new file mode 100644 index 00000000000..2a88a430c50 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' +import type { Repo, Worktree } from '../../../../shared/types' +import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy' + +export function useDeleteWorktreeStatusHydration({ + isOpen, + deleteTargets, + repoMap +}: { + isOpen: boolean + deleteTargets: readonly Worktree[] + repoMap: ReadonlyMap +}): void { + const repos = useAppStore((state) => state.repos) + const settings = useAppStore((state) => state.settings) + const setGitStatus = useAppStore((state) => state.setGitStatus) + + useEffect(() => { + if (!isOpen) { + return + } + const gitStatusByWorktree = useAppStore.getState().gitStatusByWorktree + const targets = deleteTargets.filter( + (target) => + !target.isMainWorktree && + !isFolderWorkspaceDelete(repoMap, target) && + gitStatusByWorktree[target.id] === undefined + ) + const controller = new AbortController() + for (const target of targets) { + void getRuntimeGitStatus( + { + settings: getSettingsForWorktreeRuntimeOwner( + { repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo }, + target.id + ), + worktreeId: target.id, + worktreePath: target.path, + connectionId: getConnectionId(target.id) ?? undefined + }, + { signal: controller.signal } + ) + .then((status) => { + if (!controller.signal.aborted) { + setGitStatus(target.id, status) + } + }) + .catch(() => { + // Best effort only; deletion performs the authoritative backend check. + }) + } + return () => { + controller.abort() + } + }, [deleteTargets, isOpen, repoMap, repos, setGitStatus, settings]) +} diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts new file mode 100644 index 00000000000..20d28499be9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ runDelete: vi.fn(), runBatchDelete: vi.fn() })) + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('./delete-worktree-flow', () => ({ + runWorktreeDelete: mocks.runDelete, + runWorktreeBatchDelete: mocks.runBatchDelete +})) + +import { deferWorktreeContextMenuDeleteIntent } from './worktree-context-menu-delete-intent' + +describe('deferWorktreeContextMenuDeleteIntent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('dispatches the selected workspace identity after the menu event completes', () => { + const defer = vi.fn<(callback: () => void) => void>() + const intent = { + kind: 'worktree' as const, + worktree: { id: 'repo::/work/wt', instanceId: 'instance-1' } + } + const onDispatched = vi.fn() + + deferWorktreeContextMenuDeleteIntent(intent, onDispatched, defer) + + expect(mocks.runDelete).not.toHaveBeenCalled() + expect(onDispatched).not.toHaveBeenCalled() + expect(defer).toHaveBeenCalledOnce() + + const [deferred] = defer.mock.calls[0] + deferred() + + expect(mocks.runDelete).toHaveBeenCalledWith('repo::/work/wt', { + expectedInstanceId: 'instance-1' + }) + expect(onDispatched).toHaveBeenCalledOnce() + }) + + it('preserves every selected workspace identity for batch validation', () => { + const intent = { + kind: 'batch' as const, + worktrees: [ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'instance-2' } + ] + } + + deferWorktreeContextMenuDeleteIntent(intent, undefined, (callback) => callback()) + + expect(mocks.runBatchDelete).toHaveBeenCalledWith(intent.worktrees) + }) + + it('dispatches on the next macrotask by default', () => { + vi.useFakeTimers() + vi.stubGlobal('window', { setTimeout }) + const intent = { + kind: 'worktree' as const, + worktree: { id: 'wt-1', instanceId: 'instance-1' } + } + + deferWorktreeContextMenuDeleteIntent(intent) + + expect(mocks.runDelete).not.toHaveBeenCalled() + vi.runAllTimers() + expect(mocks.runDelete).toHaveBeenCalledWith('wt-1', { + expectedInstanceId: 'instance-1' + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts new file mode 100644 index 00000000000..7662af6d247 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts @@ -0,0 +1,38 @@ +import { useAppStore } from '@/store' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' +import type { WorktreeDeleteIdentity } from './worktree-delete-request' + +export type WorktreeContextMenuDeleteIntent = + | { kind: 'worktree'; worktree: WorktreeDeleteIdentity } + | { kind: 'batch'; worktrees: readonly WorktreeDeleteIdentity[] } + | { kind: 'folder'; folderWorkspaceId: string } + +export function runWorktreeContextMenuDeleteIntent(intent: WorktreeContextMenuDeleteIntent): void { + if (intent.kind === 'batch') { + runWorktreeBatchDelete(intent.worktrees) + return + } + if (intent.kind === 'worktree') { + runWorktreeDelete(intent.worktree.id, { expectedInstanceId: intent.worktree.instanceId }) + return + } + const state = useAppStore.getState() + void state.deleteFolderWorkspace(intent.folderWorkspaceId).then((deleted) => { + const current = useAppStore.getState() + if (deleted && current.activeWorktreeId === folderWorkspaceKey(intent.folderWorkspaceId)) { + current.setActiveWorktree(null) + } + }) +} + +export function deferWorktreeContextMenuDeleteIntent( + intent: WorktreeContextMenuDeleteIntent, + onDispatched?: () => void, + defer: (callback: () => void) => void = (callback) => window.setTimeout(callback, 0) +): void { + defer(() => { + runWorktreeContextMenuDeleteIntent(intent) + onDispatched?.() + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts new file mode 100644 index 00000000000..df556f4e5cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -0,0 +1,194 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { getWorktreeMapFromState } from '@/store/selectors' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { translate } from '@/i18n/i18n' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' +import type { Worktree } from '../../../../shared/types' +import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' +import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' +import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' +import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' + +// A failed delete usually means unresolved changes, so land on the diff panel. +function viewWorktreeDiff(worktreeId: string): void { + activateAndRevealWorktree(worktreeId) + const state = useAppStore.getState() + state.setRightSidebarTab('source-control') + state.setRightSidebarOpen(true) +} + +function isStrictDescendantPath(parentPath: string, childPath: string): boolean { + return ( + normalizeRuntimePathForComparison(parentPath) !== + normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) + ) +} + +export async function runWorktreeDeletesInParallel( + targets: readonly Pick[], + options: WorktreeDeleteWithToastOptions = {} +): Promise { + // A destructive command must run once per identity even if a refresh duplicated rows. + const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values()) + // Batch focus is committed once after every target settles. + const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId + const commitBatchFocus = activeWorktreeIdBefore + ? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore) + : null + // Mark all targets up front so the sidebar shows immediate progress. + useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id)) + // Git worktree removal shares repo locks, while separate repos can proceed in parallel. + const groups = new Map() + for (const target of uniqueTargets) { + const group = groups.get(target.repoId) + if (group) { + group.push(target) + } else { + groups.set(target.repoId, [target]) + } + } + for (const group of groups.values()) { + // Children must leave first or Git rejects their registered ancestor. + group.sort((a, b) => b.path.length - a.path.length) + } + let listChanged = false + const groupResults = await Promise.all( + Array.from(groups.values()).map(async (group) => { + const deletedInGroup: string[] = [] + const failedInGroup: (typeof group)[number][] = [] + for (const target of group) { + // A queued target may be recreated while an earlier repo sibling is deleting. + const currentTarget = getWorktreeMapFromState(useAppStore.getState()).get(target.id) + if (!currentTarget || currentTarget.instanceId !== target.instanceId) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + listChanged = true + continue + } + if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + continue + } + const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { + ...options, + focusSuccessorOnDelete: false + }) + if (deleted) { + deletedInGroup.push(target.id) + } else { + // A failed child makes deleting its ancestor unsafe because the child lives below it. + failedInGroup.push(target) + } + } + return deletedInGroup + }) + ) + if (listChanged) { + showWorkspaceListChangedToast() + } + const deletedSet = new Set(groupResults.flat()) + // Intermediate focus can spawn a terminal in another target that is still queued. + if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) { + commitBatchFocus?.() + } + return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) +} + +/** Shared confirmed and skip-confirm execution with consistent failure recovery. */ +export function runWorktreeDeleteWithToast( + worktreeId: string, + worktreeName: string, + options: WorktreeDeleteWithToastOptions = {} +): Promise { + const removeWorktree = useAppStore.getState().removeWorktree + const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) + const focusSuccessor = options.focusSuccessorOnDelete !== false + + return removeWorktree(worktreeId, options.force === true) + .then((result) => { + if (result.ok) { + if (focusSuccessor) { + commitFocus() + } + return true + } + const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + const canForceDelete = state?.canForceDelete ?? false + const hasKnownChanges = + (useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0 + showDeleteWorktreeFailureToast({ + error: result.error, + canForceDelete, + forceDeleteReason: state?.forceDeleteReason ?? null, + lockReason: state?.lockReason ?? null, + hasKnownChanges, + onViewChanges: () => viewWorktreeDiff(worktreeId), + onForceDelete: () => { + // Recapture focus because the user may have navigated while the toast was open. + const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) + // The explicit Force Delete retry may waive an unverified PTY-stop proof. + const forceRemoval = useAppStore + .getState() + .removeWorktree(worktreeId, true, { allowUnverifiedPtyStop: true }) + forceRemoval + .then((forceResult) => { + if (!forceResult.ok) { + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.4f3876c0f5', + 'Force delete failed' + ), + { + description: forceResult.error, + action: { + label: translate( + 'auto.components.sidebar.delete.worktree.flow.7488ed8711', + 'View' + ), + onClick: () => viewWorktreeDiff(worktreeId) + } + } + ) + return + } + commitForceFocus() + options.onForceDeleted?.(worktreeId) + }) + .catch((err: unknown) => { + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ), + { + description: err instanceof Error ? err.message : String(err), + action: { + label: translate( + 'auto.components.sidebar.delete.worktree.flow.7488ed8711', + 'View' + ), + onClick: () => viewWorktreeDiff(worktreeId) + } + } + ) + }) + }, + worktreeId, + worktreeName + }) + return false + }) + .catch((err: unknown) => { + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + return false + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts new file mode 100644 index 00000000000..4092c5cde51 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -0,0 +1,65 @@ +import type { Worktree } from '../../../../shared/types' + +export type WorktreeBatchDeleteOptions = { + forceConfirm?: boolean + onDeleted?: (worktreeIds: string[]) => void +} + +export type WorktreeDeleteIdentity = Pick + +export type WorktreeDeleteOptions = { + expectedInstanceId?: string +} + +export type WorktreeDeleteWithToastOptions = { + force?: boolean + onForceDeleted?: (worktreeId: string) => void + // Batch deletion commits one focus handoff after all targets settle. + focusSuccessorOnDelete?: boolean +} + +export function toWorktreeDeleteIdentities( + worktrees: readonly Pick[] +): WorktreeDeleteIdentity[] { + return worktrees.map(({ id, instanceId }) => ({ id, instanceId })) +} + +export function resolveWorktreeBatchDeleteTargets( + requestedWorktrees: readonly string[] | readonly WorktreeDeleteIdentity[], + worktreeMap: ReadonlyMap +): Worktree[] | null { + const uniqueRequests = Array.from( + new Map( + requestedWorktrees.map( + (request) => [typeof request === 'string' ? request : request.id, request] as const + ) + ).values() + ) + const targets: Worktree[] = [] + for (const request of uniqueRequests) { + const worktreeId = typeof request === 'string' ? request : request.id + const target = worktreeMap.get(worktreeId) ?? null + if (typeof request !== 'string' && (!target || target.instanceId !== request.instanceId)) { + return null + } + if (target && !target.isMainWorktree) { + targets.push(target) + } + } + return targets +} + +export function readWorktreeDeleteIdentities(value: unknown): WorktreeDeleteIdentity[] { + if (!Array.isArray(value)) { + return [] + } + return value.flatMap((entry) => { + if (!entry || typeof entry !== 'object' || !('id' in entry) || typeof entry.id !== 'string') { + return [] + } + const instanceId = 'instanceId' in entry ? entry.instanceId : undefined + return instanceId === undefined || typeof instanceId === 'string' + ? [{ id: entry.id, instanceId }] + : [] + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1aaead6e8f0..34911f6f372 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4910,7 +4910,8 @@ "ae57cbf6e4": "Failed to delete workspace", "7488ed8711": "View", "2b20ce87b3": "Force Delete", - "4f3876c0f5": "Force delete failed" + "4f3876c0f5": "Force delete failed", + "workspaceListChanged": "Workspace list changed" }, "toast": { "1d0fa5c0a5": "Failed to delete workspace {{value0}}", From fe0ab305c410a1eddc59c75abeafd280ea88b4ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:57:44 -0700 Subject: [PATCH 004/211] Avoid synchronous webview URL IPC during browser pane render (#13646) * perf(browser): avoid synchronous URL IPC during render * fix(browser): sync live URL after CDP navigation --- .../BrowserPane.render-ipc.test.ts | 19 +++++++++ .../components/browser-pane/BrowserPane.tsx | 36 ++++------------- .../browser-pane/browser-runtime.test.ts | 38 ++++++++++++++++-- .../browser-pane/browser-runtime.ts | 6 +++ .../src/hooks/ipc-events-test-harness.ts | 18 +++++++++ src/renderer/src/hooks/useIpcEvents.test.ts | 39 +++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 3 ++ 7 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts diff --git a/src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts new file mode 100644 index 00000000000..8d3253ad460 --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +describe('BrowserPagePane render IPC boundary', () => { + it('derives toolbar URLs without querying the webview', () => { + const source = readFileSync( + fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)), + 'utf8' + ) + const start = source.indexOf('const isBlankTab =', source.indexOf('function BrowserPagePane')) + const end = source.indexOf('useEffect(() => {', start) + const renderUrlDerivation = source.slice(start, end) + + expect(renderUrlDerivation).toContain('getLiveBrowserUrl(browserTab.id)') + expect(renderUrlDerivation).not.toContain('webviewRef') + expect(renderUrlDerivation).not.toContain('.getURL(') + }) +}) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 90a7e1fdc2c..c22ff6a365d 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -82,7 +82,7 @@ import { browserViewportPresetToOverride, getBrowserViewportPreset } from '../../../../shared/browser-viewport-presets' -import { rememberLiveBrowserUrl } from './browser-runtime' +import { getLiveBrowserUrl, rememberLiveBrowserUrl, seedLiveBrowserUrl } from './browser-runtime' import { ensureBrowserPageWebview } from './browser-page-webview' import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' @@ -688,35 +688,10 @@ function getRemoteBrowserDeviceScaleFactor(): number { return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) } -function getOpenableExternalUrl( - webview: Electron.WebviewTag | null, - fallbackUrl: string -): string | null { - let currentUrl = fallbackUrl - if (webview) { - try { - currentUrl = webview.getURL() || fallbackUrl - } catch { - // Why: querying nav state before dom-ready throws and blanks the whole IDE on launch; fall back to the persisted URL. - currentUrl = fallbackUrl - } - } +function getOpenableExternalUrl(currentUrl: string): string | null { return normalizeExternalBrowserUrl(redactKagiSessionToken(currentUrl)) } -function getCurrentBrowserUrl(webview: Electron.WebviewTag | null, fallbackUrl: string): string { - let currentUrl = fallbackUrl - if (webview) { - try { - currentUrl = webview.getURL() || fallbackUrl - } catch { - // Why: toolbar actions need a stable URL during early guest attach/restore; fall back to the persisted URL instead of throwing. - currentUrl = fallbackUrl - } - } - return toDisplayUrl(currentUrl) -} - function retryBrowserTabLoad( webview: Electron.WebviewTag | null, browserTab: BrowserPageState, @@ -3430,6 +3405,7 @@ function BrowserPagePane({ container = ensuredWebview.container const webview = ensuredWebview.webview const needsInitialNavigation = ensuredWebview.created + seedLiveBrowserUrl(browserTab.id, redactKagiSessionToken(browserTabUrlRef.current)) if (!ensuredWebview.created) { // pointerEvents already applied inside ensureBrowserPageWebview for the reused-webview path. @@ -4556,8 +4532,10 @@ function BrowserPagePane({ // Why: a blank tab reads as 'about:blank' or the resolved data: URL, so match both to keep the "New Browser Tab" overlay visible. const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL - const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) - const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) + // Why: synchronous webview URL access blocks render; navigation handlers update this cache before their store writes can re-render the pane. + const liveBrowserUrl = getLiveBrowserUrl(browserTab.id) ?? browserTab.url + const externalUrl = getOpenableExternalUrl(liveBrowserUrl) + const currentBrowserUrl = toDisplayUrl(liveBrowserUrl) const shareableArtifactFile = workspaceConnectionId === null ? getShareableBrowserArtifactFile(currentBrowserUrl) : null const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl diff --git a/src/renderer/src/components/browser-pane/browser-runtime.test.ts b/src/renderer/src/components/browser-pane/browser-runtime.test.ts index b225d8c3698..846b13f306d 100644 --- a/src/renderer/src/components/browser-pane/browser-runtime.test.ts +++ b/src/renderer/src/components/browser-pane/browser-runtime.test.ts @@ -1,9 +1,15 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { clearLiveBrowserUrl, getLiveBrowserUrl, rememberLiveBrowserUrl } from './browser-runtime' +import { afterEach, describe, expect, it } from 'vitest' +import { + clearLiveBrowserUrl, + getLiveBrowserUrl, + rememberLiveBrowserUrl, + seedLiveBrowserUrl +} from './browser-runtime' describe('browser runtime live URL cache', () => { - beforeEach(() => { + afterEach(() => { clearLiveBrowserUrl('page-1') + clearLiveBrowserUrl('popup-1') }) it('remembers and clears the last live URL for a browser page', () => { @@ -15,4 +21,30 @@ describe('browser runtime live URL cache', () => { expect(getLiveBrowserUrl('page-1')).toBeNull() }) + + it('seeds an initial URL without replacing a committed navigation', () => { + seedLiveBrowserUrl('page-1', 'https://initial.example/') + rememberLiveBrowserUrl('page-1', 'https://committed.example/') + seedLiveBrowserUrl('page-1', 'https://stale-persisted.example/') + + expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/') + }) + + it('retains the last committed URL when a later load fails', () => { + seedLiveBrowserUrl('page-1', 'https://initial.example/') + rememberLiveBrowserUrl('page-1', 'https://committed.example/') + // A failure event records its validated URL in loadError, not the live URL cache. + + expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/') + }) + + it('keeps popup page URLs independent and clears only the destroyed page', () => { + seedLiveBrowserUrl('page-1', 'https://opener.example/') + seedLiveBrowserUrl('popup-1', 'https://popup.example/') + + clearLiveBrowserUrl('popup-1') + + expect(getLiveBrowserUrl('page-1')).toBe('https://opener.example/') + expect(getLiveBrowserUrl('popup-1')).toBeNull() + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-runtime.ts b/src/renderer/src/components/browser-pane/browser-runtime.ts index 34ef5098010..029afc41b0f 100644 --- a/src/renderer/src/components/browser-pane/browser-runtime.ts +++ b/src/renderer/src/components/browser-pane/browser-runtime.ts @@ -1,5 +1,11 @@ const liveBrowserUrlByTabId = new Map() +export function seedLiveBrowserUrl(browserTabId: string, initialUrl: string): void { + if (!liveBrowserUrlByTabId.has(browserTabId)) { + liveBrowserUrlByTabId.set(browserTabId, initialUrl) + } +} + export function rememberLiveBrowserUrl(browserTabId: string, url: string): void { liveBrowserUrlByTabId.set(browserTabId, url) } diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 8bd4eea5fca..e2226460a7b 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -125,6 +125,7 @@ export type IpcEventsHarness = { /** Fire a main-process digit chord (zero-based index). */ jumpToWorktreeIndex: (index: number) => void jumpToTabIndex: (index: number) => void + navigationUpdate: (event: { browserPageId: string; url: string; title: string }) => void /** Standard (non-palette) target of a workspace digit chord. */ activateAndRevealWorkspace: ReturnType } @@ -146,6 +147,9 @@ export async function loadIpcEventsHarness( const activateAndRevealWorkspace = vi.fn() let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null + let navigationUpdateListener: + | ((event: { browserPageId: string; url: string; title: string }) => void) + | null = null const indexJumpListeners = new Map void>() vi.resetModules() @@ -243,6 +247,14 @@ export async function loadIpcEventsHarness( onStatus: () => () => {}, onClearDismissal: () => () => {} }, + browser: createApiNamespaceStub({ + onNavigationUpdate: ( + listener: (event: { browserPageId: string; url: string; title: string }) => void + ) => { + navigationUpdateListener = listener + return () => {} + } + }), mobile: createApiNamespaceStub({ consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false) }), @@ -270,6 +282,12 @@ export async function loadIpcEventsHarness( replyTerminalCreate, jumpToWorktreeIndex: (index) => fireIndexJump(indexJumpListeners, 'worktree', index), jumpToTabIndex: (index) => fireIndexJump(indexJumpListeners, 'tab', index), + navigationUpdate: (event) => { + if (typeof navigationUpdateListener !== 'function') { + throw new Error('Expected the browser navigation listener to be registered') + } + navigationUpdateListener(event) + }, activateAndRevealWorkspace } } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index d4bf4241392..1019ca6bad1 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -108,6 +108,45 @@ describe('getRuntimeProjectRefreshEnvironmentIds', () => { }) }) +describe('browser navigation updates', () => { + it('commits CDP navigation URLs to the render-time cache before updating the store', async () => { + let liveUrlDuringStoreWrite: string | null = null + let readLiveUrl = (_browserPageId: string): string | null => null + const setBrowserPageUrl = vi.fn((browserPageId: string) => { + liveUrlDuringStoreWrite = readLiveUrl(browserPageId) + }) + const updateBrowserPageState = vi.fn() + const storeState = createHarnessStoreState({ + tabsByWorktree: {}, + setBrowserPageUrl, + updateBrowserPageState + }) + const harness = await loadIpcEventsHarness(storeState) + const { clearLiveBrowserUrl, getLiveBrowserUrl } = + await import('@/components/browser-pane/browser-runtime') + readLiveUrl = getLiveBrowserUrl + harness.useIpcEvents() + + harness.navigationUpdate({ + browserPageId: 'page-1', + url: 'https://kagi.com/search?token=secret&q=next', + title: 'Next' + }) + + expect(liveUrlDuringStoreWrite).toBe('https://kagi.com/search?q=next') + expect(getLiveBrowserUrl('page-1')).toBe('https://kagi.com/search?q=next') + expect(setBrowserPageUrl).toHaveBeenCalledWith( + 'page-1', + 'https://kagi.com/search?token=secret&q=next' + ) + expect(updateBrowserPageState).toHaveBeenCalledWith('page-1', { + title: 'Next', + loading: false + }) + clearLiveBrowserUrl('page-1') + }) +}) + function expectWorktreeRouting(worktreeId: string): unknown { return expect.objectContaining({ worktreeId }) } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 70adba7776f..66861de31ce 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -86,6 +86,7 @@ import { setDriverForBrowserPage } from '@/lib/pane-manager/browser-mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' +import { rememberLiveBrowserUrl } from '@/components/browser-pane/browser-runtime' import { acquireBrowserAutomationVisibility, releaseBrowserAutomationVisibility @@ -156,6 +157,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { translate } from '@/i18n/i18n' +import { redactKagiSessionToken } from '../../../shared/browser-url' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { getConnectionIdFromState } from '@/lib/connection-context' @@ -2086,6 +2088,7 @@ export function useIpcEvents(): void { return } const store = useAppStore.getState() + rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) store.setBrowserPageUrl(browserPageId, url) store.updateBrowserPageState(browserPageId, { title, loading: false }) }) From b46ece212bec5359839808b4bd342ac6793c7cb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:24:45 -0700 Subject: [PATCH 005/211] perf: Stop orchestration interaction UI broadcast storms (#13651) * fix(perf): stop feature interaction UI broadcast storms * test(perf): verify feature bucket persistence --- ...re-interaction-broadcast.benchmark.test.ts | 92 +++++++++++++++++++ src/main/persistence.ts | 11 ++- 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/main/persistence-feature-interaction-broadcast.benchmark.test.ts diff --git a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts new file mode 100644 index 00000000000..4c04ab22d01 --- /dev/null +++ b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeFeatureInteractions } from '../shared/feature-interactions' +import type { PersistedState } from '../shared/types' + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +import { Store } from './persistence' + +const INTERACTIONS = 200 +const tempDirs: string[] = [] + +function createStore(name: string): { dataFile: string; store: Store } { + const dir = mkdtempSync(join(tmpdir(), `orca-ui-broadcast-${name}-`)) + tempDirs.push(dir) + const dataFile = join(dir, 'orca-data.json') + return { dataFile, store: new Store({ dataFile }) } +} + +describe('feature interaction UI broadcast benchmark', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reduces repeated interaction recording to one material UI broadcast', () => { + const { store: legacyStore } = createStore('legacy') + const { dataFile, store: optimizedStore } = createStore('optimized') + let legacyBroadcasts = 0 + let optimizedBroadcasts = 0 + legacyStore.onUIChanged(() => legacyBroadcasts++) + optimizedStore.onUIChanged(() => optimizedBroadcasts++) + + const legacyStart = performance.now() + for (let index = 0; index < INTERACTIONS; index++) { + const featureInteractions = normalizeFeatureInteractions( + legacyStore.getUI().featureInteractions + ) + const existing = featureInteractions['agent-orchestration'] + legacyStore.updateUI({ + featureInteractions: { + ...featureInteractions, + 'agent-orchestration': { + firstInteractedAt: existing?.firstInteractedAt ?? 1, + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + } + const legacyMs = performance.now() - legacyStart + + const optimizedStart = performance.now() + for (let index = 0; index < INTERACTIONS; index++) { + optimizedStore.recordFeatureInteraction('agent-orchestration') + } + const optimizedMs = performance.now() - optimizedStart + + expect(legacyBroadcasts).toBe(INTERACTIONS) + expect(optimizedBroadcasts).toBe(1) + expect( + optimizedStore.getUI().featureInteractions?.['agent-orchestration']?.interactionCount + ).toBe(INTERACTIONS) + optimizedStore.flush() + expect( + new Store({ dataFile }).getUI().featureInteractions?.['agent-orchestration']?.interactionCount + ).toBe(INTERACTIONS) + const persisted = JSON.parse(readFileSync(dataFile, 'utf8')) as PersistedState + expect(persisted.featureInteractionTelemetryBuckets?.['agent-orchestration']).toBe( + 'count_200_499' + ) + + console.log( + `[bench] ${INTERACTIONS} orchestration interactions: ` + + `full-state broadcasts ${legacyBroadcasts} -> ${optimizedBroadcasts}; ` + + `main-thread mutation ${legacyMs.toFixed(2)}ms -> ${optimizedMs.toFixed(2)}ms` + ) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c2e36d3ce9d..922a0ced183 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -5778,7 +5778,7 @@ export class Store { } } - // Why: UI view-state is written from both desktop and mobile (ui.set RPC), so notify to keep bi-directional sync (desktop hydrates UI only once). + // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { this.uiChangeListeners.add(listener) return () => { @@ -6189,7 +6189,8 @@ export class Store { (lastEmittedBucket === null || compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) - this.updateUI({ + this.state.ui = { + ...this.state.ui, featureInteractions: { ...featureInteractions, [id]: { @@ -6197,11 +6198,15 @@ export class Store { interactionCount: nextCount } } - }) + } this.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets this.scheduleSave() + // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. + if (!existing) { + this.notifyUIChanged() + } if (shouldEmit) { track('feature_interaction_usage_bucket_reached', { From 72184b75fd5b37cd5f4431ba59156a3381d1a893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=97=B2=E4=BA=BA?= <38777313+qiuyongjin@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:32:50 +0800 Subject: [PATCH 006/211] fix(settings): widen font size input so values are fully visible (#11585) * fix(settings): widen font size input so values are fully visible The number input for terminal font size was too narrow (w-14) to display two-digit values cleanly. Changed to w-24 to ensure 10-24px values fit. * fix(settings): hide the native spin buttons clipping the font size value The overlapping webkit spinner was what cut off the second digit, not the box width. Adopt the number-input-clean idiom every other numeric settings input already uses; the spinner also duplicated the -/+ steppers. * fix(settings): keep font size stepper compact --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../settings/TerminalFontSizeSetting.test.tsx | 89 +++++++++++++++++++ .../settings/TerminalFontSizeSetting.tsx | 3 +- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx new file mode 100644 index 00000000000..d5e6ecc54bd --- /dev/null +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/types' +import { TerminalFontSizeSetting } from './TerminalFontSizeSetting' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +describe('TerminalFontSizeSetting', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + function renderSetting(terminalFontSize: number, updateSettings = vi.fn()): void { + act(() => { + root.render( + + ) + }) + } + + function getInput(): HTMLInputElement { + const input = container.querySelector('input[type="number"]') + if (!input) { + throw new Error('font size input not found') + } + return input + } + + function getStepperButtons(): { decrement: HTMLButtonElement; increment: HTMLButtonElement } { + const [decrement, increment] = Array.from( + container.querySelectorAll('button') + ) + if (!decrement || !increment) { + throw new Error('stepper buttons not found') + } + return { decrement, increment } + } + + it('keeps the two-digit input compact without native spin buttons', () => { + renderSetting(15) + + expect(Array.from(getInput().classList)).toEqual( + expect.arrayContaining(['number-input-clean', 'w-14']) + ) + }) + + it('steps the font size within the supported range', () => { + const updateSettings = vi.fn() + renderSetting(15, updateSettings) + + const { decrement, increment } = getStepperButtons() + + act(() => increment.click()) + expect(updateSettings).toHaveBeenCalledWith({ terminalFontSize: 16 }) + + act(() => decrement.click()) + expect(updateSettings).toHaveBeenCalledWith({ terminalFontSize: 14 }) + }) + + it('disables the steppers at the range bounds', () => { + renderSetting(10) + expect(getStepperButtons().decrement.disabled).toBe(true) + expect(getStepperButtons().increment.disabled).toBe(false) + + renderSetting(24) + expect(getStepperButtons().decrement.disabled).toBe(false) + expect(getStepperButtons().increment.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx index 3ce31694e67..61a451c7377 100644 --- a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx @@ -45,6 +45,7 @@ export function TerminalFontSizeSetting({ > + {/* Why: native spin buttons overlap the value and duplicate the −/+ steppers. */}