From a70291a918c4781bef3e62d296af11e31b416123 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:17:52 -0700 Subject: [PATCH 01/69] perf(orchestration): index mutation receipt capacity (#13647) --- src/main/runtime/orchestration/db.ts | 58 ++---- .../mutation-receipt-capacity.test.ts | 171 ++++++++++++++++ .../mutation-receipt-capacity.ts | 79 ++++++++ ...hestration-db-retention-pagination.test.ts | 12 +- .../mutation-receipt-capacity-bench.mjs | 187 ++++++++++++++++++ 5 files changed, 455 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/orchestration/mutation-receipt-capacity.test.ts create mode 100644 src/main/runtime/orchestration/mutation-receipt-capacity.ts create mode 100644 tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 7049d59469d..259664c5544 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -57,6 +57,10 @@ import { releaseContextOnlyDispatch, type ContextOnlyDispatchReleaseResult } from './context-only-dispatch-release' +import { + ensureMutationReceiptCapacity, + migrateMutationReceiptCapacity +} from './mutation-receipt-capacity' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -261,9 +265,6 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION -const MUTATION_RECEIPT_MAX_ROWS = 10_000 -const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 - export type RunListPage = { runs: RunRow[] nextCursor: string | null @@ -281,8 +282,8 @@ type RunListCursor = { id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup. -const SCHEMA_VERSION = 25 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity. +const SCHEMA_VERSION = 26 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -983,6 +984,9 @@ export class OrchestrationDb { WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched'); `) } + if (current < 26) { + migrateMutationReceiptCapacity(this.db) + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) @@ -1398,44 +1402,6 @@ export class OrchestrationDb { // ── Durable mutation receipts ── - private ensureMutationReceiptCapacity(): void { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE state = 'completed' - AND updated_at < datetime('now', ?)` - ) - .run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) - - const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1 - if (completedToRemove > 0) { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE rowid IN ( - SELECT rowid FROM mutation_receipts - WHERE state = 'completed' - ORDER BY updated_at ASC, rowid ASC - LIMIT ? - )` - ) - .run(completedToRemove) - } - - const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) { - throw new OrchestrationError( - 'mutation_ledger_full', - 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' - ) - } - } - beginMutationReceipt(params: { callerFingerprint: string requestId: string @@ -1458,7 +1424,7 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: existing.state, row: existing } } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4009,7 +3975,7 @@ export class OrchestrationDb { `Mutation ${receipt.requestId} already has a durable acceptance record.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4642,7 +4608,7 @@ export class OrchestrationDb { `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts new file mode 100644 index 00000000000..6907d73ba81 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { MUTATION_RECEIPT_MAX_ROWS } from './mutation-receipt-capacity' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +function insertReceipts( + sqlite: Database.Database, + count: number, + state: 'pending' | 'completed' +): void { + sqlite + .prepare( + `WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ? + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), ? + FROM receipt_numbers` + ) + .run(count, state) +} + +function beginReceipt(db: OrchestrationDb, requestId: string): void { + db.beginMutationReceipt({ + callerFingerprint: 'new-caller', + requestId, + method: 'orchestration.send', + payloadHash: `hash-${requestId}` + }) +} + +describe('mutation receipt capacity schema', () => { + let db: OrchestrationDb | undefined + let secondDb: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + secondDb?.close() + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('uses the completed receipt index for age and capacity pruning', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, 10, 'completed') + + const agePlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ) + .all('-30 days') as { detail: string }[] + const capacityPlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ?` + ) + .all(64) as { detail: string }[] + const details = [...agePlan, ...capacityPlan].map((row) => row.detail).join('\n') + + expect(details).toContain('idx_mutation_receipts_completed_updated') + expect(details).not.toContain('USE TEMP B-TREE') + expect(details).not.toMatch(/SCAN mutation_receipts(?:\n|$)/) + }) + + it('migrates a populated v25 database and tracks writes from older connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + insertReceipts(sqliteFor(db), 20, 'completed') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec(` + DROP TRIGGER mutation_receipts_count_insert; + DROP TRIGGER mutation_receipts_count_delete; + DROP TABLE mutation_receipt_ledger; + DROP INDEX idx_mutation_receipts_completed_updated; + `) + oldDb.pragma('user_version = 25') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + + const olderConnection = new Database(dbPath) + olderConnection.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('old-client', 'inserted', 'orchestration.send', 'hash', 'pending'); + DELETE FROM mutation_receipts + WHERE caller_fingerprint = 'old-client' AND request_id = 'inserted'; + `) + olderConnection.close() + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + }) + + it('amortizes capacity pruning while retaining the newest replay records', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + beginReceipt(db, 'first') + const afterFirst = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + beginReceipt(db, 'second') + const afterSecond = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + + expect(afterFirst.receipt_count).toBe(MUTATION_RECEIPT_MAX_ROWS - 63) + expect(afterSecond.receipt_count).toBe(afterFirst.receipt_count + 1) + expect(db.getMutationReceipt('caller', 'request_00064')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'request_00065')).toMatchObject({ state: 'completed' }) + expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) + }) + + it('keeps the row limit exact across independent database connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-concurrency-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + secondDb = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS - 1, 'pending') + sqlite.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('caller', 'completed-slot', 'orchestration.send', 'hash', 'completed') + `) + + beginReceipt(db, 'first-connection') + expect(() => beginReceipt(secondDb!, 'second-connection')).toThrowError( + expect.objectContaining({ code: 'mutation_ledger_full' }) + ) + db.discardPendingMutationReceipt('new-caller', 'first-connection') + beginReceipt(secondDb, 'second-connection') + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: MUTATION_RECEIPT_MAX_ROWS + }) + }) +}) diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.ts new file mode 100644 index 00000000000..2ba735adad0 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.ts @@ -0,0 +1,79 @@ +import type Database from '../../sqlite/sync-database' +import { OrchestrationError } from './orchestration-error' + +export const MUTATION_RECEIPT_MAX_ROWS = 10_000 +const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 +const MUTATION_RECEIPT_PRUNE_BATCH_SIZE = 64 + +export function migrateMutationReceiptCapacity(db: Database.Database): void { + // Why: database triggers keep the count exact for concurrent connections and older binaries. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + + CREATE TABLE IF NOT EXISTS mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + receipt_count INTEGER NOT NULL CHECK(receipt_count >= 0) + ); + + INSERT INTO mutation_receipt_ledger (singleton, receipt_count) + SELECT 1, COUNT(*) FROM mutation_receipts + -- SQLite needs a WHERE to disambiguate SELECT UPSERT syntax. + WHERE true + ON CONFLICT(singleton) DO UPDATE SET receipt_count = excluded.receipt_count; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_insert + AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count + 1 + WHERE singleton = 1; + END; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_delete + AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count - 1 + WHERE singleton = 1; + END; + `) +} + +function receiptCount(db: Database.Database): number { + const row = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() as { receipt_count: number } | undefined + if (!row) { + throw new Error('Mutation receipt ledger metadata is missing.') + } + return row.receipt_count +} + +export function ensureMutationReceiptCapacity(db: Database.Database): void { + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ).run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) + + const count = receiptCount(db) + if (count >= MUTATION_RECEIPT_MAX_ROWS) { + db.prepare( + `DELETE FROM mutation_receipts + WHERE rowid IN ( + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ? + )` + ).run(count - MUTATION_RECEIPT_MAX_ROWS + MUTATION_RECEIPT_PRUNE_BATCH_SIZE) + } + + if (receiptCount(db) >= MUTATION_RECEIPT_MAX_ROWS) { + throw new OrchestrationError( + 'mutation_ledger_full', + 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' + ) + } +} diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 8cb0547ee27..0b9c5ad7097 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -74,7 +74,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' }) @@ -117,7 +117,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' }) expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined() @@ -240,7 +240,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: null, @@ -277,7 +277,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) @@ -309,7 +309,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: 'tab_creator:leaf_creator', created_by_process_incarnation: 'pty_creator:incarnation-a', @@ -328,7 +328,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') }) }) diff --git a/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs b/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs new file mode 100644 index 00000000000..dfdcfe6cf13 --- /dev/null +++ b/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { DatabaseSync } from 'node:sqlite' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' + +const ROW_LIMIT = 10_000 +const PRUNE_BATCH_SIZE = 64 + +function parseArgs(argv) { + const options = { iterations: 32, payloadBytes: 9_500 } + for (let index = 2; index < argv.length; index += 1) { + const value = argv[index + 1] + if (argv[index] === '--iterations') { + options.iterations = Number(value) + } else if (argv[index] === '--payload-bytes') { + options.payloadBytes = Number(value) + } else { + throw new Error(`Unknown argument: ${argv[index]}`) + } + index += 1 + } + if (!Number.isSafeInteger(options.iterations) || options.iterations < 1) { + throw new Error('--iterations must be a positive integer') + } + if (!Number.isSafeInteger(options.payloadBytes) || options.payloadBytes < 0) { + throw new Error('--payload-bytes must be a non-negative integer') + } + return options +} + +function createFixture(path, payloadBytes, optimized) { + const db = new DatabaseSync(path) + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE mutation_receipts ( + caller_fingerprint TEXT NOT NULL, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + payload_hash TEXT NOT NULL, + state TEXT NOT NULL, + receipt BLOB, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (caller_fingerprint, request_id) + ); + WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ${ROW_LIMIT} + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state, receipt + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), 'completed', zeroblob(${payloadBytes}) + FROM receipt_numbers; + `) + if (optimized) { + db.exec(` + CREATE INDEX idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + CREATE TABLE mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY, + receipt_count INTEGER NOT NULL + ); + INSERT INTO mutation_receipt_ledger VALUES (1, ${ROW_LIMIT}); + CREATE TRIGGER mutation_receipts_count_insert AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger SET receipt_count = receipt_count + 1; + END; + CREATE TRIGGER mutation_receipts_count_delete AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger SET receipt_count = receipt_count - 1; + END; + `) + } + db.exec('PRAGMA wal_checkpoint(TRUNCATE)') + return db +} + +function runLegacyMutation(db, iteration) { + db.exec('BEGIN IMMEDIATE') + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' AND updated_at < datetime('now', '-30 days')` + ).run() + const { count } = db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() + const completedToRemove = count - ROW_LIMIT + 1 + if (completedToRemove > 0) { + db.prepare( + `DELETE FROM mutation_receipts WHERE rowid IN ( + SELECT rowid FROM mutation_receipts WHERE state = 'completed' + ORDER BY updated_at, rowid LIMIT ? + )` + ).run(completedToRemove) + } + db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() + insertReceipt(db, iteration) + db.exec('COMMIT') +} + +function runOptimizedMutation(db, iteration) { + db.exec('BEGIN IMMEDIATE') + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' AND updated_at < datetime('now', '-30 days')` + ).run() + const { receipt_count: count } = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() + if (count >= ROW_LIMIT) { + db.prepare( + `DELETE FROM mutation_receipts WHERE rowid IN ( + SELECT rowid FROM mutation_receipts WHERE state = 'completed' + ORDER BY updated_at, rowid LIMIT ? + )` + ).run(count - ROW_LIMIT + PRUNE_BATCH_SIZE) + } + db.prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1').get() + insertReceipt(db, iteration) + db.exec('COMMIT') +} + +function insertReceipt(db, iteration) { + db.prepare( + `INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('benchmark', ?, 'orchestration.send', ?, 'pending')` + ).run(`new_${iteration}`, `new_hash_${iteration}`) +} + +function percentile(sorted, fraction) { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] +} + +function measure(db, iterations, mutation) { + const samplesMs = [] + for (let index = 0; index < iterations; index += 1) { + const startedAt = performance.now() + mutation(db, index) + samplesMs.push(performance.now() - startedAt) + } + const sorted = samplesMs.toSorted((left, right) => left - right) + return { + firstMs: samplesMs[0], + medianMs: percentile(sorted, 0.5), + p95Ms: percentile(sorted, 0.95), + maxMs: sorted.at(-1), + totalMs: samplesMs.reduce((sum, value) => sum + value, 0) + } +} + +const options = parseArgs(process.argv) +const fixtureDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-bench-')) +try { + const legacyPath = join(fixtureDir, 'legacy.db') + const optimizedPath = join(fixtureDir, 'optimized.db') + const legacyDb = createFixture(legacyPath, options.payloadBytes, false) + const optimizedDb = createFixture(optimizedPath, options.payloadBytes, true) + const databaseBytes = statSync(legacyPath).size + const legacy = measure(legacyDb, options.iterations, runLegacyMutation) + const optimized = measure(optimizedDb, options.iterations, runOptimizedMutation) + legacyDb.close() + optimizedDb.close() + + process.stdout.write( + `${JSON.stringify( + { + rows: ROW_LIMIT, + payloadBytes: options.payloadBytes, + databaseBytes, + iterations: options.iterations, + legacy, + optimized, + medianSpeedup: legacy.medianMs / optimized.medianMs, + totalSpeedup: legacy.totalMs / optimized.totalMs + }, + null, + 2 + )}\n` + ) +} finally { + rmSync(fixtureDir, { recursive: true, force: true }) +} From 2ee43bfc0d8af45fe5c6fa4f368bd95285acd76d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:34:15 -0700 Subject: [PATCH 02/69] fix(agent-hooks): refresh existing Orca launchers when agent CLIs are unavailable (#13378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-hooks): refresh existing shared hook scripts when the CLI is no longer detected A CLI that falls off PATH (moved npm prefix, relocated shim) keeps its user-wide config invoking Orca's launcher script under ~/.orca/agent-hooks, but the presence gate skips install() with no removal — freezing the script at whatever Orca generated last. Anyone in that state kept the pre-#11568 more.com-leaking .cmd forever, because no launcher script is ever deleted and Windows startup deliberately skips shell PATH hydration. Reconcile before gating: every existing shared launcher/statusline script is rewritten to the current template on each install pass. Creating scripts stays behind the presence gate — an existing file is proof of a prior install; a missing one means the gate did its job. Amp and Hermes are deliberately absent: they write provider-native plugin code with its own install lifecycle, not shared launchers. - refreshManagedScriptIfPresent() in installer-utils (no-op unless the file exists) - refreshManagedScripts() on the 11 launcher-writing services (openclaude via the shared Claude class) - reconcile pass in installManagedAgentHooks before presence detection, filtered by the agents option, best-effort per agent - coverage gate: a launcher written to ~/.orca/agent-hooks without a matching refresher entry fails the suite, in both directions * perf(agent-hooks): refresh launchers off the main thread * test(agent-hooks): keep refresh mode assertion POSIX-only --- config/tsconfig.cli.json | 1 + .../managed-agent-hook-controls.test.ts | 82 ++++++- .../managed-agent-hook-controls.ts | 20 ++ .../managed-agent-hook-registry.ts | 22 ++ ...ed-hook-script-refresh-main-thread.test.ts | 101 +++++++++ .../managed-hook-script-refresh.test.ts | 206 ++++++++++++++++++ .../managed-hook-script-refresh.ts | 90 ++++++++ src/main/antigravity/hook-service.ts | 13 ++ src/main/claude/hook-service.ts | 13 ++ src/main/codex/hook-service.ts | 5 + src/main/command-code/hook-service.ts | 5 + src/main/copilot/hook-service.ts | 5 + src/main/cursor/hook-service.ts | 5 + src/main/devin/hook-service.ts | 5 + src/main/droid/hook-service.ts | 5 + src/main/gemini/hook-service.ts | 5 + src/main/grok/hook-service.ts | 5 + src/main/kimi/hook-service.ts | 5 + 18 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts create mode 100644 src/main/agent-hooks/managed-hook-script-refresh.test.ts create mode 100644 src/main/agent-hooks/managed-hook-script-refresh.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 14295cb475a..747780e416b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,7 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", + "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index f87fd8a06dd..60004f94df0 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({ removeClaude: vi.fn(), removeCodex: vi.fn(), statusClaude: vi.fn(), - statusCodex: vi.fn() + statusCodex: vi.fn(), + refreshClaude: vi.fn(), + refreshCodex: vi.fn() })) vi.mock('./local-agent-cli-presence', () => ({ @@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({ MANAGED_AGENT_HOOK_STATUS_READERS: [ ['claude', mocks.statusClaude], ['codex', mocks.statusCodex] + ], + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [ + ['claude', mocks.refreshClaude], + ['codex', mocks.refreshCodex] ] })) @@ -51,6 +57,8 @@ describe('managed agent hook controls', () => { mocks.installCodex.mockReturnValue(status('codex', 'installed')) mocks.removeClaude.mockReturnValue(status('claude', 'not_installed')) mocks.removeCodex.mockReturnValue(status('codex', 'not_installed')) + mocks.refreshClaude.mockResolvedValue(undefined) + mocks.refreshCodex.mockResolvedValue(undefined) }) it('installs only agents with positively detected CLIs', async () => { @@ -73,6 +81,78 @@ describe('managed agent hook controls', () => { ]) }) + it('refreshes existing scripts for agents whose CLI is no longer detected', async () => { + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'found' } + }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + // Why (#11549 aftermath): the skipped agent's user-wide config still invokes the + // script, so a stale (leaking) script must not be frozen by the presence gate. + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + expect(mocks.installClaude).not.toHaveBeenCalled() + }) + + it('refreshes existing scripts even when CLI detection rejects', async () => { + mocks.detect.mockRejectedValue(new Error('detection unavailable')) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + + it('awaits each script refresh before probing for CLIs', async () => { + let releaseRefresh: (() => void) | undefined + mocks.refreshClaude.mockImplementation( + () => + new Promise((resolve) => { + releaseRefresh = resolve + }) + ) + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'missing' } + }) + + const install = installManagedAgentHooks({ agentCmdOverrides: {} }) + await Promise.resolve() + + expect(mocks.detect).not.toHaveBeenCalled() + releaseRefresh?.() + await install + expect(mocks.detect).toHaveBeenCalledTimes(1) + }) + + it('keeps installing when a script refresh throws', async () => { + mocks.refreshClaude.mockRejectedValue(new Error('disk full')) + mocks.detect.mockResolvedValue({ + claude: { state: 'found' }, + codex: { state: 'found' } + }) + + const results = await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.installClaude).toHaveBeenCalledTimes(1) + expect(mocks.installCodex).toHaveBeenCalledTimes(1) + expect(results).toEqual([ + expect.objectContaining({ agent: 'claude', state: 'installed' }), + expect.objectContaining({ agent: 'codex', state: 'installed' }) + ]) + }) + + it('only refreshes scripts for the selected agents', async () => { + mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] }) + + expect(mocks.refreshClaude).not.toHaveBeenCalled() + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + it('fails closed when CLI detection rejects', async () => { mocks.detect.mockRejectedValue(new Error('detection unavailable')) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 0e767ee72b0..0de10c6417c 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_INSTALLERS, MANAGED_AGENT_HOOK_REMOVERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, MANAGED_AGENT_HOOK_STATUS_READERS, type ManagedAgentHookInstaller } from './managed-agent-hook-registry' @@ -87,10 +88,29 @@ function runInstaller( } } +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script, but the presence gate below then skips install() forever, freezing the +// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them +// current before any gating; creating new ones remains install()'s presence-gated job. +async function refreshExistingManagedScripts(options: InstallOptions): Promise { + const allowed = options.agents ? new Set(options.agents) : null + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + if (allowed !== null && !allowed.has(agent)) { + continue + } + try { + await refresh() + } catch (error) { + console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error) + } + } +} + export async function installManagedAgentHooks( settings: ManagedHookSettings = null, options: InstallOptions = {} ): Promise { + await refreshExistingManagedScripts(options) const installers = selectedInstallers(options) const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents)) const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent)) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 1c17da381ce..4772a763b6c 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise] export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['kimi', () => kimiHookService.install()] ] +// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a +// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write +// provider-native plugin code into their own config dirs with their own install lifecycles, +// not shared launchers, so they are deliberately absent. Enforced by the coverage test in +// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding +// a refresher here fails that test. +export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [ + ['claude', () => claudeHookService.refreshManagedScripts()], + ['openclaude', () => openClaudeHookService.refreshManagedScripts()], + ['codex', () => codexHookService.refreshManagedScripts()], + ['gemini', () => geminiHookService.refreshManagedScripts()], + ['antigravity', () => antigravityHookService.refreshManagedScripts()], + ['cursor', () => cursorHookService.refreshManagedScripts()], + ['droid', () => droidHookService.refreshManagedScripts()], + ['command-code', () => commandCodeHookService.refreshManagedScripts()], + ['grok', () => grokHookService.refreshManagedScripts()], + ['copilot', () => copilotHookService.refreshManagedScripts()], + ['devin', () => devinHookService.refreshManagedScripts()], + ['kimi', () => kimiHookService.refreshManagedScripts()] +] + export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['claude', () => claudeHookService.remove()], ['openclaude', () => openClaudeHookService.remove()], diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts new file mode 100644 index 00000000000..e80011d803f --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -0,0 +1,101 @@ +import type * as NodeFsModule from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import { tmpdir } from 'node:os' +import type * as NodeOsModule from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + home: '', + syncCalls: [] as { name: string; target: string }[], + blockAsyncReads: false +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const wrapped: Record = { ...actual } + for (const [name, value] of Object.entries(actual)) { + if (!name.endsWith('Sync') || typeof value !== 'function') { + continue + } + const original = value as ((...args: unknown[]) => unknown) & Record + const recorder = (...args: unknown[]): unknown => { + state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' }) + return original(...args) + } + Object.assign(recorder, original) + wrapped[name] = recorder + } + return { ...wrapped, default: wrapped } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: actual, + readFile: (...args: Parameters) => + state.blockAsyncReads && String(args[0]).startsWith(state.home) + ? new Promise(() => {}) + : actual.readFile(...args) + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: actual, homedir: () => state.home } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } })) + +import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry' + +function syncCallsUnderHome(): string[] { + return state.syncCalls + .filter((call) => call.target.startsWith(state.home)) + .map((call) => `${call.name}(${call.target})`) +} + +describe('managed hook script refresh stays off the main thread', () => { + beforeEach(async () => { + state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-')) + state.syncCalls = [] + state.blockAsyncReads = false + }) + + afterEach(async () => { + state.blockAsyncReads = false + await rm(state.home, { recursive: true, force: true }) + }) + + it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => { + const hooksDir = join(state.home, '.orca', 'agent-hooks') + const claudeScript = join( + hooksDir, + process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' + ) + await mkdir(hooksDir, { recursive: true }) + await writeFile(claudeScript, 'stale', 'utf-8') + state.syncCalls = [] + + for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await refresh() + } + + expect(syncCallsUnderHome()).toEqual([]) + }) + + it('keeps the event loop responsive while a HOME read is stalled', async () => { + state.blockAsyncReads = true + const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]() + let settled = false + void refresh.then(() => { + settled = true + }) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + expect(settled).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts new file mode 100644 index 00000000000..799f8acf58c --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -0,0 +1,206 @@ +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script while the presence gate skips install() forever. These tests pin the +// repair — existing scripts come current, missing ones are never created. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +let isolatedUserDataDir = '' +let previousUserDataPath: string | undefined + +beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-')) + process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir +}) + +afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(isolatedUserDataDir, { recursive: true, force: true }) +}) + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh' +import { + MANAGED_AGENT_HOOK_INSTALLERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS +} from './managed-agent-hook-registry' +import { ClaudeHookService } from '../claude/hook-service' + +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +const STALE_WINDOWS_HOOK = [ + '@echo off', + 'setlocal', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin', + ':orca_agent_hook_drain_stdin', + '"%SystemRoot%\\System32\\more.com" >nul 2>nul', + 'exit /b 0', + '' +].join('\r\n') + +describe('refreshManagedScriptIfPresent', () => { + it('rewrites an existing script and refuses to create a missing one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-')) + try { + const present = join(dir, 'present.cmd') + writeFileSync(present, 'stale') + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(readFileSync(present, 'utf8')).toBe('fresh') + + if (process.platform !== 'win32') { + chmodSync(present, 0o600) + } + const fixedTime = new Date(1_000) + utimesSync(present, fixedTime, fixedTime) + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(statSync(present).mtimeMs).toBe(fixedTime.getTime()) + if (process.platform !== 'win32') { + expect(statSync(present).mode & 0o777).toBe(0o755) + } + + const missing = join(dir, 'missing.cmd') + expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false) + expect(existsSync(missing)).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('managed hook script refresh', () => { + it('brings a stale leaking script current without touching agent config', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-')) + homedirMock.mockReturnValue(home) + try { + // Why: the bug population has a script (from a past install) but no reachable + // CLI — and possibly no config dir Orca may create. Seed only the script. + const hooksDir = join(home, '.orca', 'agent-hooks') + mkdirSync(hooksDir, { recursive: true }) + writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK) + + await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts()) + + const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8') + expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0') + expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto') + // Why: refresh must not resurrect config for a CLI the user may have removed. + expect(existsSync(join(home, '.claude'))).toBe(false) + // Why: the statusline script was never installed here, so it must not appear. + expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) + + it('covers every shared launcher script with a refresher', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-')) + homedirMock.mockReturnValue(home) + const previousGrokHome = process.env.GROK_HOME + const previousKimiHome = process.env.KIMI_CODE_HOME + delete process.env.GROK_HOME + delete process.env.KIMI_CODE_HOME + try { + await withPlatform('win32', () => { + for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { + install() + } + }) + const hooksDir = join(home, '.orca', 'agent-hooks') + const files = readdirSync(hooksDir) + expect(files.length).toBeGreaterThan(0) + const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent) + // Why: an installer that writes a shared launcher but skips the refresher list would + // recreate the frozen-stale-script class this suite exists to prevent. + for (const file of files) { + expect( + refresherAgents.some((agent) => file.startsWith(`${agent}-`)), + `${file} is written to ~/.orca/agent-hooks but no refresher owns it` + ).toBe(true) + } + // Why: the reverse direction — a refresher naming an agent that writes nothing is a + // stale registry entry, likely a renamed script file. + for (const agent of refresherAgents) { + expect( + files.some((file) => file.startsWith(`${agent}-`)), + `refresher for ${agent} matches no installed script` + ).toBe(true) + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + if (previousGrokHome === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previousGrokHome + } + if (previousKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = previousKimiHome + } + rmSync(home, { recursive: true, force: true }) + } + }) + + it('creates nothing anywhere when no managed scripts exist', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-')) + homedirMock.mockReturnValue(home) + try { + await withPlatform('win32', async () => { + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined() + } + }) + // Why: a refresh pass on a machine with no prior installs must be a strict no-op. + expect(readdirSync(home)).toEqual([]) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts new file mode 100644 index 00000000000..5842a9dfbfe --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto' +import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { grantDirAclAsync, isPermissionError } from '../win32-utils' + +type ExistingScript = { exists: false } | { exists: true; content: string | null } + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingScript(scriptPath: string): Promise { + try { + return { exists: true, content: await readFile(scriptPath, 'utf-8') } + } catch (error) { + if (isMissingPathError(error)) { + return { exists: false } + } + try { + await stat(scriptPath) + return { exists: true, content: null } + } catch (statError) { + if (isMissingPathError(statError)) { + return { exists: false } + } + throw error + } + } +} + +async function scriptStillExists(scriptPath: string): Promise { + try { + await stat(scriptPath) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } +} + +async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise { + try { + await writeFile(scriptPath, content, 'utf-8') + } catch (error) { + if (isPermissionError(error) && process.platform === 'win32') { + try { + await grantDirAclAsync(dirname(scriptPath)) + await writeFile(scriptPath, content, 'utf-8') + return + } catch { + // Re-throw the original permission error. + } + } + throw error + } +} + +// Why: refresh must not block Electron's main thread or create state for an absent CLI. +export async function refreshManagedScriptIfPresent( + scriptPath: string, + content: string +): Promise { + const existing = await readExistingScript(scriptPath) + if (!existing.exists) { + return false + } + if (existing.content === content) { + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755) + } + return true + } + + const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`) + try { + await writeScriptWithAclRetry(tmpPath, content) + if (process.platform !== 'win32') { + await chmod(tmpPath, 0o755) + } + if (!(await scriptStillExists(scriptPath))) { + return false + } + await rename(tmpPath, scriptPath) + return true + } finally { + await rm(tmpPath, { force: true }).catch(() => undefined) + } +} diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index e83160a3549..535eb3a3682 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -25,6 +25,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void { } export class AntigravityHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + if (process.platform === 'win32') { + for (const event of ANTIGRAVITY_EVENTS) { + await refreshManagedScriptIfPresent( + getWindowsWrapperScriptPath(event), + getWindowsWrapperScript(event.eventName) + ) + } + } + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index ba2dfac1351..eb8891a5bf7 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -13,6 +13,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -171,6 +172,18 @@ export class ClaudeHookService { return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } } + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent( + getManagedScriptPath(this.options.settings), + getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + ) + // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. + await refreshManagedScriptIfPresent( + getStatusLineScriptPath(this.options.settings), + getManagedStatusLineScript('local') + ) + } + install(): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1c4052cefca..3bf087b14d9 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { @@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string { } export class CodexHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + private readonly wslReconciliationGeneration = new Map() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 2906ffd2da3..1e1ab9e56af 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -88,6 +89,10 @@ function buildInstalledConfig( } export class CommandCodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index 6a8206d0b59..332731fe352 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -17,6 +17,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -183,6 +184,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CopilotHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 7b5f3b90dda..63ff9b36a71 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -15,6 +15,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CursorHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 82b00a71c3c..6cff0d2b824 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -5,6 +5,7 @@ import { writeHooksJson, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeHooksJsonRemote, @@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class DevinHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getDevinConfigPath() const scriptPath = getDevinManagedScriptPath() diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 508f24e98c8..fdef56a2401 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -16,6 +16,7 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -159,6 +160,10 @@ function buildInstalledDroidConfig( } export class DroidHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 1615ea336f9..4e44dba7bb6 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -16,6 +16,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class GeminiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 51a6150eab6..1b6e8bba165 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -182,6 +183,10 @@ function buildInstalledConfig( } export class GrokHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94f5d11ea3e..8036330272a 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -18,6 +18,7 @@ import { wrapPosixHookCommand, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeManagedScriptRemote, @@ -155,6 +156,10 @@ function buildStatus(present: Set, configPath: string): AgentHookInstall } export class KimiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const text = readConfigToml(configPath) From 3d8c8c3d4c0f17f49d4a7cb8a7e5139d2de98beb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:53 -0700 Subject: [PATCH 03/69] docs: update Android APK to 0.0.42 (#13700) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 670b54e2835..b679cb95c3d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) +- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- From 2ade803522e39b86d6e54e2159d6c4e842ddfa39 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:25:26 -0700 Subject: [PATCH 04/69] [Perf-SS] Remove redundant native Ripgrep probes (#13462) --- ...ystem-list-files-git-fallback-real.test.ts | 30 +-- .../filesystem-list-files-install-rg.test.ts | 61 +++++- src/main/ipc/filesystem-list-files.test.ts | 159 +++++++++++++--- src/main/ipc/filesystem-list-files.ts | 61 ++++-- .../ipc/filesystem-search-rg-timeout.test.ts | 100 +++++++++- src/main/ipc/filesystem.ts | 60 ++++-- src/main/runtime/orca-runtime-files.test.ts | 98 +++++++++- src/main/runtime/orca-runtime-files.ts | 64 +++++-- .../fs-handler-list-files-cancel.test.ts | 1 + .../fs-handler-list-files-ignored.test.ts | 180 ++++++++++++++++++ src/relay/fs-handler-list-files.ts | 53 +++++- src/relay/fs-handler-rg-availability.test.ts | 50 ----- src/relay/fs-handler-ripgrep-fallback.test.ts | 121 ++++++++++++ src/relay/fs-handler-utils.ts | 114 ++++++----- src/relay/fs-handler.ts | 42 ++-- .../fs-list-files-cancel.integration.test.ts | 1 - .../ripgrep-process-availability.test.ts | 90 +++++++++ src/shared/ripgrep-process-availability.ts | 124 ++++++++++++ 18 files changed, 1198 insertions(+), 211 deletions(-) delete mode 100644 src/relay/fs-handler-rg-availability.test.ts create mode 100644 src/relay/fs-handler-ripgrep-fallback.test.ts create mode 100644 src/shared/ripgrep-process-availability.test.ts create mode 100644 src/shared/ripgrep-process-availability.ts diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 0cf672fa30f..69dde7afaf3 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -1,17 +1,19 @@ -import { execFile as execFileCallback } from 'node:child_process' +import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' -const { checkRgAvailableMock } = vi.hoisted(() => ({ - checkRgAvailableMock: vi.fn() +const { wslAwareSpawnMock } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn() })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock })) import { listQuickOpenFiles } from './filesystem-list-files' @@ -50,6 +52,16 @@ async function initRepo(repoPath: string): Promise { describe('filesystem-list-files real git fallback', () => { let tempDir: string | null = null + beforeEach(() => { + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { + cwd: options.cwd, + stdio: options.stdio + }) + ) + }) + afterEach(async () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }) @@ -59,7 +71,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('returns real paths for UTF-8 filenames from the git fallback', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) const repoPath = join(tempDir, 'repo') await execFile('git', ['init', '-q', repoPath]) @@ -71,7 +82,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-')) const repoPath = join(tempDir, 'parent') const appPath = join(repoPath, 'packages', 'app') @@ -117,7 +127,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('walks a non-git root instead of returning an empty git fallback result', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-')) await writeRel(tempDir, 'folder/file.ts') @@ -127,7 +136,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('bounds a non-git readdir fallback without treating the limit as an error', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) await writeRel(tempDir, 'a.ts') await writeRel(tempDir, 'b.ts') @@ -139,7 +147,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) @@ -151,7 +158,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('resolves an empty repo as an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts index 7f4c34f5eda..f0221d8e24e 100644 --- a/src/main/ipc/filesystem-list-files-install-rg.test.ts +++ b/src/main/ipc/filesystem-list-files-install-rg.test.ts @@ -1,16 +1,21 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' const { listFilesWithGitMock, resolveAuthorizedPathMock, checkRgAvailableMock, - getLocalGitOptionsForRegisteredWorktreeMock + getLocalGitOptionsForRegisteredWorktreeMock, + wslAwareSpawnMock } = vi.hoisted(() => ({ listFilesWithGitMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), - getLocalGitOptionsForRegisteredWorktreeMock: vi.fn() + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + wslAwareSpawnMock: vi.fn() })) vi.mock('./filesystem-list-files-git-fallback', () => ({ @@ -29,14 +34,39 @@ vi.mock('./local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock +})) + import { listQuickOpenFiles } from './filesystem-list-files' +function createStartedRipgrepProcess(): ChildProcess { + const child = new EventEmitter() as ChildProcess + ;(child as unknown as Record).stdout = new EventEmitter() + ;( + (child as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(child as unknown as Record).stderr = new EventEmitter() + ;(child as unknown as Record).kill = vi.fn() + ;(child as unknown as Record).exitCode = null + ;(child as unknown as Record).signalCode = null + Object.defineProperty(child, 'pid', { value: 1 }) + return child +} + describe('filesystem-list-files ripgrep guidance', () => { beforeEach(() => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(false) + checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { stdio: options.stdio }) + ) }) it('turns only a readdir budget failure into install guidance', async () => { @@ -51,6 +81,31 @@ describe('filesystem-list-files ripgrep guidance', () => { ) }) + it('keeps the WSL preflight and falls back before starting real rg', async () => { + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + await expect(listQuickOpenFiles('C:\\repo', {} as Store)).resolves.toEqual(['src/index.ts']) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const primary = createStartedRipgrepProcess() + const ignored = createStartedRipgrepProcess() + let callIndex = 0 + wslAwareSpawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + const listing = listQuickOpenFiles('/workspace', {} as Store) + await Promise.resolve() + primary.emit('close', 127, null) + + await expect(listing).resolves.toEqual(['src/index.ts']) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + it('keeps cancellation and Git errors unchanged', async () => { const cancellation = new Error('File listing cancelled') listFilesWithGitMock.mockRejectedValueOnce(cancellation) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 946f8653dd6..7debd516760 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -36,6 +36,7 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' +import { FileListingCancelledError } from '../../shared/file-listing-cancellation' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -55,10 +56,24 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } +function createMissingRipgrepProcess(): ChildProcess { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + void Promise.resolve().then(() => child.emit('close', -2, null)) + return child +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 12; index++) { + await Promise.resolve() + } +} + function isIgnoredRgPass(args: string[]): boolean { return args.includes('--no-ignore-vcs') } @@ -93,6 +108,8 @@ describe('filesystem-list-files', () => { expect(p1.kill).toHaveBeenCalled() expect(p2.kill).not.toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) it('merges normal files and ignored files and filters correctly', async () => { @@ -108,6 +125,8 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(2) // Simulate stdout output for normal files setTimeout(() => { @@ -192,6 +211,24 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) + it('does not mistake a WSL-routed executable exit 127 for missing rg', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + Object.defineProperty(p1, 'pid', { value: 1 }) + Object.defineProperty(p2, 'pid', { value: 2 }) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + + const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store) + setTimeout(() => { + p1.emit('close', 127, null) + p2.emit('close', 0, null) + }, 0) + + await expect(promise).rejects.toThrow('rg exited with code 127') + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + }) + it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -330,16 +367,90 @@ describe('filesystem-list-files', () => { expect(result).toEqual(['valid.ts']) }) - describe('git ls-files fallback', () => { - it('falls back to git ls-files when rg is not available', async () => { - checkRgAvailableMock.mockResolvedValue(false) + it('lets cancellation win a native-unavailable race before Git starts', async () => { + const first = createMockProcess() + Object.defineProperty(first, 'pid', { value: undefined }) + spawnMock.mockReturnValue(first) + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + controller.signal + ) + await flushMicrotasks() + controller.abort(cancellation) + first.emit('close', -2, null) + + await expect(promise).rejects.toBe(cancellation) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => first.emit('error', error)).not.toThrow() + expect(first.listenerCount('error')).toBe(0) + }) + + describe('git ls-files fallback', () => { + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const primary = createMockProcess() + const missingIgnored = createMockProcess() + const revParse = createMockProcess() + const gitPrimary = createMockProcess() + const gitIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + let gitPassIndex = 0 + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return isIgnoredRgPass(args) ? missingIgnored : primary + } + if (args.includes('rev-parse')) { + return revParse + } + if (args.includes('ls-files')) { + return gitPassIndex++ === 0 ? gitPrimary : gitIgnored + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + await flushMicrotasks() + expect(spawnMock.mock.calls.filter((call) => call[0] === 'rg')).toHaveLength(2) + missingIgnored.emit('close', -2, null) + + await vi.waitFor(() => expect(primary.kill).toHaveBeenCalled()) + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + await vi.waitFor(() => + expect( + spawnMock.mock.calls.some((call) => (call[1] as string[]).includes('rev-parse')) + ).toBe(true) + ) + revParse.emit('close', 0, null) + await vi.waitFor(() => + expect( + spawnMock.mock.calls.filter((call) => (call[1] as string[]).includes('ls-files')) + ).toHaveLength(2) + ) + gitPrimary.emit('close', 0, null) + gitIgnored.emit('close', 0, null) + + await expect(promise).resolves.toEqual([]) + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('falls back to git ls-files when rg is not available', async () => { let callIndex = 0 const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -378,9 +489,10 @@ describe('filesystem-list-files', () => { const result = await promise - // Verify rg was never called + // The primary real command doubles as the availability check. const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg') - expect(rgCalls.length).toBe(0) + expect(rgCalls.length).toBe(1) + expect(rgCalls.every((call) => !(call[1] as string[]).includes('--version'))).toBe(true) // Verify git ls-files was called const gitCalls = spawnMock.mock.calls.filter( @@ -402,13 +514,15 @@ describe('filesystem-list-files', () => { }) it('stops after primary Git files fill the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -439,10 +553,12 @@ describe('filesystem-list-files', () => { }) it('does not let a discarded Git directory placeholder consume the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const primary = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -469,14 +585,15 @@ describe('filesystem-list-files', () => { }) it('git fallback applies hidden dir blocklist', async () => { - checkRgAvailableMock.mockResolvedValue(false) - const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -518,7 +635,6 @@ describe('filesystem-list-files', () => { }) it('settles and detaches git fallback scans that ignore timeout kills', async () => { - checkRgAvailableMock.mockResolvedValue(false) vi.useFakeTimers() try { @@ -528,6 +644,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -541,13 +660,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') @@ -567,7 +682,6 @@ describe('filesystem-list-files', () => { }) it('keeps primary results when only the ignored pass times out', async () => { - checkRgAvailableMock.mockResolvedValue(false) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.useFakeTimers() @@ -578,6 +692,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -591,13 +708,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit( 'data', diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 7edb688de72..c1666234cd0 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -17,6 +17,12 @@ import { import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' import { listFilesWithGit } from './filesystem-list-files-git-fallback' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../../shared/ripgrep-process-availability' export async function listQuickOpenFiles( rootPath: string, @@ -37,13 +43,9 @@ export async function listQuickOpenFiles( // every worktree instead of just the active one. The shared helper // normalizes, validates, and root-relativizes every input. const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - // Why: checking rg availability upfront avoids a race condition where - // spawn('rg') emits 'close' before 'error' on some platforms, causing - // the handler to resolve with empty results before the git fallback - // can run. - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const listWithoutRipgrep = async (): Promise => { try { return await listFilesWithGit( authorizedRootPath, @@ -59,6 +61,12 @@ export async function listQuickOpenFiles( throw new Error(await buildInstallRgMessage(err)) } } + if ( + wslDistroForOutput && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { + return listWithoutRipgrep() + } const files = new Set() const children: { @@ -68,8 +76,6 @@ export async function listQuickOpenFiles( }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. - const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the // search target is cwd-relative. With an absolute target, `!packages/app` @@ -86,6 +92,8 @@ export async function listQuickOpenFiles( let buf = '' let done = false let parseablePathCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false const processLine = (rawLine: string): boolean => { const translated = @@ -138,12 +146,27 @@ export async function listQuickOpenFiles( /* drain */ } const handleError = (): void => { + processErrorObserved = true // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' + if (isRipgrepUnavailableExit(child, null, null)) { + finish(new RipgrepUnavailableError()) + return + } finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + buf = '' + finish(new RipgrepUnavailableError()) + return + } if (signal) { // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this @@ -179,6 +202,10 @@ export async function listQuickOpenFiles( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) if (err) { reject(err) } else { @@ -197,7 +224,7 @@ export async function listQuickOpenFiles( // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. buf = '' - child.kill() + killSpawnedRipgrepProcess(child) finish(new Error('rg list timed out')) }, 10000) }) @@ -213,7 +240,7 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } @@ -225,24 +252,30 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } - try { + const primaryRun = runRg(primary) if (maxResults === undefined) { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. + await (children[0]?.child.pid === undefined + ? primaryRun + : Promise.all([primaryRun, runRg(ignoredPass)])) } else { // Why: ignored-file output can be much larger and faster than the primary // pass; let source files claim the bounded autocomplete budget first. - await runRg(primary) + await primaryRun if (files.size < maxResults) { await runRg(ignoredPass) } } } catch (err) { killSurvivors() + if (err instanceof RipgrepUnavailableError) { + return listWithoutRipgrep() + } throw err } return Array.from(files).slice(0, maxResults) diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 8d9881b2261..9e0a259d2d3 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -7,6 +7,7 @@ const { resolveAuthorizedPathMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, toWindowsWslPathMock } = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const { resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), toWindowsWslPathMock: vi.fn((value: string) => value) })) @@ -56,7 +58,7 @@ vi.mock('./filesystem-mutations', () => ({ })) vi.mock('./filesystem-search-git', () => ({ - searchWithGitGrep: vi.fn() + searchWithGitGrep: searchWithGitGrepMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -87,6 +89,12 @@ function createMockProcess(): ChildProcess { return p } +async function flushMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('filesystem rg search timeout', () => { beforeEach(() => { handlers.clear() @@ -120,6 +128,7 @@ describe('filesystem rg search timeout', () => { const result = await promise expect(result.truncated).toBe(true) + expect(checkRgAvailableMock).not.toHaveBeenCalled() expect(child.kill).toHaveBeenCalled() expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) @@ -130,8 +139,77 @@ describe('filesystem rg search timeout', () => { } }) + it.each(['error-first', 'close-first'] as const)( + 'falls back once when a native launch failure is %s', + async (order) => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it('keeps post-spawn errors on the existing empty-result path', async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise<{ files: unknown[] }> + await flushMicrotasks() + child.emit('error', new Error('post-spawn failure')) + + await expect(promise).resolves.toMatchObject({ files: [] }) + expect(searchWithGitGrepMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + child.emit('close', 127, null) + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => { const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) registerFilesystemHandlers({} as never) @@ -142,12 +220,13 @@ describe('filesystem rg search timeout', () => { ) as Promise setTimeout(() => { - child.emit('close') + child.emit('close', 127, null) }, 10) await promise expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -158,6 +237,23 @@ describe('filesystem rg search timeout', () => { ) }) + it('keeps the WSL search preflight and falls back before starting real rg', async () => { + const fallback = { files: [], totalMatches: 0, truncated: false } + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'ok' } + ) as Promise + + await expect(promise).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 4ca9547b77c..d5fb98086f8 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -112,6 +112,11 @@ import { } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { getSshFilesystemProvider, requireSshFilesystemProvider @@ -980,32 +985,35 @@ export function registerFilesystemHandlers( Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - // Why: probe rg upfront; on some platforms spawn emits 'close' before 'error', resolving empty before the git-grep fallback runs. - const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const rgArgs = buildRgArgs(args.query, rootPath, args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - activeTextSearches.get(searchKey)?.kill() + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null let killTimeout: ReturnType - // Why: WSL-routed rg emits Linux paths; UNC repos carry the distro in the path, Windows-path repos in project runtime. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro const transformAbsPath = wslDistroForOutput ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1019,13 +1027,22 @@ export function registerFilesystemHandlers( child?.stderr?.off('data', handleStderrData) child?.off('error', handleError) child?.off('close', handleClose) - resolvePromise(finalize(acc)) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) const processLine = (line: string): void => { const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1049,9 +1066,24 @@ export function registerFilesystemHandlers( // Drain stderr so rg cannot block on a full pipe. } const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } resolveOnce() } - const handleClose = (): void => { + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -1067,7 +1099,9 @@ export function registerFilesystemHandlers( // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e5c8a18479e..ce96eddb5e0 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -22,6 +22,7 @@ const { closeWatcherInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, watchMock } = vi.hoisted(() => ({ @@ -35,6 +36,7 @@ const { statMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), closeWatcherInWatcherProcessMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -91,6 +93,10 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../ipc/filesystem-search-git', () => ({ + searchWithGitGrep: searchWithGitGrepMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), onSshFilesystemProviderRegistered: () => () => undefined, @@ -202,6 +208,12 @@ function createRuntimeSearchChild(): MockRuntimeSearchChild { return child } +async function flushRuntimeSearchMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -217,6 +229,7 @@ describe('RuntimeFileCommands', () => { closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() + searchWithGitGrepMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() resetSshConnectionGenerations() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() @@ -754,6 +767,68 @@ describe('RuntimeFileCommands', () => { expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + }) + + it.each(['error-first', 'close-first'] as const)( + 'falls back once when runtime rg native launch failure is %s', + async (order) => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: undefined }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + child.emit('close', 127, null) + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) }) it('routes runtime rg searches through the registered WSL project runtime', async () => { @@ -767,6 +842,7 @@ describe('RuntimeFileCommands', () => { })) const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) @@ -779,7 +855,7 @@ describe('RuntimeFileCommands', () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() - child.emit('close') + child.emit('close', 127, null) await expect(resultPromise).resolves.toMatchObject({ files: [] }) expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( @@ -788,6 +864,7 @@ describe('RuntimeFileCommands', () => { 'C:\\repo' ) expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -798,6 +875,25 @@ describe('RuntimeFileCommands', () => { ) }) + it('keeps the runtime WSL preflight and falls back before starting real rg', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const fallback = { files: [], totalMatches: 0, truncated: false } + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + + await expect( + commands.searchRuntimeFiles('id:wt-1', { query: 'needle', maxResults: 10 }) + ).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + describe('resolveTerminalPath', () => { let tempDirs: string[] = [] diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 68000f1b297..f85bff8abde 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -58,6 +58,11 @@ import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { searchWithGitGrep } from '../ipc/filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { checkRgAvailable } from '../ipc/rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths @@ -1920,26 +1925,33 @@ export class RuntimeFileCommands { 1, Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const wslInfo = parseWslPath(authorizedRootPath) + if ( + (wslInfo || localGitOptions.wslDistro) && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) - this.activeRuntimeTextSearches.get(searchKey)?.kill() + const previousChild = this.activeRuntimeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null - const wslInfo = parseWslPath(authorizedRootPath) const transformAbsPath = wslInfo ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1948,8 +1960,11 @@ export class RuntimeFileCommands { this.activeRuntimeTextSearches.delete(searchKey) } cleanupListeners() - resolvePromise(finalize(acc)) + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) let killTimeout: ReturnType | null = null const cleanupListeners = (): void => { @@ -1961,6 +1976,12 @@ export class RuntimeFileCommands { child?.stderr?.off('data', onStderrData) child?.off('error', onError) child?.off('close', onClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } } const processLine = (line: string): void => { @@ -1971,8 +1992,8 @@ export class RuntimeFileCommands { maxResults, transformAbsPath ) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1996,8 +2017,25 @@ export class RuntimeFileCommands { const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } - const onError = (): void => resolveOnce() - const onClose = (): void => { + const onError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -2011,7 +2049,9 @@ export class RuntimeFileCommands { killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 02cd8da6afc..baf6101600c 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -31,6 +31,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 1ca5c2f5327..b819d3ac85c 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -16,6 +16,11 @@ import { tmpdir } from 'node:os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { + ListFilesScanCoordinator, + LIST_FILES_SUPERSEDED_MESSAGE +} from './fs-list-files-scan-coordinator' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -36,6 +41,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } @@ -72,6 +78,7 @@ describe('relay quick open ignored file listing', () => { }) const promise = listFilesWithRg('/remote/root', ['packages/other']) + expect(spawnMock).toHaveBeenCalledTimes(2) setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') @@ -115,6 +122,82 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it.each(['error-first', 'close-first'] as const)( + 'tags a %s pre-spawn listing failure without starting the ignored pass', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValue(missing) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + + const promise = listFilesWithRg(root) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + } else { + missing.emit('close', -2, null) + } + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + if (order === 'error-first') { + missing.emit('close', -2, null) + } else { + expect(() => missing.emit('error', error)).not.toThrow() + } + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const root = await makeTempRoot() + const primary = createMockProcess() + const missingIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? missingIgnored : primary + ) + + const promise = listFilesWithRg(root) + expect(spawnMock).toHaveBeenCalledTimes(2) + missingIgnored.emit('close', -2, null) + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(primary.kill).toHaveBeenCalled() + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('does not signal failed-spawn passes when a same-client scan is superseded', async () => { + const root = await makeTempRoot() + const firstChild = createMockProcess() + Object.defineProperty(firstChild, 'pid', { value: undefined }) + spawnMock.mockReturnValue(firstChild) + const coordinator = new ListFilesScanCoordinator() + const first = coordinator.run({ + clientId: 1, + key: 'first', + start: (signal) => listFilesWithRg(root, [], { signal }) + }) + const firstOutcome = first.catch((error: unknown) => error) + + const second = coordinator.run({ + clientId: 1, + key: 'second', + start: async () => ['second.ts'] + }) + + await expect(firstOutcome).resolves.toMatchObject({ message: LIST_FILES_SUPERSEDED_MESSAGE }) + await expect(second).resolves.toEqual(['second.ts']) + expect(firstChild.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => firstChild.emit('error', error)).not.toThrow() + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -430,4 +513,101 @@ describe('relay quick open ignored file listing', () => { vi.useRealTimers() } }) + + it.each(['error-first', 'close-first'] as const)( + 'tags only a %s pre-spawn rg search failure as unavailable', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValueOnce(missing) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + missing.emit('close', -2, null) + } else { + missing.emit('close', -2, null) + expect(() => missing.emit('error', error)).not.toThrow() + } + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('tags unsupported native launcher exits as unavailable', async () => { + const root = await makeTempRoot() + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(child) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + + child.emit('close', 127, null) + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + }) + + it('keeps missing-root launch errors on their prior non-fallback paths', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const listFirst = createMockProcess() + const listProbe = createMockProcess() + Object.defineProperty(listFirst, 'pid', { value: undefined }) + Object.defineProperty(listProbe, 'pid', { value: 1 }) + let callIndex = 0 + spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++]) + const listError = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + listFirst.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + expect(spawnMock.mock.calls[1]).toEqual(['rg', ['--version'], { stdio: 'ignore' }]) + listProbe.emit('close', 0, null) + await expect(listing).rejects.toBe(listError) + + spawnMock.mockReset() + const searchChild = createMockProcess() + const searchProbe = createMockProcess() + Object.defineProperty(searchChild, 'pid', { value: undefined }) + Object.defineProperty(searchProbe, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe) + const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 }) + searchChild.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + searchProbe.emit('close', 0, null) + await expect(search).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) + + it('keeps missing-rg precedence when the root also disappeared', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const first = createMockProcess() + const probe = createMockProcess() + for (const child of [first, probe]) { + Object.defineProperty(child, 'pid', { value: undefined }) + } + let callIndex = 0 + spawnMock.mockImplementation(() => [first, probe][callIndex++]) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + first.emit('error', error) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + probe.emit('close', -2, null) + + await expect(listing).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(() => probe.emit('error', error)).not.toThrow() + }) + + it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + const started = createMockProcess() + Object.defineProperty(started, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(started) + const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) + started.emit('error', new Error('post-spawn failure')) + await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) }) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index c63a352786e..3ec7b99026d 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,7 +1,5 @@ /** * Ripgrep-based file listing for Quick Open. - * Extracted from fs-handler-utils.ts to keep it under 300 lines (oxlint max-lines). - * * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir * worktree over SSH, rg descended into every dotfile cache, hit the timeout, * and silently resolved with a partial list — Quick Open then showed "No @@ -23,6 +21,13 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../shared/quick-open-filter' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -78,6 +83,9 @@ export function listFilesWithRg( let passBuf = '' let passDone = false let passFileCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // --no-messages: permission-denied noise on the remote (e.g. .ssh, // root-owned mounts) would otherwise flood stderr. // cwd: rootPath — root-relative exclude globs like `!packages/app/**` @@ -98,6 +106,10 @@ export function listFilesWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) } const rejectPass = (error: Error): void => { if (passDone) { @@ -116,6 +128,16 @@ export function listFilesWithRg( cleanup() passResolve() } + const rejectLaunchFailure = (error: Error): void => { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then( + (unavailable) => { + rejectPass(unavailable ? new RipgrepUnavailableError() : error) + } + ) + } children.push({ child, isDone: () => passDone, @@ -125,7 +147,7 @@ export function listFilesWithRg( timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. - child.kill() + killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) @@ -149,12 +171,28 @@ export function listFilesWithRg( /* drain to prevent backpressure stalls */ } function handleError(err: Error): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + passBuf = '' + rejectLaunchFailure(err) + return + } rejectPass(err) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { if (passDone) { return } + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + passBuf = '' + rejectLaunchFailure(new Error(`rg exited with code ${code}`)) + return + } // Why signal != null is a failure: the only way spawn gets a signal // is if the process was killed (timeout, OOM, external SIGKILL). // Trusting its stdout could surface a truncated list as a success. @@ -199,7 +237,7 @@ export function listFilesWithRg( continue } if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } entry.reject(new Error(reason)) } @@ -228,12 +266,15 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) + const primaryPass = runPass(primary) const passes = maxResults === undefined - ? Promise.all([runPass(primary), runPass(ignoredPass)]) + ? children[0]?.child.pid === undefined + ? primaryPass + : Promise.all([primaryPass, runPass(ignoredPass)]) : // Why: deterministic primary-first budgeting prevents a large ignored // tree from starving ordinary source paths on a remote host. - runPass(primary).then(() => + primaryPass.then(() => files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() ) diff --git a/src/relay/fs-handler-rg-availability.test.ts b/src/relay/fs-handler-rg-availability.test.ts deleted file mode 100644 index 8b14a3d61af..00000000000 --- a/src/relay/fs-handler-rg-availability.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' - -const { execFileMock, spawnMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - execFile: execFileMock, - spawn: spawnMock -})) - -import { checkRgAvailable } from './fs-handler-utils' - -class FakeChildProcess extends EventEmitter { - kill = vi.fn() -} - -describe('relay rg availability', () => { - it('removes listeners after a successful probe', async () => { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - child.emit('close', 0) - - await expect(result).resolves.toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('settles and detaches when a wedged probe ignores timeout kill', async () => { - vi.useFakeTimers() - try { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - await vi.advanceTimersByTimeAsync(5000) - - await expect(result).resolves.toBe(false) - expect(child.kill).toHaveBeenCalledTimes(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts new file mode 100644 index 00000000000..93d89fd9008 --- /dev/null +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ChildProcessModule from 'node:child_process' +import type * as FsHandlerGitFallback from './fs-handler-git-fallback' +import type * as FsHandlerUtils from './fs-handler-utils' + +const { + execFileMock, + listFilesWithGitMock, + listFilesWithRgMock, + searchWithGitGrepMock, + searchWithRgMock +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + listFilesWithGitMock: vi.fn(), + listFilesWithRgMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), + searchWithRgMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +vi.mock('./fs-handler-utils', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithRg: listFilesWithRgMock, + searchWithRg: searchWithRgMock +})) + +vi.mock('./fs-handler-git-fallback', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithGit: listFilesWithGitMock, + searchWithGitGrep: searchWithGitGrepMock +})) + +import { FileListingCancelledError } from '../shared/file-listing-cancellation' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' + +type FsHandlerInternals = { + search(params: Record): Promise + runListFilesScan( + rootPath: string, + excludePathPrefixes: string[], + signal: AbortSignal, + maxResults?: number + ): Promise +} + +function createHandler(): FsHandlerInternals { + const dispatcher = { + onRequest: vi.fn(), + onNotification: vi.fn(), + onClientDetached: vi.fn(() => () => undefined) + } + const watcherPool = { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe: vi.fn() + } + return new FsHandler(dispatcher as never, new RelayContext(), watcherPool as never) as never +} + +describe('relay direct ripgrep admission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back only for a tagged search launch failure', async () => { + const handler = createHandler() + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + searchWithGitGrepMock.mockResolvedValueOnce(fallback) + + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).resolves.toBe(fallback) + expect(searchWithRgMock).toHaveBeenCalledTimes(1) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + + const ordinaryFailure = new Error('rg failed after spawn') + searchWithRgMock.mockRejectedValueOnce(ordinaryFailure) + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).rejects.toBe( + ordinaryFailure + ) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + + it('falls back only for a tagged listing launch failure', async () => { + const handler = createHandler() + const controller = new AbortController() + listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null) + return undefined + }) + listFilesWithGitMock.mockResolvedValueOnce(['src/index.ts']) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).resolves.toEqual([ + 'src/index.ts' + ]) + expect(listFilesWithRgMock).toHaveBeenCalledTimes(1) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + + it('lets cancellation win an unavailable-listing race before Git starts', async () => { + const handler = createHandler() + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + listFilesWithRgMock.mockImplementationOnce(async () => { + controller.abort(cancellation) + throw new RipgrepUnavailableError() + }) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).rejects.toBe( + cancellation + ) + expect(execFileMock).not.toHaveBeenCalled() + expect(listFilesWithGitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 3fcf50a6eb2..83726c686bf 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -5,7 +5,7 @@ * These functions depend only on their arguments (plus `rg` being on PATH), * so they are straightforward to test independently. */ -import { spawn, execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { open } from 'node:fs/promises' import { buildRgArgs, @@ -16,6 +16,13 @@ import { } from '../shared/text-search' import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' import type { SearchResult as SharedSearchResult } from '../shared/types' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' // ─── Constants ─────────────────────────────────────────────────────── @@ -89,11 +96,14 @@ export function searchWithRg( query: string, opts: SearchOptions ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() let buffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), // which would leak out of the `new Promise` executor and leave the @@ -124,13 +134,47 @@ export function searchWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) resolve(finalize(acc)) } + function rejectUnavailable(): void { + if (resolved) { + return + } + resolved = true + clearTimeout(killTimeout) + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + reject(new RipgrepUnavailableError()) + } + + function settleLaunchFailure(): void { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => { + if (unavailable) { + rejectUnavailable() + } else { + resolveOnce() + } + }) + } + function processLine(line: string): void { const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults) if (verdict === 'stop') { - child.kill() + killSpawnedRipgrepProcess(child) } } @@ -148,10 +192,24 @@ export function searchWithRg( } function handleError(): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + settleLaunchFailure() + return + } resolveOnce() } - function handleClose(): void { + function handleClose(code: number | null, signal: NodeJS.Signals | null): void { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + settleLaunchFailure() + return + } if (buffer) { processLine(buffer) } @@ -166,57 +224,11 @@ export function searchWithRg( killTimeout = setTimeout(() => { acc.truncated = true - child.kill() + killSpawnedRipgrepProcess(child) resolveOnce() }, SEARCH_TIMEOUT_MS) }) } -// ─── rg availability check ────────────────────────────────────────── - -// Why no cache: `rg --version` is a sub-10ms local spawn, and caching the -// result caused a footgun — a negative cache persisted across rg installs -// (forcing a relay restart), while a positive cache could mask an rg that -// was uninstalled or broken mid-session. The `settled` flag below closes -// the original race between 'error' and 'close' that the cache was added -// to paper over, so re-checking per call is both simpler and safer. -const RG_AVAILABILITY_TIMEOUT_MS = 5000 - -export function checkRgAvailable(): Promise { - return new Promise((resolve) => { - let settled = false - const child = execFile('rg', ['--version']) - let timeout: ReturnType | null = null - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - child.off('error', onError) - child.off('close', onClose) - } - const settle = (available: boolean, options?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - cleanup() - if (options?.kill) { - child.kill() - } - resolve(available) - } - const onError = (): void => settle(false) - const onClose = (code: number | null): void => settle(code === 0) - - child.once('error', onError) - child.once('close', onClose) - timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - // Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint) export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index d07e2bf397f..b7dd2dfa6b5 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -10,12 +10,7 @@ import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. import { expandTilde } from './context' -import { - DEFAULT_MAX_RESULTS, - searchWithRg, - listFilesWithRg, - checkRgAvailable -} from './fs-handler-utils' +import { DEFAULT_MAX_RESULTS, searchWithRg, listFilesWithRg } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' @@ -35,6 +30,7 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' @@ -328,26 +324,22 @@ export class FsHandler { DEFAULT_MAX_RESULTS ) - const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { - return searchWithGitGrep(rootPath, query, { - caseSensitive, - wholeWord, - useRegex, - includePattern, - excludePattern, - maxResults - }) - } - - return searchWithRg(rootPath, query, { + const options = { caseSensitive, wholeWord, useRegex, includePattern, excludePattern, maxResults - }) + } + try { + return await searchWithRg(rootPath, query, options) + } catch (error) { + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } + return searchWithGitGrep(rootPath, query, options) + } } private listFiles(params: Record, context?: RequestContext): Promise { @@ -380,10 +372,14 @@ export class FsHandler { signal: AbortSignal, maxResults?: number ): Promise { - const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) - if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + try { + return await listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + } catch (error) { + throwIfFileListingCancelled(signal) + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index 588ddb5fe51..f54ebaea369 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -54,7 +54,6 @@ vi.mock('./fs-handler-utils', async (importOriginal) => { const original = (await importOriginal()) as Record return { ...original, - checkRgAvailable: () => Promise.resolve(true), listFilesWithRg: fakeListFiles } }) diff --git a/src/shared/ripgrep-process-availability.test.ts b/src/shared/ripgrep-process-availability.test.ts new file mode 100644 index 00000000000..033df296666 --- /dev/null +++ b/src/shared/ripgrep-process-availability.test.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + isRipgrepSpawnCwdUsable, + killSpawnedRipgrepProcess +} from './ripgrep-process-availability' + +function createChild(pid: number | undefined): ChildProcess { + const child = new EventEmitter() as ChildProcess + Object.defineProperty(child, 'pid', { value: pid }) + return child +} + +describe('ripgrep process availability', () => { + it('recognizes native pre-spawn failures before either event wins the race', () => { + expect(isRipgrepUnavailableExit(createChild(undefined), null, null)).toBe(true) + expect(isRipgrepUnavailableExit(createChild(undefined), -2, null)).toBe(true) + }) + + it('keeps all post-spawn and signal exits on their existing paths', () => { + for (const code of [0, 1, 2, 127]) { + expect(isRipgrepUnavailableExit(createChild(1), code, null)).toBe(false) + } + expect(isRipgrepUnavailableExit(createChild(1), null, 'SIGTERM')).toBe(false) + }) + + it('tags only unsupported native launcher exits after spawn', () => { + for (const code of [0, 1, 2]) { + expect( + isRipgrepUnavailableExit(createChild(1), code, null, { + classifyNativeLauncherExit: true + }) + ).toBe(false) + } + for (const code of [3, 126, 127, 9009]) { + expect( + isRipgrepUnavailableExit(createChild(1), code, null, { + classifyNativeLauncherExit: true + }) + ).toBe(true) + } + }) + + it('does not signal a real failed-spawn handle', async () => { + const child = spawn('orca-definitely-missing-rg-admission-test', []) + const error = new Promise((resolve) => child.once('error', () => resolve())) + const close = new Promise((resolve) => child.once('close', () => resolve())) + + expect(killSpawnedRipgrepProcess(child)).toBe(false) + await Promise.all([error, close]) + }) + + it('distinguishes a usable root from a missing spawn cwd', async () => { + await expect(isRipgrepSpawnCwdUsable(process.cwd())).resolves.toBe(true) + await expect( + isRipgrepSpawnCwdUsable(join(process.cwd(), 'orca-definitely-missing-rg-cwd')) + ).resolves.toBe(false) + }) + + it('absorbs a queued spawn error when cleanup wins the race', () => { + const child = createChild(undefined) + absorbPendingRipgrepSpawnError(child, { + errorObserved: false, + unavailableExitObserved: false + }) + + expect(() => child.emit('error', new Error('spawn rg ENOENT'))).not.toThrow() + expect(child.listenerCount('error')).toBe(0) + }) + + it('does not retain a sink for started or already-observed processes', () => { + const started = createChild(1) + absorbPendingRipgrepSpawnError(started, { + errorObserved: false, + unavailableExitObserved: false + }) + const observed = createChild(undefined) + absorbPendingRipgrepSpawnError(observed, { + errorObserved: true, + unavailableExitObserved: true + }) + + expect(started.listenerCount('error')).toBe(0) + expect(observed.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/shared/ripgrep-process-availability.ts b/src/shared/ripgrep-process-availability.ts new file mode 100644 index 00000000000..0b5ef026c0e --- /dev/null +++ b/src/shared/ripgrep-process-availability.ts @@ -0,0 +1,124 @@ +import { constants } from 'node:fs' +import { access, stat } from 'node:fs/promises' +import { spawn, type ChildProcess } from 'node:child_process' + +const RIPGREP_CWD_CHECK_TIMEOUT_MS = 1000 +const RIPGREP_FAILURE_PROBE_TIMEOUT_MS = 5000 + +export class RipgrepUnavailableError extends Error { + constructor() { + super('ripgrep is unavailable') + this.name = 'RipgrepUnavailableError' + } +} + +function ignoreRipgrepSpawnError(): void {} + +export function killSpawnedRipgrepProcess(child: ChildProcess): boolean { + // Why: killing a failed-spawn handle can signal the relay's own process group. + if (Object.hasOwn(child, 'pid') && child.pid === undefined) { + return false + } + return child.kill() +} + +export function absorbPendingRipgrepSpawnError( + child: ChildProcess, + state: { errorObserved: boolean; unavailableExitObserved: boolean } +): void { + if ( + state.errorObserved || + (!state.unavailableExitObserved && !(Object.hasOwn(child, 'pid') && child.pid === undefined)) + ) { + return + } + // Why: concurrent-pass cleanup can win before Node delivers the queued spawn error. + child.once('error', ignoreRipgrepSpawnError) +} + +export async function isRipgrepSpawnCwdUsable(cwd: string): Promise { + let timeout: ReturnType | null = null + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), RIPGREP_CWD_CHECK_TIMEOUT_MS) + timeout.unref?.() + }) + const checked = Promise.all([stat(cwd), access(cwd, constants.X_OK)]).then( + ([entry]) => entry.isDirectory(), + () => false + ) + try { + return await Promise.race([checked, timedOut]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function checkRipgrepAvailableWithoutCwd(): Promise { + return new Promise((resolve) => { + let child: ChildProcess + try { + child = spawn('rg', ['--version'], { stdio: 'ignore' }) + } catch { + resolve(false) + return + } + let settled = false + let errorObserved = false + let unavailableExitObserved = false + let timeout: ReturnType | null = null + const settle = (available: boolean, kill = false): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + child.off('error', onError) + child.off('close', onClose) + if (kill) { + child.once('error', ignoreRipgrepSpawnError) + killSpawnedRipgrepProcess(child) + } else { + absorbPendingRipgrepSpawnError(child, { errorObserved, unavailableExitObserved }) + } + resolve(available) + } + const onError = (): void => { + errorObserved = true + settle(false) + } + const onClose = (code: number | null): void => { + unavailableExitObserved = code !== null && code < 0 + settle(code === 0) + } + child.once('error', onError) + child.once('close', onClose) + timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS) + timeout.unref?.() + }) +} + +export async function isRipgrepUnavailableAfterLaunchFailure(cwd: string): Promise { + if (await isRipgrepSpawnCwdUsable(cwd)) { + return true + } + return !(await checkRipgrepAvailableWithoutCwd()) +} + +export function isRipgrepUnavailableExit( + child: ChildProcess, + code: number | null, + signal: NodeJS.Signals | null, + options: { classifyNativeLauncherExit?: boolean } = {} +): boolean { + if (signal) { + return false + } + if ((Object.hasOwn(child, 'pid') && child.pid === undefined) || (code !== null && code < 0)) { + return true + } + return Boolean(options.classifyNativeLauncherExit && code !== null && code > 2) +} From 119b1e1c53bf6cf955778293e3aec46ab9a070d4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:27:56 -0700 Subject: [PATCH 05/69] fix(release): reject non-release publications (#13591) * fix(release): quarantine unauthorized publications * fix(release): remove unauthorized publication tags * fix(release): require canonical version tags * refactor(release): narrow policy to one workflow * fix(release): reassert latest stable release --- .github/workflows/release-policy.yml | 98 ++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/release-policy.yml diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml new file mode 100644 index 00000000000..8014145939b --- /dev/null +++ b/.github/workflows/release-policy.yml @@ -0,0 +1,98 @@ +name: Release Policy + +on: + release: + types: + - published + - edited + +permissions: + contents: write + +concurrency: + group: release-policy + cancel-in-progress: false + +jobs: + enforce: + if: github.repository == 'stablyai/orca' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Enforce release policy + uses: actions/github-script@v8 + with: + script: | + const release = context.payload.release; + const tag = release.tag_name; + const author = release.author?.login; + const number = "(?:0|[1-9][0-9]*)"; + const version = `${number}\\.${number}\\.${number}`; + const stableTag = new RegExp(`^v${version}$`); + const prereleaseTag = new RegExp( + `^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`, + ); + const expectedPrerelease = prereleaseTag.test(tag); + const allowed = author === "github-actions[bot]" && + (stableTag.test(tag) || expectedPrerelease); + const { owner, repo } = context.repo; + + async function restoreLatestStable() { + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const stable = releases + .filter((candidate) => + !candidate.draft && + !candidate.prerelease && + candidate.author?.login === "github-actions[bot]" && + stableTag.test(candidate.tag_name), + ) + .sort((left, right) => { + const a = left.tag_name.slice(1).split(".").map(Number); + const b = right.tag_name.slice(1).split(".").map(Number); + return a.reduce((result, part, index) => result || part - b[index], 0); + }) + .at(-1); + if (stable) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: stable.id, + make_latest: "true", + }); + } + } + + if (allowed) { + if (release.prerelease !== expectedPrerelease) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + prerelease: expectedPrerelease, + make_latest: expectedPrerelease ? "false" : "legacy", + }); + } + await restoreLatestStable(); + return; + } + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: true, + prerelease: true, + make_latest: "false", + }); + await restoreLatestStable(); + await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); + try { + await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); + } catch (error) { + if (error.status !== 404) throw error; + } + core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`); From 1c1107fc9b1620fc2cd7e5accc9d9907e10a4c3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:33:35 +0000 Subject: [PATCH 06/69] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7414e4cea7a..a3074230367 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 17m + + downloads: 18m @@ -15,7 +15,7 @@ downloads downloads - 17m - 17m + 18m + 18m From 090678a32b3fd4651ef7bc6bf07afa839cdf12c5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:33:46 -0700 Subject: [PATCH 07/69] [Perf-SS] Keep reattach live queues linear during replay (#13478) --- .../deferred-reattach-live-data-queue.test.ts | 336 ++++++++++++++++++ .../deferred-reattach-live-data-queue.ts | 111 ++++++ .../terminal-pane/pty-connection.ts | 73 +--- tests/e2e/ssh-docker-relay-perf.spec.ts | 27 +- 4 files changed, 482 insertions(+), 65 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts create mode 100644 src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts diff --git a/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts new file mode 100644 index 00000000000..539348edf8b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DeferredReattachLiveDataQueue, + MAX_DEFERRED_REATTACH_LIVE_CHARS, + MAX_DEFERRED_REATTACH_LIVE_CHUNKS, + type DeferredReattachLiveDataChunk +} from './deferred-reattach-live-data-queue' + +function createChunk( + data: string, + streamGeneration = 1, + options: Omit = { + ptyId: 'pty-1' + } +): DeferredReattachLiveDataChunk { + return { data, streamGeneration, ...options } +} + +function releaseTransferred(chunks: DeferredReattachLiveDataChunk[]): void { + for (const chunk of chunks) { + chunk.ackCredit?.() + } +} + +class ReferenceDeferredQueue { + private chunks: DeferredReattachLiveDataChunk[] = [] + private retainedChars = 0 + + enqueue(chunk: DeferredReattachLiveDataChunk): void { + this.chunks = this.chunks.filter((queued) => { + const keep = queued.streamGeneration === chunk.streamGeneration + if (!keep) { + queued.ackCredit?.() + } + return keep + }) + this.retainedChars = this.chunks.reduce((total, queued) => total + queued.data.length, 0) + const oversized = chunk.data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + this.chunks.push({ + ...chunk, + data: oversized ? chunk.data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : chunk.data + }) + this.retainedChars += this.chunks.at(-1)?.data.length ?? 0 + + let dropped = oversized + while ( + this.chunks.length > 1 && + (this.chunks.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + this.retainedChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) + ) { + const removed = this.chunks.shift() + this.retainedChars -= removed?.data.length ?? 0 + removed?.ackCredit?.() + dropped = true + } + if (dropped && this.chunks[0]) { + this.chunks[0].meta = { ...this.chunks[0].meta, droppedOutput: true } + } + } + + takeAll(): DeferredReattachLiveDataChunk[] { + const chunks = this.chunks + this.chunks = [] + this.retainedChars = 0 + return chunks + } + + discard(): void { + for (const chunk of this.takeAll()) { + chunk.ackCredit?.() + } + } +} + +function normalizedChunks(chunks: DeferredReattachLiveDataChunk[]): unknown[] { + return chunks.map(({ ackCredit: _ackCredit, ...chunk }) => chunk) +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } +} + +describe('deferred reattach live data queue', () => { + it('does not rescan or shift the live queue while saturated', () => { + const originalFilter = Array.prototype.filter + const originalReduce = Array.prototype.reduce + const originalShift = Array.prototype.shift + const queue = new DeferredReattachLiveDataQueue() + + try { + for (const method of ['filter', 'reduce', 'shift'] as const) { + Object.defineProperty(Array.prototype, method, { + configurable: true, + writable: true, + value() { + throw new Error(`Array.${method} should not run on enqueue`) + } + }) + } + for (let index = 0; index < MAX_DEFERRED_REATTACH_LIVE_CHUNKS * 2; index += 1) { + queue.enqueue(createChunk('x'.repeat(512))) + } + } finally { + Object.defineProperties(Array.prototype, { + filter: { configurable: true, writable: true, value: originalFilter }, + reduce: { configurable: true, writable: true, value: originalReduce }, + shift: { configurable: true, writable: true, value: originalShift } + }) + } + + expect(queue.takeAll()).toHaveLength(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + }) + + it('evicts whole chunks only after the count and character boundaries are exceeded', () => { + const countQueue = new DeferredReattachLiveDataQueue() + const countAcks = Array.from({ length: MAX_DEFERRED_REATTACH_LIVE_CHUNKS + 1 }, () => vi.fn()) + for (let index = 0; index < MAX_DEFERRED_REATTACH_LIVE_CHUNKS; index += 1) { + countQueue.enqueue( + createChunk('', 1, { ptyId: 'pty-1', meta: { seq: index }, ackCredit: countAcks[index] }) + ) + } + + expect(countQueue.getStorageForTest().retainedChunks).toBe(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + expect(countAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + countQueue.enqueue( + createChunk('', 1, { + ptyId: 'pty-1', + meta: { seq: MAX_DEFERRED_REATTACH_LIVE_CHUNKS }, + ackCredit: countAcks.at(-1) + }) + ) + + const counted = countQueue.takeAll() + expect(countAcks[0]).toHaveBeenCalledOnce() + expect(counted).toHaveLength(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + expect(counted[0]?.meta).toEqual({ seq: 1, droppedOutput: true }) + expect(counted.at(-1)?.meta?.seq).toBe(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + releaseTransferred(counted) + expect(countAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + + const charQueue = new DeferredReattachLiveDataQueue() + const fullAck = vi.fn() + const tailAck = vi.fn() + charQueue.enqueue( + createChunk('a'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS), 1, { + ptyId: 'pty-1', + ackCredit: fullAck + }) + ) + expect(charQueue.getStorageForTest().retainedChars).toBe(MAX_DEFERRED_REATTACH_LIVE_CHARS) + charQueue.enqueue(createChunk('tail', 1, { ptyId: 'pty-1', ackCredit: tailAck })) + + const characterBounded = charQueue.takeAll() + expect(fullAck).toHaveBeenCalledOnce() + expect(characterBounded.map((chunk) => chunk.data)).toEqual(['tail']) + expect(characterBounded[0]?.meta).toEqual({ droppedOutput: true }) + expect(tailAck).not.toHaveBeenCalled() + releaseTransferred(characterBounded) + expect(tailAck).toHaveBeenCalledOnce() + }) + + it('keeps the exact oversized tail and preserves metadata under the gap marker', () => { + const queue = new DeferredReattachLiveDataQueue() + const ackCredit = vi.fn() + const meta = { seq: 9, rawLength: MAX_DEFERRED_REATTACH_LIVE_CHARS + 1, background: true } + queue.enqueue( + createChunk(`P${'T'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS)}`, 3, { + ptyId: 'pty-3', + meta, + ackCredit + }) + ) + + const retained = queue.takeAll() + expect(retained).toHaveLength(1) + expect(retained[0]?.data).toBe('T'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS)) + expect(retained[0]?.meta).toEqual({ ...meta, droppedOutput: true }) + expect(meta).not.toHaveProperty('droppedOutput') + expect(ackCredit).not.toHaveBeenCalled() + releaseTransferred(retained) + expect(ackCredit).toHaveBeenCalledOnce() + }) + + it('releases a replaced generation exactly once without carrying its gap marker forward', () => { + const queue = new DeferredReattachLiveDataQueue() + const firstAck = vi.fn() + const secondAck = vi.fn() + const thirdAck = vi.fn() + const replacementAck = vi.fn() + queue.enqueue(createChunk('a'.repeat(300 * 1024), 4, { ptyId: 'old', ackCredit: firstAck })) + queue.enqueue(createChunk('b'.repeat(300 * 1024), 4, { ptyId: 'old', ackCredit: secondAck })) + queue.enqueue(createChunk('c', 4, { ptyId: 'old', ackCredit: thirdAck })) + + expect(firstAck).toHaveBeenCalledOnce() + queue.enqueue( + createChunk('replacement', 5, { + ptyId: 'new', + meta: { seq: 5, transformed: true }, + ackCredit: replacementAck + }) + ) + + const replacement = queue.takeAll() + expect(firstAck).toHaveBeenCalledOnce() + expect(secondAck).toHaveBeenCalledOnce() + expect(thirdAck).toHaveBeenCalledTimes(1) + expect(replacement).toHaveLength(1) + expect(replacement[0]?.data).toBe('replacement') + expect(replacement[0]?.meta).toEqual({ seq: 5, transformed: true }) + expect(replacementAck).not.toHaveBeenCalled() + releaseTransferred(replacement) + expect(replacementAck).toHaveBeenCalledOnce() + }) + + it('compacts a cleared prefix repeatedly while settling every credit once', () => { + const queue = new DeferredReattachLiveDataQueue() + const acks = Array.from({ length: 513 }, () => vi.fn()) + const fullChunk = 'x'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS) + for (let index = 0; index < acks.length; index += 1) { + queue.enqueue( + createChunk(fullChunk, 1, { + ptyId: 'pty-1', + ackCredit: acks[index] + }) + ) + const storage = queue.getStorageForTest() + expect(storage.retainedChunks).toBe(1) + expect(storage.retainedChars).toBe(MAX_DEFERRED_REATTACH_LIVE_CHARS) + expect(storage.backingLength).toBeLessThanOrEqual(64) + } + + const retained = queue.takeAll() + expect(retained).toHaveLength(1) + releaseTransferred(retained) + expect(acks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + expect(queue.getStorageForTest()).toEqual({ + backingLength: 0, + head: 0, + retainedChars: 0, + retainedChunks: 0 + }) + }) + + it('transfers credits from takeAll but settles retained credits on discard', () => { + const queue = new DeferredReattachLiveDataQueue() + const transferredAcks = [vi.fn(), vi.fn()] + queue.enqueue(createChunk('first', 1, { ptyId: 'pty-1', ackCredit: transferredAcks[0] })) + queue.enqueue(createChunk('second', 1, { ptyId: 'pty-1', ackCredit: transferredAcks[1] })) + + const transferred = queue.takeAll() + expect(transferred.map((chunk) => chunk.data)).toEqual(['first', 'second']) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + + const discardedAcks = [vi.fn(), vi.fn()] + queue.enqueue(createChunk('third', 2, { ptyId: 'pty-2', ackCredit: discardedAcks[0] })) + queue.enqueue(createChunk('fourth', 2, { ptyId: 'pty-2', ackCredit: discardedAcks[1] })) + queue.discard() + queue.discard() + expect(discardedAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + releaseTransferred(transferred) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + }) + + it('matches the previous algorithm across a seeded adversarial stream', () => { + const queue = new DeferredReattachLiveDataQueue() + const reference = new ReferenceDeferredQueue() + const random = createSeededRandom(0x5eedc0de) + const queueAcks = new Map() + const referenceAcks = new Map() + let generation = 1 + + const recordAck = + (acks: Map, index: number): (() => void) => + () => { + acks.set(index, (acks.get(index) ?? 0) + 1) + } + for (let index = 0; index < 5_000; index += 1) { + const value = random() + if (index > 0 && value % 997 === 0) { + generation += 1 + } + const bucket = value % 100 + const length = + index < 1_500 + ? value % 3 + : bucket < 70 + ? value % 1_025 + : bucket < 90 + ? 4 * 1024 + : bucket < 97 + ? 64 * 1024 + : bucket < 99 + ? 300 * 1024 + : MAX_DEFERRED_REATTACH_LIVE_CHARS + 1 + const data = String.fromCharCode(65 + (index % 26)).repeat(length) + const base = { + data, + ptyId: `pty-${generation % 3}`, + streamGeneration: generation, + meta: { + seq: index, + rawLength: length, + ...(value % 7 === 0 ? { background: true } : {}) + } + } + queue.enqueue({ ...base, ackCredit: recordAck(queueAcks, index) }) + reference.enqueue({ ...base, ackCredit: recordAck(referenceAcks, index) }) + + if ((index + 1) % 1_000 !== 0) { + continue + } + if ((index + 1) % 2_000 === 0) { + queue.discard() + reference.discard() + } else { + const actual = queue.takeAll() + const expected = reference.takeAll() + expect(normalizedChunks(actual)).toEqual(normalizedChunks(expected)) + releaseTransferred(actual) + releaseTransferred(expected) + } + expect(queueAcks).toEqual(referenceAcks) + } + + expect(queueAcks).toEqual(referenceAcks) + expect([...queueAcks.values()].every((count) => count === 1)).toBe(true) + expect(queueAcks.size).toBe(5_000) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts new file mode 100644 index 00000000000..3045569b1e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts @@ -0,0 +1,111 @@ +import type { PtyDataMeta } from './pty-dispatcher' + +export const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 +export const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 + +const COMPACTION_MIN_HEAD = 64 + +export type DeferredReattachLiveDataChunk = { + data: string + ptyId: string | null + streamGeneration: number + meta?: PtyDataMeta + ackCredit?: () => void +} + +export type DeferredReattachLiveDataQueueStorage = { + backingLength: number + head: number + retainedChars: number + retainedChunks: number +} + +export class DeferredReattachLiveDataQueue { + private chunks: (DeferredReattachLiveDataChunk | undefined)[] = [] + private head = 0 + private retainedChars = 0 + private streamGeneration: number | null = null + + enqueue(chunk: DeferredReattachLiveDataChunk): void { + if (this.streamGeneration !== null && this.streamGeneration !== chunk.streamGeneration) { + this.discard() + } + this.streamGeneration = chunk.streamGeneration + + const oversized = chunk.data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const queuedChunk = { + ...chunk, + data: oversized ? chunk.data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : chunk.data + } + this.chunks.push(queuedChunk) + this.retainedChars += queuedChunk.data.length + + let dropped = oversized + while ( + this.retainedChunkCount > 1 && + (this.retainedChunkCount > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + this.retainedChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) + ) { + const removed = this.chunks[this.head] + this.chunks[this.head] = undefined + this.head += 1 + if (removed) { + this.retainedChars -= removed.data.length + removed.ackCredit?.() + } + dropped = true + } + + const oldest = this.chunks[this.head] + if (dropped && oldest) { + oldest.meta = { ...oldest.meta, droppedOutput: true } + } + this.compact() + } + + takeAll(): DeferredReattachLiveDataChunk[] { + const retained: DeferredReattachLiveDataChunk[] = [] + for (let index = this.head; index < this.chunks.length; index += 1) { + const chunk = this.chunks[index] + if (chunk) { + retained.push(chunk) + } + } + this.reset() + return retained + } + + discard(): void { + for (const chunk of this.takeAll()) { + chunk.ackCredit?.() + } + } + + getStorageForTest(): DeferredReattachLiveDataQueueStorage { + return { + backingLength: this.chunks.length, + head: this.head, + retainedChars: this.retainedChars, + retainedChunks: this.retainedChunkCount + } + } + + private get retainedChunkCount(): number { + return this.chunks.length - this.head + } + + private compact(): void { + if (this.head < COMPACTION_MIN_HEAD || this.head * 2 < this.chunks.length) { + return + } + this.chunks = this.chunks.slice(this.head) + this.head = 0 + } + + private reset(): void { + this.chunks = [] + this.head = 0 + this.retainedChars = 0 + this.streamGeneration = null + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index c48b0b29a91..f7a266eb90d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -236,6 +236,10 @@ import { createCommandCodeOutputStatusDetector } from '../../../../shared/comman import { createCodexBackfillErrorDetector } from './codex-backfill-error-detector' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' +import { + DeferredReattachLiveDataQueue, + MAX_DEFERRED_REATTACH_LIVE_CHARS +} from './deferred-reattach-live-data-queue' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { CONPTY_DA1_RESPONSE, @@ -3744,21 +3748,10 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: - | { - data: string - ptyId: string | null - streamGeneration: number - meta?: PtyDataMeta - ackCredit?: () => void - }[] - | null = null - let deferredReattachLiveDataChars = 0 + let deferredReattachLiveData: DeferredReattachLiveDataQueue | null = null let reattachLiveDataDeferralDepth = 0 let deferredReattachLiveDataOwners = new Map() let transportStreamGeneration = 0 - const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 - const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { lastTerminalInputAt = performance.now() // Why: input must probe a wedged xterm even when the PTY produces no renderer output. @@ -7615,47 +7608,14 @@ export function connectPanePty( return } if (deferredReattachLiveData !== null) { - // Why: a replacement stream must not inherit bytes or a gap marker from the replay owner it superseded. - deferredReattachLiveData = deferredReattachLiveData.filter((chunk) => { - const keep = chunk.streamGeneration === streamGeneration - if (!keep) { - chunk.ackCredit?.() - } - return keep - }) - deferredReattachLiveDataChars = deferredReattachLiveData.reduce( - (total, chunk) => total + chunk.data.length, - 0 - ) - const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS - const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data const ackCredit = takeCurrentTerminalDeliveryCredit() - deferredReattachLiveData.push({ - data: deferredData, + deferredReattachLiveData.enqueue({ + data, ptyId: transport.getPtyId(), streamGeneration, ...(meta ? { meta } : {}), ...(ackCredit ? { ackCredit } : {}) }) - deferredReattachLiveDataChars += deferredData.length - // Why: one huge IPC frame would bypass the queue's memory bound; mark a stream gap so snapshot recovery replaces it, not a partial ANSI frame. - let dropped = oversized - while ( - deferredReattachLiveData.length > 1 && - (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) - ) { - const removed = deferredReattachLiveData.shift() - deferredReattachLiveDataChars -= removed?.data.length ?? 0 - removed?.ackCredit?.() - dropped = true - } - if (dropped && deferredReattachLiveData[0]) { - deferredReattachLiveData[0].meta = { - ...deferredReattachLiveData[0].meta, - droppedOutput: true - } - } return } if (data.length > 0) { @@ -7820,8 +7780,7 @@ export function connectPanePty( const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { reattachLiveDataDeferralDepth += 1 if (reattachLiveDataDeferralDepth === 1) { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + deferredReattachLiveData = new DeferredReattachLiveDataQueue() deferredReattachLiveDataOwners = new Map() } if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { @@ -7854,19 +7813,17 @@ export function connectPanePty( if (reattachLiveDataDeferralDepth > 0) { return } - const chunks = deferredReattachLiveData + const queue = deferredReattachLiveData deferredReattachLiveData = null - deferredReattachLiveDataChars = 0 const currentPtyId = transport.getPtyId() const currentGeneration = transportStreamGeneration const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) deferredReattachLiveDataOwners = new Map() - if (disposed || !chunks) { - for (const chunk of chunks ?? []) { - chunk.ackCredit?.() - } + if (disposed || !queue) { + queue?.discard() return } + const chunks = queue.takeAll() // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. let deliveredDeferredChunks = 0 for (const chunk of chunks) { @@ -9258,11 +9215,9 @@ export function connectPanePty( } directSshPaneRetrySettlementTimers.clear() // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. - for (const chunk of deferredReattachLiveData ?? []) { - chunk.ackCredit?.() - } + const queue = deferredReattachLiveData deferredReattachLiveData = null - deferredReattachLiveDataChars = 0 + queue?.discard() reattachLiveDataDeferralDepth = 0 deferredReattachLiveDataOwners = new Map() cancelPendingSafeFitContinuations(pane) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index 775f7cc5f62..fe39cfef709 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -50,6 +50,11 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } +function encodedRemoteNodeCommand(script: string): string { + const encoded = Buffer.from(script, 'utf8').toString('base64') + return `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}` +} + function remoteTypingLoadScript(runId: string): string { return [ "process.stdin.setEncoding('utf8')", @@ -368,7 +373,9 @@ test.describe('Docker SSH relay perf', () => { await waitForActiveTerminalManager(orcaPage, 60_000) const beforePtyId = await waitForActivePanePtyId(orcaPage, 60_000) const beforeMarker = `SSH_RECONNECT_BEFORE_${Date.now()}` - await execInTerminal(orcaPage, beforePtyId, `printf ${shellQuote(beforeMarker)}`) + const beforeCommand = encodedRemoteNodeCommand(`process.stdout.write('${beforeMarker}\\n')`) + expect(beforeCommand).not.toContain(beforeMarker) + await execInTerminal(orcaPage, beforePtyId, beforeCommand) await waitForTerminalOutput(orcaPage, beforeMarker, 20_000, 60_000) const recoveryStartedMarker = `SSH_RECONNECT_RECOVERY_STARTED_${Date.now()}` const recoveryMarker = `SSH_RECONNECT_RECOVERY_${Date.now()}` @@ -382,7 +389,10 @@ test.describe('Docker SSH relay perf', () => { `if (frame === 256) { clearInterval(timer); process.stdout.write('${recoveryMarker}\\n') }`, '}, 10)' ].join(';') - await execInTerminal(orcaPage, beforePtyId, `node -e ${shellQuote(recoveryScript)}`) + const recoveryCommand = encodedRemoteNodeCommand(recoveryScript) + expect(recoveryCommand).not.toContain(recoveryStartedMarker) + expect(recoveryCommand).not.toContain(recoveryMarker) + await execInTerminal(orcaPage, beforePtyId, recoveryCommand) await waitForTerminalOutput(orcaPage, recoveryStartedMarker, 30_000, 80_000) await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) @@ -392,11 +402,16 @@ test.describe('Docker SSH relay perf', () => { await waitForTerminalOutput(orcaPage, recoveryMarker, 30_000, 80_000) const afterMarker = `SSH_RECONNECT_AFTER_${Date.now()}` const remoteProofPath = `/tmp/${afterMarker}` - await execInTerminal( - orcaPage, - afterPtyId, - `printf ${shellQuote(afterMarker)} | tee ${shellQuote(remoteProofPath)}` + const afterCommand = encodedRemoteNodeCommand( + [ + "const fs = require('node:fs')", + `const marker = '${afterMarker}'`, + `fs.writeFileSync('${remoteProofPath}', marker)`, + "process.stdout.write(marker + '\\n')" + ].join(';') ) + expect(afterCommand).not.toContain(afterMarker) + await execInTerminal(orcaPage, afterPtyId, afterCommand) await waitForTerminalOutput(orcaPage, afterMarker, 20_000, 60_000) expect(execDockerSshRelayTargetCommand(target, `cat ${shellQuote(remoteProofPath)}`)).toBe( afterMarker From 20848169e0cd9c2c7f063e7e9f454efce39096a7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:37:49 -0700 Subject: [PATCH 08/69] Improve automations page UI: full-page detail and table redesign (#13292) * improve automations page ui * Improve automations page UI: fix menu interactions, extract components, - Extract status cell to reusable component - Fix portaled context menu clicks re-selecting rows - Use explicit selection IDs instead of resolved objects for reliable detail state - Close detail when selection is lost during refresh or deletion - Improve escape key navigation through nested detail views - Use native input for search field to avoid unwanted shadow elevation - Add i18n keys for table and button labels * fix: merge duplicate type imports in AutomationListLocalRows test Static analysis fails under --deny-warnings when the same module is imported twice; combine the Automation and external types into one import. * Fix row keyboard activation to not intercept nested controls Row handlers for Enter/Space were preventing the actions menu button from opening when activated via keyboard. Extract a reusable check that only treats these keys as row activation when they originate on the row itself, not on child controls. Also consolidate the search input to use the shared Input component for consistency. * fix: use stable keys for automations page skeleton rows React Doctor no-array-index-as-key failed static analysis on the loading skeleton map. Give each static row an id for the React key. * Reorganize i18n keys for automation components Move translation keys to their proper component namespaces (rowActions to AutomationListLocalRows, normalize loading key), update all locale files. * fix: move automations detail selection refs out of render React Doctor fails CI when refs are mutated during render. Sync selectedExternalKey and isDetailOpen into refs in effects instead. * style: soften automation search field focus state - Reduce focus ring opacity to 70% for a subtler focus indicator - Set focus-visible:ring-0 to eliminate elevation effect - Add dark:bg-background for consistent dark mode appearance --- src/renderer/src/App.tsx | 3 +- .../AutomationListExternalRows.tsx | 193 ++++++--- .../AutomationListLocalRows.test.tsx | 209 +++++++++ .../automations/AutomationListLocalRows.tsx | 395 +++++++++++------- .../automations/AutomationListSearchField.tsx | 6 +- .../automations/AutomationListStatusCell.tsx | 20 + .../automations/AutomationsDetailPane.tsx | 33 +- .../automations/AutomationsListPanel.tsx | 255 +++++++---- .../automations/AutomationsPage.tsx | 256 ++++++------ .../automations/AutomationsPageSkeleton.tsx | 129 ++++++ .../automation-detail-selection.test.ts | 35 ++ .../automation-detail-selection.ts | 20 + .../automation-list-row-interaction.test.ts | 54 +++ .../automation-list-row-interaction.ts | 26 ++ .../automations/automations-table-layout.ts | 15 + src/renderer/src/i18n/locales/en.json | 17 +- src/renderer/src/i18n/locales/es.json | 35 +- src/renderer/src/i18n/locales/ja.json | 35 +- src/renderer/src/i18n/locales/ko.json | 35 +- src/renderer/src/i18n/locales/zh.json | 35 +- 20 files changed, 1401 insertions(+), 405 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationListLocalRows.test.tsx create mode 100644 src/renderer/src/components/automations/AutomationListStatusCell.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPageSkeleton.tsx create mode 100644 src/renderer/src/components/automations/automation-detail-selection.test.ts create mode 100644 src/renderer/src/components/automations/automation-detail-selection.ts create mode 100644 src/renderer/src/components/automations/automation-list-row-interaction.test.ts create mode 100644 src/renderer/src/components/automations/automation-list-row-interaction.ts create mode 100644 src/renderer/src/components/automations/automations-table-layout.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7529436ca93..25c47a5806d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2347,7 +2347,8 @@ function App(): React.JSX.Element { ) ) : null}
- {stackedSidebarOpen ? ( + {/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */} + {stackedSidebarOpen && activeView !== 'automations' ? (
{titlebarMainStrip}
) : null}
diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index e036dcef419..25a9f137397 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -7,6 +7,14 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { ExternalAutomationAction, @@ -22,6 +30,13 @@ import { } from './external-automation-display' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' +import { + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + AUTOMATIONS_TABLE_ROW_SELECTED_CLASS +} from './automations-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' export function AutomationListExternalRows({ @@ -52,10 +67,8 @@ export function AutomationListExternalRows({ {entries.map((entry) => { const providerLabel = getExternalProviderLabel(entry.manager) const targetKindLabel = getExternalTargetKindLabel(entry.manager) - const nextRunLabel = entry.job.enabled - ? formatExternalDate(entry.job.nextRunAt, relativeNow) - : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const entrySshStatus = + const isSelected = selectedExternalKey === entry.key + const sshStatus = entry.manager.target.type === 'ssh' ? sshConnectionStates.get(entry.manager.target.connectionId)?.status : undefined @@ -63,66 +76,140 @@ export function AutomationListExternalRows({ manager: entry.manager, providerLabel, targetKindLabel, - sshStatus: entrySshStatus, + sshStatus, actionInProgress: externalActionKey !== null }) const actionDisabled = disabledMessage !== null - const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) + const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label + const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}` + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + return ( - + + + onRequestAction(entry.manager, entry.job, 'run')} + > + + + {disabledMessage ?? + translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + )} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job)} + > + + {translate( + 'auto.components.automations.AutomationsPage.f4612e3f78', + 'Edit' + )} + + ) : null} + + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume' + ) + } + > + {entry.job.enabled ? ( + + ) : ( + + )} + {entry.job.enabled ? translate( - 'auto.components.automations.AutomationsPage.runCount', - '{{count}} runs', - { count: entry.job.runCount } + 'auto.components.automations.AutomationsPage.b457436d6a', + 'Pause' ) - : entry.manager.canManage - ? translate( - 'auto.components.automations.AutomationsPage.aecdc3681f', - 'Manageable' - ) - : translate( - 'auto.components.automations.AutomationsPage.e059042585', - 'Read-only' - )} - - - - - - {nextRunLabel} - - + : translate( + 'auto.components.automations.AutomationsPage.376631ef2b', + 'Resume' + )} + + + onRequestAction(entry.manager, entry.job, 'delete')} + > + + {translate( + 'auto.components.automations.AutomationsPage.15e0bfb13b', + 'Delete' + )} + + + +
({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'automation-1', + name: 'test', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'worktree-1', + baseBranch: null, + reuseSession: false, + timezone: 'America/Los_Angeles', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: Date.now() + 60_000, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeExternalJob(overrides: Partial = {}): ExternalAutomationJob { + return { + id: 'job-1', + managerId: 'manager-1', + provider: 'hermes', + name: 'Nightly', + schedule: '0 0 * * *', + rawSchedule: null, + enabled: true, + state: 'enabled', + prompt: null, + promptPreview: '', + nextRunAt: new Date(Date.now() + 60_000).toISOString(), + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 0, + runs: [], + ...overrides + } +} + +function makeExternalManager(): ExternalAutomationManager { + return { + id: 'manager-1', + provider: 'hermes', + label: 'Local Hermes', + targetLabel: 'Local', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [] + } +} + +function renderLocalRows(handlers: { + onSelect: (automationId: string) => void + onDelete?: (automation: Automation) => void +}) { + return render( + + ) +} + +function renderExternalRows(handlers: { + onSelect: (entryKey: string) => void + onRequestAction?: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: 'run' | 'pause' | 'resume' | 'delete' + ) => void +}) { + const manager = makeExternalManager() + const job = makeExternalJob() + return render( + + ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('Automation list row selection', () => { + it('does not open local detail when a quick-action menu item is chosen', async () => { + // Radix portals the menu out of the row's DOM, but React bubbles its + // clicks back through the component tree. Selecting Delete must not call + // onSelect (which opens the detail page behind the confirm dialog). + const onSelect = vi.fn() + const onDelete = vi.fn() + renderLocalRows({ onSelect, onDelete }) + const user = userEvent.setup() + + await user.click( + screen.getByRole('button', { + name: 'Automation actions' + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('still selects when the local row itself is clicked', async () => { + const onSelect = vi.fn() + const { container } = renderLocalRows({ onSelect }) + const user = userEvent.setup() + + const row = container.querySelector('[data-slot="context-menu-trigger"]') + expect(row).not.toBeNull() + await user.click(row as Element) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith('automation-1') + }) + + it('opens the actions menu on keyboard activation without selecting the row', async () => { + // The row handles Enter/Space for its own activation. Those keys bubble + // from the actions trigger too, so an unguarded row handler would + // preventDefault the button's native activation and open detail instead. + const onSelect = vi.fn() + renderLocalRows({ onSelect }) + const user = userEvent.setup() + + screen.getByRole('button', { name: 'Automation actions' }).focus() + await user.keyboard('{Enter}') + + expect(await screen.findByRole('menuitem', { name: 'Delete' })).toBeTruthy() + expect(onSelect).not.toHaveBeenCalled() + }) + + it('does not open external detail when a quick-action menu item is chosen', async () => { + const onSelect = vi.fn() + const onRequestAction = vi.fn() + renderExternalRows({ onSelect, onRequestAction }) + const user = userEvent.setup() + + await user.click( + screen.getByRole('button', { + name: 'Automation actions' + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onRequestAction).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 1a5a4994aac..97ec2c7bb5f 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -7,24 +7,41 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import { + getExecutionHostLabel, + getLocalExecutionHostLabel, + getRepoExecutionHostId +} from '../../../../shared/execution-host' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationHostTarget } from './automation-host-client' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' -import { - formatAutomationCost, - formatAutomationTokens, - summarizeAutomationRunUsage -} from './automation-usage-model' import { getAutomationTargetAvailability } from './automation-target-availability' import { getAgentLabel } from './automation-draft-model' +import { formatAutomationCost } from './automation-usage-model' +import { + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + AUTOMATIONS_TABLE_ROW_SELECTED_CLASS +} from './automations-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' export function AutomationListLocalRows({ @@ -40,6 +57,7 @@ export function AutomationListLocalRows({ runtimeStatusByEnvironmentId, automationHostTarget, automationSourceHostAvailabilityById, + hostLabelById, onSelect, onRunNow, onEdit, @@ -61,6 +79,7 @@ export function AutomationListLocalRows({ > automationHostTarget: AutomationHostTarget | null automationSourceHostAvailabilityById: ReadonlyMap + hostLabelById: ReadonlyMap onSelect: (automationId: string) => void onRunNow: (automation: Automation) => void onEdit: (automation: Automation) => void @@ -69,18 +88,17 @@ export function AutomationListLocalRows({ }): React.JSX.Element { // Why: one pass over runs instead of a full scan per rendered automation — // this list re-renders on the relativeNow timer. - const runsByAutomationId = React.useMemo(() => { - const grouped = new Map() + const lastRunByAutomationId = React.useMemo(() => { + const latest = new Map() for (const run of runs) { - const existing = grouped.get(run.automationId) - if (existing) { - existing.push(run) - } else { - grouped.set(run.automationId, [run]) + const existing = latest.get(run.automationId) + if (!existing || run.createdAt > existing.createdAt) { + latest.set(run.automationId, run) } } - return grouped + return latest }, [runs]) + return ( <> {automations.map((automation) => { @@ -98,148 +116,237 @@ export function AutomationListLocalRows({ automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) - const baseRefLabel = - automation.baseBranch ?? - automationRepo?.worktreeBaseRef ?? - translate( - 'auto.components.automations.AutomationsPage.projectDefaultBaseRef', - 'project default' - ) - const workspaceLabel = - automation.workspaceMode === 'new_per_run' - ? translate( - 'auto.components.automations.AutomationsPage.createFromBaseRef', - 'Create from {{baseRef}}', - { baseRef: baseRefLabel } - ) - : (automationWorktree?.displayName ?? - translate( - 'auto.components.automations.AutomationsPage.missingWorkspace', - 'Missing workspace' - )) - const usageSummary = summarizeAutomationRunUsage( - runsByAutomationId.get(automation.id) ?? [] - ) - const usageText = - usageSummary.knownRuns > 0 - ? translate( - 'auto.components.automations.AutomationsPage.runUsageSummary', - '{{cost}} est. · {{tokens}} tokens', - { - cost: formatAutomationCost(usageSummary.estimatedCostUsd), - tokens: formatAutomationTokens(usageSummary.totalTokens) - } - ) - : usageSummary.unavailableRuns > 0 - ? translate( - 'auto.components.automations.AutomationsPage.usageUnavailable', - 'Usage unavailable' - ) - : translate( - 'auto.components.automations.AutomationsPage.noRunUsageYet', - 'No run usage yet' - ) + const projectLabel = + automationRepo?.displayName ?? + translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project') + const scheduleLabel = formatAutomationSchedule(automation.rrule) const nextRunLabel = automation.enabled ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const scheduleLabel = formatAutomationSchedule(automation.rrule) + const isSelected = isSelectedLocal && selectedId === automation.id + const agentLabel = getAgentLabel(automation.agentId) + const hostId = + automation.runContext?.hostId ?? + (automationRepo ? getRepoExecutionHostId(automationRepo) : null) + const hostLabel = hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : getLocalExecutionHostLabel() + const lastRun = lastRunByAutomationId.get(automation.id) + const lastRunCost = + lastRun?.usage?.status === 'known' + ? formatAutomationCost(lastRun.usage.estimatedCostUsd) + : formatAutomationCost(null) + const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${lastRunCost}` + + const actionItems = ( + <> + onRunNow(automation)} + /> + } + label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + onSelect={() => onEdit(automation)} + /> + : + } + label={ + automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') + } + onSelect={() => onToggle(automation)} + /> + + } + label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + variant="destructive" + onSelect={() => onDelete(automation)} + /> + + ) + return ( - - - - { - if (!automationRunAvailability.canRunNow) { - event.preventDefault() +
{ + // Why: Radix portals menus out of the row DOM, but React still + // bubbles those clicks here — ignore so menu actions don't open detail. + if (isPortaledRowMenuClick(event)) { return } - onRunNow(automation) + onSelect(automation.id) }} - > - - - {automationRunAvailability.canRunNow - ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') - : automationRunAvailability.message} - - - onEdit(automation)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - onToggle(automation)}> - {automation.enabled ? ( - - ) : ( - + onKeyDown={(event) => { + if (!isRowActivationKey(event)) { + return + } + event.preventDefault() + onSelect(automation.id) + }} + className={cn( + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + isSelected && AUTOMATIONS_TABLE_ROW_SELECTED_CLASS )} - {automation.enabled - ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') - : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} - - - onDelete(automation)}> - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} - - + > + {automation.name} + + {scheduleLabel} + + + {projectLabel} + + + {nextRunLabel} + + + + + + + + + + {agentTooltipLabel} + + + + + + + + { + if (automationRunAvailability.canRunNow) { + onRunNow(automation) + } + }} + > + + + {automationRunAvailability.canRunNow + ? translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + ) + : automationRunAvailability.message} + + + onEdit(automation)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate( + 'auto.components.automations.AutomationsPage.b457436d6a', + 'Pause' + ) + : translate( + 'auto.components.automations.AutomationsPage.376631ef2b', + 'Resume' + )} + + + onDelete(automation)}> + + {translate( + 'auto.components.automations.AutomationsPage.15e0bfb13b', + 'Delete' + )} + + + +
+ + {actionItems}
) })} ) } + +function MenuRunItem({ + disabled, + label, + onSelect +}: { + disabled: boolean + label: string + onSelect: () => void +}): React.JSX.Element { + return ( + { + if (disabled) { + event.preventDefault() + return + } + onSelect() + }} + > + + {label} + + ) +} + +function MenuItem({ + icon, + label, + onSelect, + variant +}: { + icon: React.ReactNode + label: string + onSelect: () => void + variant?: 'destructive' +}): React.JSX.Element { + return ( + + {icon} + {label} + + ) +} + +function MenuSeparator(): React.JSX.Element { + return +} diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx index d1691a49036..fead100d80e 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -34,6 +34,7 @@ export function AutomationListSearchField({ onQueryChange(event.target.value)} diff --git a/src/renderer/src/components/automations/AutomationListStatusCell.tsx b/src/renderer/src/components/automations/AutomationListStatusCell.tsx new file mode 100644 index 00000000000..b8da08336de --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListStatusCell.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { X } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function AutomationListStatusCell({ enabled }: { enabled: boolean }): React.JSX.Element { + if (enabled) { + return ( + + + {translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled')} + + ) + } + return ( + + + {translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 1864e085680..48ffceba88f 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Eye, RefreshCw } from 'lucide-react' +import { ArrowLeft, Eye, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' @@ -80,6 +80,7 @@ type AutomationsDetailPaneProps = { rerunAutomationRun: (automation: Automation, run: AutomationRun) => void openRunWorkspace: (run: AutomationRun) => void openAutomationRunPage: (run: AutomationRun) => void + onBackToList: () => void } export function AutomationsDetailPane({ @@ -114,12 +115,22 @@ export function AutomationsDetailPane({ requestDeleteAutomation, rerunAutomationRun, openRunWorkspace, - openAutomationRunPage + openAutomationRunPage, + onBackToList }: AutomationsDetailPaneProps): React.JSX.Element { return ( -
+
{selectedExternal ? ( -
+
+
+ +
{selectedExternalRunPage ? (
+ {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 31d9cc9d6d9..a9c246fe782 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -1,6 +1,8 @@ import React from 'react' -import { Plus } from 'lucide-react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import type { Automation, AutomationRun, @@ -20,6 +22,11 @@ import { getAutomationTemplates, type AutomationTemplate } from './automation-te import type { ExternalAutomationListEntry } from './external-automation-list-entries' import { AutomationListLocalRows } from './AutomationListLocalRows' import { AutomationListExternalRows } from './AutomationListExternalRows' +import { + AUTOMATIONS_TABLE_CONTAINER_CLASS, + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_HEADER_CLASS +} from './automations-table-layout' import { translate } from '@/i18n/i18n' type AutomationsListPanelProps = { @@ -31,8 +38,10 @@ type AutomationsListPanelProps = { onListSearchQueryChange: (query: string) => void filteredAutomations: readonly Automation[] filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] - selected: Automation | null - selectedExternal: ExternalAutomationListEntry | null + // Why: use explicit ids, not resolved detail selection (which falls back to the + // first row) — list highlight should only mark a user-chosen row. + selectedId: string | null + selectedExternalKey: string | null runs: readonly AutomationRun[] relativeNow: number repoMap: ReadonlyMap @@ -45,6 +54,7 @@ type AutomationsListPanelProps = { > automationHostTarget: AutomationHostTarget | null automationSourceHostAvailabilityById: ReadonlyMap + hostLabelById: ReadonlyMap externalActionKey: string | null selectAutomationId: (automationId: string | null) => void selectExternalKey: (externalKey: string | null) => void @@ -60,6 +70,9 @@ type AutomationsListPanelProps = { ) => void openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void openCreateDialog: (template?: AutomationTemplate) => void + onOpenDetail: () => void + onRefresh: () => void + isRefreshing: boolean } export function AutomationsListPanel({ @@ -71,8 +84,8 @@ export function AutomationsListPanel({ onListSearchQueryChange, filteredAutomations, filteredExternalAutomationEntries, - selected, - selectedExternal, + selectedId, + selectedExternalKey, runs, relativeNow, repoMap, @@ -82,6 +95,7 @@ export function AutomationsListPanel({ runtimeStatusByEnvironmentId, automationHostTarget, automationSourceHostAvailabilityById, + hostLabelById, externalActionKey, selectAutomationId, selectExternalKey, @@ -92,81 +106,19 @@ export function AutomationsListPanel({ requestDeleteAutomation, requestExternalAction, openEditExternalDialog, - openCreateDialog + openCreateDialog, + onOpenDetail, + onRefresh, + isRefreshing }: AutomationsListPanelProps): React.JSX.Element { return (
- {hasListItems ? ( -
- - onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) - } - onClear={() => onListSearchQueryChange('')} - /> -
- ) : null} -
- {hasFilteredListItems ? ( -
- - {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')} - - - {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} - -
- ) : null} - { - selectExternalKey(null) - selectAutomationId(automationId) - }} - onRunNow={runNow} - onEdit={openEditDialog} - onToggle={toggleAutomation} - onDelete={requestDeleteAutomation} - /> - { - selectExternalKey(entryKey) - setActivePaneTab('overview') - }} - onRequestAction={requestExternalAction} - onEdit={openEditExternalDialog} - /> - {hasListItems && isListSearchActive && !hasFilteredListItems ? ( -
- {translate( - 'auto.components.automations.AutomationsPage.noSearchMatches', - 'No automations match your search.' - )} -
- ) : null} - {!hasListItems ? ( -
+ {!hasListItems ? ( +
+
{translate( 'auto.components.automations.AutomationsPage.d207ab4c25', @@ -199,8 +151,157 @@ export function AutomationsListPanel({ {translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
- ) : null} -
+
+ ) : ( +
+
+
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + /> + + + + + + {translate( + 'auto.components.automations.AutomationsPage.19a6e30eae', + 'Refresh automations' + )} + + +
+ +
+ +
+ {hasFilteredListItems ? ( + <> +
+ + {translate('auto.components.automations.AutomationsPage.tableName', 'Name')} + + + {translate( + 'auto.components.automations.AutomationDetail.18763ded26', + 'Schedule' + )} + + + {translate( + 'auto.components.automations.AutomationsPage.tableProject', + 'Project' + )} + + + {translate( + 'auto.components.automations.AutomationDetail.578ff46987', + 'Next run' + )} + + + {translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')} + + + {translate('auto.components.automations.AutomationDetail.2df8970cd5', 'Agent')} + + + {translate( + 'auto.components.automations.AutomationsPage.tableActions', + 'Actions' + )} + +
+
+ { + selectExternalKey(null) + selectAutomationId(automationId) + onOpenDetail() + }} + onRunNow={runNow} + onEdit={openEditDialog} + onToggle={toggleAutomation} + onDelete={requestDeleteAutomation} + /> + { + selectAutomationId(null) + selectExternalKey(entryKey) + setActivePaneTab('overview') + onOpenDetail() + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> +
+ + ) : isListSearchActive ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.noSearchMatches', + 'No automations match your search.' + )} +
+ ) : null} +
+
+ )}
) } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7293b79ad22..996f474a7c9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,16 +1,12 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail * orchestration while the form, list, and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react' import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { Button } from '@/components/ui/button' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' -import { cn } from '@/lib/utils' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -108,10 +104,12 @@ import { isMissingExternalRunsApiError } from './external-automation-display' import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { shouldCloseDetailForLostSelection } from './automation-detail-selection' import { useAutomationListSearch } from './use-automation-list-search' import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' import { AutomationsListPanel } from './AutomationsListPanel' import { AutomationsDetailPane } from './AutomationsDetailPane' +import { AutomationsPageSkeleton } from './AutomationsPageSkeleton' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' @@ -186,6 +184,17 @@ export default function AutomationsPage(): React.JSX.Element { const [selectedExternalKey, setSelectedExternalKey] = useState(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState(null) + // Why: list is the primary surface; detail is a full-page drill-in, not a side pane. + const [isDetailOpen, setIsDetailOpen] = useState(false) + const selectedExternalKeyRef = useRef(null) + const isDetailOpenRef = useRef(false) + // Keep async refresh/delete handlers reading the latest selection without render-time mutation. + useEffect(() => { + selectedExternalKeyRef.current = selectedExternalKey + }, [selectedExternalKey]) + useEffect(() => { + isDetailOpenRef.current = isDetailOpen + }, [isDetailOpen]) const runtimePreflightMountedRef = useRef(true) const runtimePreflightRequestedHostIdsRef = useRef>(new Set()) const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< @@ -210,7 +219,7 @@ export default function AutomationsPage(): React.JSX.Element { } | null>(null) useContextualTour( 'automations', - !createOpen && !deleteTarget && !externalDeleteTarget, + !isLoading && !createOpen && !deleteTarget && !externalDeleteTarget, 'automations_open' ) const [editingExternalTarget, setEditingExternalTarget] = useState<{ @@ -429,6 +438,11 @@ export default function AutomationsPage(): React.JSX.Element { const pendingAutomation = automations.find( (automation) => automation.id === pending.automationId ) + // Why: external selection wins over local in detail resolution; clear it so + // pending local navigation cannot open the wrong automation. + if (selectedExternalKey !== null) { + selectExternalKey(null) + } if (!pendingAutomation) { // Why: stale provenance should not silently select the first automation. setSelectedId(pending.automationId) @@ -444,9 +458,11 @@ export default function AutomationsPage(): React.JSX.Element { } if (selectedId !== pending.automationId) { setSelectedId(pending.automationId) + setIsDetailOpen(true) return } if (!pending.runId) { + setIsDetailOpen(true) setActivePaneTab('overview') setSelectedAutomationRunPageId(null) setPendingAutomationRunNavigation(null) @@ -455,6 +471,7 @@ export default function AutomationsPage(): React.JSX.Element { if (selectedAutomationRuns.automationId !== pending.automationId) { return } + setIsDetailOpen(true) setActivePaneTab('runs') const pendingRun = selectedRuns.find((run) => run.id === pending.runId) if (pendingRun) { @@ -475,7 +492,9 @@ export default function AutomationsPage(): React.JSX.Element { automationHostTargetKey, isLoading, pendingAutomationRunNavigation, + selectExternalKey, selectedAutomationRuns.automationId, + selectedExternalKey, selectedId, selectedRuns, setPendingAutomationRunNavigation, @@ -761,6 +780,21 @@ export default function AutomationsPage(): React.JSX.Element { setExternalManagers(nextExternalManagers) if (!hasCurrentSelection && !pendingNavigation) { selectAutomationId(nextAutomations[0]?.id ?? null) + if ( + shouldCloseDetailForLostSelection({ + isDetailOpen: isDetailOpenRef.current, + hasPendingNavigation: false, + isSelectedAutomationInNextList: false, + isSelectedExternalInNextList: buildExternalAutomationListEntries( + nextExternalManagers + ).some((entry) => entry.key === selectedExternalKeyRef.current) + }) + ) { + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + } } } finally { setIsLoading(false) @@ -1468,6 +1502,9 @@ export default function AutomationsPage(): React.JSX.Element { await deleteAutomationForTarget(automation, automationHostTarget) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setActivePaneTab('overview') } await refresh() } @@ -1608,6 +1645,16 @@ export default function AutomationsPage(): React.JSX.Element { useAppStore.getState().recordFeatureInteraction('automation-run') } await refresh() + // Why: full-page detail keeps selection when the deleted external was open; + // without this, detail can fall through to an unrelated local automation. + if (action === 'delete') { + const deletedKey = getExternalAutomationKey(manager, job) + if (selectedExternalKeyRef.current === deletedKey) { + selectExternalKey(null) + setIsDetailOpen(false) + setActivePaneTab('overview') + } + } toast.success( action === 'delete' ? translate( @@ -1800,91 +1847,53 @@ export default function AutomationsPage(): React.JSX.Element { return } + // Why: detail is a full-page drill-in; step out of nested run views first, + // then return to the table before leaving Automations. + if (isDetailOpen) { + event.preventDefault() + if (selectedExternalRunPage) { + setSelectedExternalRunPage(null) + return + } + if (selectedAutomationRunPageId) { + setSelectedAutomationRunPageId(null) + return + } + setIsDetailOpen(false) + setActivePaneTab('overview') + return + } + event.preventDefault() closeAutomationsPage() } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [closeAutomationsPage, createOpen, deleteTarget, externalDeleteTarget]) + }, [ + closeAutomationsPage, + createOpen, + deleteTarget, + externalDeleteTarget, + isDetailOpen, + selectedAutomationRunPageId, + selectedExternalRunPage + ]) return ( -
-
-
- - - - - - {translate('auto.components.automations.AutomationsPage.0329f9bef1', 'Close · Esc')} - - -
- -

- {translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')} -

- - - - - - {translate( - 'auto.components.automations.AutomationsPage.8d1afa8269', - 'Add automation' - )} - - -
-
- - - - - - {translate( - 'auto.components.automations.AutomationsPage.19a6e30eae', - 'Refresh automations' - )} - - -
+
+
+

+ {translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')} +

void confirmDeleteExternalAutomation()} /> -
- void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} - requestDeleteAutomation={requestDeleteAutomation} - requestExternalAction={requestExternalAction} - openEditExternalDialog={openEditExternalDialog} - openCreateDialog={openCreateDialog} - /> - + {/* Why: empty list flashes templates before data arrives; keep layout stable on first load. */} + {isLoading && !hasListItems ? ( + + ) : isDetailOpen && (selected || selectedExternal) ? ( void rerunAutomationRun(automation, run)} openRunWorkspace={openRunWorkspace} openAutomationRunPage={openAutomationRunPage} + onBackToList={() => { + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + }} /> -
+ ) : ( + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + onOpenDetail={() => setIsDetailOpen(true)} + onRefresh={() => void refresh()} + isRefreshing={isLoading} + /> + )}
) } diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx new file mode 100644 index 00000000000..58990205c4c --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { + AUTOMATIONS_TABLE_CONTAINER_CLASS, + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_HEADER_CLASS +} from './automations-table-layout' +import { translate } from '@/i18n/i18n' + +function SkeletonBar({ className }: { className?: string }): React.JSX.Element { + return
+} + +function TableRowSkeleton({ + nameWidthClass, + scheduleWidthClass, + projectWidthClass, + nextWidthClass, + statusWidthClass +}: { + nameWidthClass: string + scheduleWidthClass: string + projectWidthClass: string + nextWidthClass: string + statusWidthClass: string +}): React.JSX.Element { + return ( +
+ + + + + + + +
+ ) +} + +const TABLE_ROW_SKELETONS = [ + { + id: 'row-1', + nameWidthClass: 'w-28', + scheduleWidthClass: 'w-36', + projectWidthClass: 'w-28', + nextWidthClass: 'w-24', + statusWidthClass: 'w-16' + }, + { + id: 'row-2', + nameWidthClass: 'w-36', + scheduleWidthClass: 'w-28', + projectWidthClass: 'w-32', + nextWidthClass: 'w-20', + statusWidthClass: 'w-14' + }, + { + id: 'row-3', + nameWidthClass: 'w-24', + scheduleWidthClass: 'w-40', + projectWidthClass: 'w-24', + nextWidthClass: 'w-28', + statusWidthClass: 'w-16' + }, + { + id: 'row-4', + nameWidthClass: 'w-32', + scheduleWidthClass: 'w-32', + projectWidthClass: 'w-36', + nextWidthClass: 'w-24', + statusWidthClass: 'w-14' + }, + { + id: 'row-5', + nameWidthClass: 'w-40', + scheduleWidthClass: 'w-24', + projectWidthClass: 'w-28', + nextWidthClass: 'w-20', + statusWidthClass: 'w-16' + }, + { + id: 'row-6', + nameWidthClass: 'w-28', + scheduleWidthClass: 'w-36', + projectWidthClass: 'w-20', + nextWidthClass: 'w-24', + statusWidthClass: 'w-14' + } +] as const + +export function AutomationsPageSkeleton(): React.JSX.Element { + return ( +
+
+
+ + +
+ +
+
+
+ + + + + + + +
+
+ {TABLE_ROW_SKELETONS.map(({ id, ...row }) => ( + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/automation-detail-selection.test.ts b/src/renderer/src/components/automations/automation-detail-selection.test.ts new file mode 100644 index 00000000000..8dea844ea15 --- /dev/null +++ b/src/renderer/src/components/automations/automation-detail-selection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { shouldCloseDetailForLostSelection } from './automation-detail-selection' + +const base = { + isDetailOpen: true, + hasPendingNavigation: false, + isSelectedAutomationInNextList: false, + isSelectedExternalInNextList: false +} + +describe('shouldCloseDetailForLostSelection', () => { + it('closes detail when the refreshed list no longer contains the selection', () => { + expect(shouldCloseDetailForLostSelection(base)).toBe(true) + }) + + it('keeps detail open when the selected automation survived the refresh', () => { + expect( + shouldCloseDetailForLostSelection({ ...base, isSelectedAutomationInNextList: true }) + ).toBe(false) + }) + + it('keeps detail open when the selected external automation survived the refresh', () => { + expect(shouldCloseDetailForLostSelection({ ...base, isSelectedExternalInNextList: true })).toBe( + false + ) + }) + + it('keeps detail open when a pending run navigation will re-open it', () => { + expect(shouldCloseDetailForLostSelection({ ...base, hasPendingNavigation: true })).toBe(false) + }) + + it('does nothing when detail is already closed', () => { + expect(shouldCloseDetailForLostSelection({ ...base, isDetailOpen: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-detail-selection.ts b/src/renderer/src/components/automations/automation-detail-selection.ts new file mode 100644 index 00000000000..ce3487ee058 --- /dev/null +++ b/src/renderer/src/components/automations/automation-detail-selection.ts @@ -0,0 +1,20 @@ +/** + * True when a refresh dropped every selected automation while full-page detail is open — + * without closing, detail silently re-renders whichever automation the list now sorts first. + */ +export function shouldCloseDetailForLostSelection({ + isDetailOpen, + hasPendingNavigation, + isSelectedAutomationInNextList, + isSelectedExternalInNextList +}: { + isDetailOpen: boolean + hasPendingNavigation: boolean + isSelectedAutomationInNextList: boolean + isSelectedExternalInNextList: boolean +}): boolean { + if (!isDetailOpen || hasPendingNavigation) { + return false + } + return !isSelectedAutomationInNextList && !isSelectedExternalInNextList +} diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.test.ts b/src/renderer/src/components/automations/automation-list-row-interaction.test.ts new file mode 100644 index 00000000000..5e0703c174f --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-interaction.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' + +describe('isPortaledRowMenuClick', () => { + it('detects clicks whose target is outside the row DOM', () => { + const row = document.createElement('div') + const portaledMenuItem = document.createElement('div') + document.body.append(row, portaledMenuItem) + + expect( + isPortaledRowMenuClick({ + target: portaledMenuItem, + currentTarget: row + }) + ).toBe(true) + + row.remove() + portaledMenuItem.remove() + }) + + it('allows in-row clicks', () => { + const row = document.createElement('div') + const child = document.createElement('span') + row.append(child) + + expect( + isPortaledRowMenuClick({ + target: child, + currentTarget: row + }) + ).toBe(false) + }) +}) + +describe('isRowActivationKey', () => { + it('accepts Enter and Space on the row itself', () => { + const row = document.createElement('div') + + expect(isRowActivationKey({ key: 'Enter', target: row, currentTarget: row })).toBe(true) + expect(isRowActivationKey({ key: ' ', target: row, currentTarget: row })).toBe(true) + expect(isRowActivationKey({ key: 'a', target: row, currentTarget: row })).toBe(false) + }) + + it('ignores keys pressed on a nested control', () => { + const row = document.createElement('div') + const trigger = document.createElement('button') + row.append(trigger) + + expect(isRowActivationKey({ key: 'Enter', target: trigger, currentTarget: row })).toBe(false) + expect(isRowActivationKey({ key: ' ', target: trigger, currentTarget: row })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.ts b/src/renderer/src/components/automations/automation-list-row-interaction.ts new file mode 100644 index 00000000000..c09504bc543 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-interaction.ts @@ -0,0 +1,26 @@ +/** True when a portaled Radix menu click re-bubbles through a row's React tree. */ +export function isPortaledRowMenuClick(event: { + target: EventTarget + currentTarget: EventTarget +}): boolean { + const target = event.target + return target instanceof Node && event.currentTarget instanceof Node + ? !event.currentTarget.contains(target) + : false +} + +/** + * True when Enter/Space landed on the row itself. Keys pressed on a nested + * control (the actions trigger) must stay with that control — preventDefault + * here would swallow its native activation. + */ +export function isRowActivationKey(event: { + key: string + target: EventTarget + currentTarget: EventTarget +}): boolean { + if (event.target !== event.currentTarget) { + return false + } + return event.key === 'Enter' || event.key === ' ' +} diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts new file mode 100644 index 00000000000..f6947f8c7f4 --- /dev/null +++ b/src/renderer/src/components/automations/automations-table-layout.ts @@ -0,0 +1,15 @@ +/** Shared layout classes for the automations list table. + * Matches AutomationRunHistory / ExternalAutomationRunTable / Tasks list tables. */ +// Name | Schedule | Project | Next run | Status | Agent | Actions +export const AUTOMATIONS_TABLE_GRID_CLASS = + 'grid grid-cols-[minmax(0,1.5fr)_minmax(7rem,10rem)_minmax(5rem,9rem)_minmax(10rem,1.2fr)_minmax(4.5rem,6rem)_2.5rem_2.5rem]' + +export const AUTOMATIONS_TABLE_CONTAINER_CLASS = 'rounded-md border border-border/50 bg-muted/20' + +export const AUTOMATIONS_TABLE_HEADER_CLASS = + 'sticky top-0 z-10 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground' + +export const AUTOMATIONS_TABLE_ROW_CLASS = + 'w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' + +export const AUTOMATIONS_TABLE_ROW_SELECTED_CLASS = 'bg-accent text-accent-foreground' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 34911f6f372..3a063d6e3d4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14352,7 +14352,17 @@ "usageUnavailable": "Usage unavailable", "noRunUsageYet": "No run usage yet", "noWorkspace": "No workspace", - "latestSavedOutput": "Latest saved output" + "latestSavedOutput": "Latest saved output", + "backToList": "All automations", + "tableName": "Name", + "tableProject": "Project", + "tableStatus": "Status", + "tableActions": "Actions", + "newAutomation": "New Automation", + "rowActions": "Automation actions" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "Loading automations" }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", @@ -14469,9 +14479,12 @@ "AutomationListSearchField": { "tooLong": "Search text is too long — list is unfiltered", "label": "Search automations", - "placeholder": "Search by name, project, or prompt", + "placeholder": "Search...", "tooLongShort": "Too long", "clear": "Clear search" + }, + "AutomationListLocalRows": { + "c92c9463c6": "Automation actions" } }, "agent": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de7bb77346c..9a80ab4e5f5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13816,7 +13816,27 @@ "a21f6c33ad": "Origen de automatización actualizado.", "53f06f0ad5": "Reintentar origen", "pendingAutomationMissing": "La automatización ya no está disponible.", - "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible." + "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible.", + "noSearchMatches": "Ninguna automatización coincide con tu búsqueda.", + "paused": "Pausada", + "runCount": "{{count}} ejecuciones", + "runCount_one": "{{count}} ejecución", + "runCount_other": "{{count}} ejecuciones", + "projectDefaultBaseRef": "predeterminado del proyecto", + "createFromBaseRef": "Crear desde {{baseRef}}", + "missingWorkspace": "Espacio de trabajo ausente", + "runUsageSummary": "{{cost}} est. · {{tokens}} tokens", + "usageUnavailable": "Uso no disponible", + "noRunUsageYet": "Aún no hay uso de ejecución", + "noWorkspace": "Sin espacio de trabajo", + "latestSavedOutput": "Última salida guardada", + "backToList": "Todas las automatizaciones", + "tableName": "Nombre", + "tableProject": "Proyecto", + "tableStatus": "Estado", + "tableActions": "Acciones", + "newAutomation": "Nueva automatización", + "rowActions": "Acciones de automatización" }, "CreateFromPicker": { "f061f49e3f": "Buscar ramas del repositorio...", @@ -13929,6 +13949,19 @@ "5a7863909c": "Ejecutar la configuración para cada espacio de trabajo nuevo", "18f000ad4e": "Avanzado", "874b72195b": "Cuando esta automatización crea un workspace, lo prepara igual que al crear un worktree manualmente: ejecuta la configuración del proyecto y abre sus pestañas de terminal." + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "Cargando automatizaciones" + }, + "AutomationListSearchField": { + "tooLong": "El texto de búsqueda es demasiado largo — la lista no está filtrada", + "label": "Buscar automatizaciones", + "placeholder": "Buscar...", + "tooLongShort": "Demasiado largo", + "clear": "Borrar búsqueda" + }, + "AutomationListLocalRows": { + "c92c9463c6": "Acciones de automatización" } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 76f154d39e8..dd37babf73d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13816,7 +13816,27 @@ "a21f6c33ad": "オートメーションのソースを更新しました。", "53f06f0ad5": "ソースを再試行", "pendingAutomationMissing": "自動化は利用できなくなりました。", - "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。" + "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。", + "noSearchMatches": "検索に一致する自動化がありません。", + "paused": "一時停止中", + "runCount": "{{count}} 回の実行", + "runCount_one": "{{count}} 回の実行", + "runCount_other": "{{count}} 回の実行", + "projectDefaultBaseRef": "プロジェクトのデフォルト", + "createFromBaseRef": "{{baseRef}} から作成", + "missingWorkspace": "ワークスペースがありません", + "runUsageSummary": "約 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "使用量を利用できません", + "noRunUsageYet": "まだ実行の使用量がありません", + "noWorkspace": "ワークスペースなし", + "latestSavedOutput": "最新の保存済み出力", + "backToList": "すべての自動化", + "tableName": "名前", + "tableProject": "プロジェクト", + "tableStatus": "ステータス", + "tableActions": "操作", + "newAutomation": "新しい自動化", + "rowActions": "自動化の操作" }, "CreateFromPicker": { "f061f49e3f": "リポジトリブランチを検索…", @@ -13929,6 +13949,19 @@ "5a7863909c": "新規ワークスペースごとにセットアップを実行", "18f000ad4e": "詳細設定", "874b72195b": "このオートメーションがワークスペースを作成するときに、手動でワークツリーを作成する場合と同じ準備を行います。プロジェクトのセットアップを実行し、ターミナルタブを開きます。" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "自動化を読み込み中" + }, + "AutomationListSearchField": { + "tooLong": "検索テキストが長すぎます — リストは未フィルタです", + "label": "自動化を検索", + "placeholder": "検索...", + "tooLongShort": "長すぎます", + "clear": "検索をクリア" + }, + "AutomationListLocalRows": { + "c92c9463c6": "自動化の操作" } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1ee6d8ee0f3..b0b476820c5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13827,7 +13827,27 @@ "a21f6c33ad": "자동화 소스를 새로 고쳤습니다.", "53f06f0ad5": "소스 다시 시도", "pendingAutomationMissing": "자동화를 더 이상 사용할 수 없습니다.", - "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다." + "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", + "noSearchMatches": "검색과 일치하는 자동화가 없습니다.", + "paused": "일시 중지됨", + "runCount": "실행 {{count}}회", + "runCount_one": "실행 {{count}}회", + "runCount_other": "실행 {{count}}회", + "projectDefaultBaseRef": "프로젝트 기본값", + "createFromBaseRef": "{{baseRef}}에서 만들기", + "missingWorkspace": "워크스페이스 없음", + "runUsageSummary": "약 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "사용량을 사용할 수 없음", + "noRunUsageYet": "아직 실행 사용량 없음", + "noWorkspace": "워크스페이스 없음", + "latestSavedOutput": "최근 저장된 출력", + "backToList": "모든 자동화", + "tableName": "이름", + "tableProject": "프로젝트", + "tableStatus": "상태", + "tableActions": "작업", + "newAutomation": "새 자동화", + "rowActions": "자동화 작업" }, "CreateFromPicker": { "f061f49e3f": "repo 브랜치 검색...", @@ -13940,6 +13960,19 @@ "5a7863909c": "새 워크스페이스마다 설정 실행", "18f000ad4e": "고급", "874b72195b": "이 자동화가 워크스페이스를 만들 때, 워크트리를 직접 만들 때와 같은 방식으로 준비합니다 — 프로젝트 설정을 실행하고 터미널 탭을 엽니다." + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "자동화 로딩 중" + }, + "AutomationListSearchField": { + "tooLong": "검색 텍스트가 너무 깁니다 — 목록이 필터링되지 않음", + "label": "자동화 검색", + "placeholder": "검색...", + "tooLongShort": "너무 김", + "clear": "검색 지우기" + }, + "AutomationListLocalRows": { + "c92c9463c6": "자동화 작업" } }, "agent": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6ce82f72417..0498519afb9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13836,7 +13836,27 @@ "a21f6c33ad": "自动化来源已刷新。", "53f06f0ad5": "重试来源", "pendingAutomationMissing": "自动化已不可用。", - "pendingAutomationRunMissing": "运行历史已不可用。" + "pendingAutomationRunMissing": "运行历史已不可用。", + "noSearchMatches": "没有匹配的自动化。", + "paused": "已暂停", + "runCount": "{{count}} 次运行", + "runCount_one": "{{count}} 次运行", + "runCount_other": "{{count}} 次运行", + "projectDefaultBaseRef": "项目默认", + "createFromBaseRef": "从 {{baseRef}} 创建", + "missingWorkspace": "缺少工作区", + "runUsageSummary": "约 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "用量不可用", + "noRunUsageYet": "尚无运行用量", + "noWorkspace": "无工作区", + "latestSavedOutput": "最近保存的输出", + "backToList": "全部自动化", + "tableName": "名称", + "tableProject": "项目", + "tableStatus": "状态", + "tableActions": "操作", + "newAutomation": "新建自动化", + "rowActions": "自动化操作" }, "CreateFromPicker": { "f061f49e3f": "搜索仓库分支...", @@ -13949,6 +13969,19 @@ "5a7863909c": "为每个新工作区运行设置", "18f000ad4e": "高级", "874b72195b": "当此自动化创建工作区时,使用与手动创建工作树相同的方式准备——运行项目的设置并打开其终端标签页。" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "正在加载自动化" + }, + "AutomationListSearchField": { + "tooLong": "搜索文本过长 — 列表未筛选", + "label": "搜索自动化", + "placeholder": "搜索...", + "tooLongShort": "过长", + "clear": "清除搜索" + }, + "AutomationListLocalRows": { + "c92c9463c6": "自动化操作" } }, "agent": { From 96c2cebfa611df3f1331ee5250cdccc003cbc26b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:52:45 -0700 Subject: [PATCH 09/69] refactor(mobile): extract terminal settings styles (#13706) --- config/max-lines-baseline.txt | 1 - mobile/.oxlintrc.json | 6 -- mobile/app/terminal-settings.tsx | 94 +------------------ .../terminal-settings-screen-styles.ts | 91 ++++++++++++++++++ 4 files changed, 94 insertions(+), 98 deletions(-) create mode 100644 mobile/src/terminal/terminal-settings-screen-styles.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3275e33987a..36f6ecd3af0 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -342,7 +342,6 @@ mobile-config app/h/*/source-control/*.tsx mobile-config app/h/*/tasks.tsx mobile-config app/index.tsx mobile-config app/pair-scan.tsx -mobile-config app/terminal-settings.tsx mobile-config app/troubleshoot.tsx mobile-config scripts/mock-server.ts mobile-config scripts/repro-terminal-colors.ts diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 51dd82305f1..877a6d7e123 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -93,12 +93,6 @@ "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["app/terminal-settings.tsx"], - "rules": { - "max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["scripts/mock-server.ts"], "rules": { diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e55819aa22a..860f906e77b 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { View, Text, Pressable, Switch } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { @@ -9,7 +9,7 @@ import Animated, { } from 'react-native-reanimated' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { colors, spacing } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' @@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' +import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles' import { loadTerminalAutocompleteEnabled, loadTerminalTextScale, @@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg, - paddingTop: 0 - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - scrollContent: { - paddingBottom: spacing.xl - }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - inputGroupGap: { - marginTop: spacing.xl - }, - emptyText: { - fontSize: typography.bodySize, - color: colors.textSecondary, - padding: spacing.md - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - } -}) diff --git a/mobile/src/terminal/terminal-settings-screen-styles.ts b/mobile/src/terminal/terminal-settings-screen-styles.ts new file mode 100644 index 00000000000..a3ff5c8dd6d --- /dev/null +++ b/mobile/src/terminal/terminal-settings-screen-styles.ts @@ -0,0 +1,91 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const terminalSettingsScreenStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg, + paddingTop: 0 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scrollContent: { + paddingBottom: spacing.xl + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + inputGroupGap: { + marginTop: spacing.xl + }, + emptyText: { + fontSize: typography.bodySize, + color: colors.textSecondary, + padding: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) From 868bfbe8cebf7b8dbe224b1b4387803b7f78ea18 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:19:10 -0700 Subject: [PATCH 10/69] fix(terminal): release retained output slice parents (#13402) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- config/vitest.config.ts | 3 +- .../src/lib/flatten-retained-slice.test.ts | 57 ++++++ .../src/lib/flatten-retained-slice.ts | 4 + .../pane-terminal-output-scheduler.test.ts | 164 ++++++++++++++++++ .../pane-terminal-output-scheduler.ts | 25 ++- 5 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/flatten-retained-slice.test.ts create mode 100644 src/renderer/src/lib/flatten-retained-slice.ts diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 735e1b2a0af..d23bee128c9 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ test: { environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. - execArgv: ['--no-experimental-webstorage'], + // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. + execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ diff --git a/src/renderer/src/lib/flatten-retained-slice.test.ts b/src/renderer/src/lib/flatten-retained-slice.test.ts new file mode 100644 index 00000000000..2eedfc5c6ad --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { flattenRetainedSlice } from './flatten-retained-slice' + +// Why 1 MB: comfortably above V8's SlicedString threshold, so a raw slice really does keep the +// parent alive and the difference between flattened and not is unmistakable in heapUsed. +const PARENT_CHARS = 1024 * 1024 +const TAIL_CHARS = 512 +const PARENTS = 8 + +function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed +} + +// Distinct leading chars stop V8 sharing one backing store across the parents. +function makeParent(index: number, filler: string): string { + return String.fromCharCode(0x41 + index) + filler.repeat(PARENT_CHARS - 1) +} + +function retainedBytesForTails(filler: string, transform: (value: string) => string): number { + const parents = Array.from({ length: PARENTS }, (_unused, index) => makeParent(index, filler)) + const baseline = collect() + const tails = parents.map((parent) => transform(parent.slice(parent.length - TAIL_CHARS))) + // Drop the parents; only the tails stay reachable. + parents.length = 0 + const retained = collect() - baseline + expect(tails).toHaveLength(PARENTS) + expect(tails.every((tail) => tail.length === TAIL_CHARS)).toBe(true) + return retained +} + +describe('flattenRetainedSlice', () => { + it('preserves content exactly', () => { + expect(flattenRetainedSlice('')).toBe('') + expect(flattenRetainedSlice('a')).toBe('a') + const source = 'hello world, 漢字, , \u{1f600}' + expect(flattenRetainedSlice(source.slice(3))).toBe(source.slice(3)) + }) + + it.each([ + ['one-byte', 'a'], + ['two-byte', '漢'] + ])('drops the %s parent a raw slice would pin', (_label, filler) => { + const raw = retainedBytesForTails(filler, (value) => value) + const flattened = retainedBytesForTails(filler, flattenRetainedSlice) + + // A raw slice pins all 8 parents; flattening must keep only the tails. + expect(raw).toBeGreaterThan(PARENTS * PARENT_CHARS * 0.5) + expect(flattened).toBeLessThan(PARENTS * PARENT_CHARS * 0.05) + }) +}) diff --git a/src/renderer/src/lib/flatten-retained-slice.ts b/src/renderer/src/lib/flatten-retained-slice.ts new file mode 100644 index 00000000000..45585c3a75e --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.ts @@ -0,0 +1,4 @@ +// V8 slices can retain their full parent; force a standalone copy for values that outlive it. +export function flattenRetainedSlice(value: string): string { + return value.length === 0 ? value : `${value} `.slice(0, -1) +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index c3f0c4df330..2a9d7d023ac 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1721,4 +1721,168 @@ describe('pane terminal output scheduler', () => { vi.advanceTimersByTime(100) expect(throwing.write).toHaveBeenCalledTimes(1) }) + + describe('queue memory retention (STA-3567)', () => { + // Why 2 MB: comfortably above BACKGROUND_CHUNK_CHARS (16 K), so every drain leaves a residual slice. + const CHUNK_CHARS = 2 * 1024 * 1024 + const TERMINALS = 8 + + function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed + } + + it('releases chunks it has already drained past', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why many medium chunks: compactConsumedChunks only splices once chunkIndex reaches 64, + // so below that the drained slots stay in the array and must be cleared individually. + const CHUNKS_PER_TERMINAL = 40 + const CHUNK = 48 * 1024 + const SINKS = 4 + + let writtenChars = 0 + const terminals = Array.from({ length: SINKS }, () => ({ + write: (data: string, callback?: () => void) => { + writtenChars += data.length + callback?.() + } + })) + + const baseline = collect() + + for (const [index, terminal] of terminals.entries()) { + for (let chunk = 0; chunk < CHUNKS_PER_TERMINAL; chunk += 1) { + writeTerminalOutput( + terminal, + String.fromCharCode(65 + index) + + String.fromCharCode(48 + (chunk % 10)) + + 'q'.repeat(CHUNK - 2), + { foreground: false, latencySensitive: false } + ) + } + } + + const debug = ( + globalThis as { + __terminalOutputSchedulerDebug?: { snapshot: () => { queuedChars: number } } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('scheduler debug API unavailable') + } + + const TAIL_CHARS = 64 * 1024 + let ticks = 0 + while (debug.snapshot().queuedChars > TAIL_CHARS && ticks < 40000) { + vi.advanceTimersByTime(4) + ticks += 1 + } + + const queuedChars = debug.snapshot().queuedChars + const retainedBytes = collect() - baseline + + // Sanity: the queues really drained down, and none hit the backlog cap. + expect(writtenChars).toBeGreaterThan(SINKS * CHUNKS_PER_TERMINAL * CHUNK * 0.9) + expect(queuedChars).toBeGreaterThan(0) + expect(queuedChars).toBeLessThanOrEqual(TAIL_CHARS) + + // The defect: ~39 drained-past slots per terminal kept their strings alive uncharged. + expect(retainedBytes).toBeLessThan(2 * 1024 * 1024) + }) + + it('does not pin the parent when a producer enqueues a slice', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why a slice: agent-status-osc.ts and the restore-overlap trims hand the scheduler + // strings cut from a much larger buffer. The queue must own its copy rather than + // trusting every producer to flatten first (STA-3567 review round 2). + const KEEP_CHARS = 64 * 1024 + const sinks = Array.from({ length: TERMINALS }, () => ({ + write: (_data: string, callback?: () => void) => callback?.() + })) + + const baseline = collect() + + for (const [index, sink] of sinks.entries()) { + const parent = String.fromCharCode(65 + index) + 'q'.repeat(CHUNK_CHARS - 1) + writeTerminalOutput(sink, parent.slice(parent.length - KEEP_CHARS), { + foreground: false, + latencySensitive: false + }) + } + + const retainedBytes = collect() - baseline + + // 8 x 64 KB of real payload must not keep 8 x 2 MB of parents alive. + expect(retainedBytes).toBeLessThan(4 * 1024 * 1024) + }) + + it('drops the parent chunk once only a small tail is still queued', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why a hand-rolled write: vi.fn() retains every argument in mock.calls, which would + // dominate the measurement with the very bytes the queue is supposed to have released. + let writtenChars = 0 + const makeSink = (): { write: (data: string, callback?: () => void) => void } => ({ + write: (data: string, callback?: () => void) => { + writtenChars += data.length + callback?.() + } + }) + const terminals = Array.from({ length: TERMINALS }, makeSink) + + // Why baseline BEFORE enqueueing: the queue owns a copy of every chunk, so a baseline + // taken after enqueue already contains the parents this test must prove get released, + // and the assertion would hold even with residual flattening disabled. + const baseline = collect() + + for (const [index, terminal] of terminals.entries()) { + // Built and dropped inline so only the queue's own copy stays reachable. + writeTerminalOutput( + terminal, + String.fromCharCode(65 + index) + 'q'.repeat(CHUNK_CHARS - 1), + { foreground: false, latencySensitive: false } + ) + } + + const debug = ( + globalThis as { + __terminalOutputSchedulerDebug?: { snapshot: () => { queuedChars: number } } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('scheduler debug API unavailable') + } + + // Why stop early: the leak is what a PARTIALLY drained queue pins. Draining to empty + // frees the chunks either way, so a full drain cannot observe the defect. + const TAIL_CHARS = 64 * 1024 + let ticks = 0 + while (debug.snapshot().queuedChars > TAIL_CHARS && ticks < 20000) { + vi.advanceTimersByTime(4) + ticks += 1 + } + + const queuedChars = debug.snapshot().queuedChars + const retainedBytes = collect() - baseline + + // Sanity: nearly everything drained, but a real tail is still queued - otherwise + // there is no residual slice to hold a parent chunk and the measurement is meaningless. + expect(writtenChars).toBeGreaterThan(TERMINALS * CHUNK_CHARS * 0.9) + expect(queuedChars).toBeGreaterThan(0) + expect(queuedChars).toBeLessThanOrEqual(TAIL_CHARS) + + // The defect: those few queued KB pinned 8 x 2 MB of chunks (~16 MB). + expect(retainedBytes).toBeLessThan(4 * 1024 * 1024) + }) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 8acb768e635..db826781812 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,6 +7,7 @@ import { } from './pane-terminal-foreground-render-settle' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { flattenRetainedSlice } from '@/lib/flatten-retained-slice' import { discardInFlightTerminalOutputAckCredits, registerTerminalOutputAckCredits @@ -49,6 +50,8 @@ type WriteTerminalOutputOptions = { type QueueChunk = { data: string + // Tracks the backing data still reachable through this queue slot. + retainedChars: number foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean @@ -631,6 +634,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { data += chunk.data remaining -= chunk.data.length entry.queuedChars -= chunk.data.length + // Clear drained slots before the 64-chunk compaction can release them. + entry.chunks[entry.chunkIndex] = { + data: '', + retainedChars: 0, + foreground: chunk.foreground, + forceForegroundRefresh: false, + followupForegroundRefresh: false, + shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + stripTransientCursorShows: false + } entry.chunkIndex += 1 if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) @@ -642,9 +655,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { } data += chunk.data.slice(0, remaining) + const residual = chunk.data.slice(remaining) + // Geometric flattening bounds retained parents while keeping total copy work linear. + const flatten = residual.length * 2 <= chunk.retainedChars entry.chunks[entry.chunkIndex] = { ...chunk, - data: chunk.data.slice(remaining) + data: flatten ? flattenRetainedSlice(residual) : residual, + retainedChars: flatten ? residual.length : chunk.retainedChars } entry.queuedChars -= remaining remaining = 0 @@ -719,8 +736,11 @@ function enqueueChunk( ackCredit?: () => void } ): void { + // Own queued data so producer slices cannot pin larger PTY or restore buffers. + const owned = flattenRetainedSlice(data) entry.chunks.push({ - data, + data: owned, + retainedChars: owned.length, foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, @@ -783,6 +803,7 @@ function replaceBacklogWithWarning( entry.chunks = [ { data: warning, + retainedChars: warning.length, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, From f2984e2230d0077f30efe0a5a636222922138293 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:24:04 -0700 Subject: [PATCH 11/69] fix(terminal): skip a too-wide alt frame on snapshot replay (#13014) * fix(terminal): skip a too-wide alt frame on snapshot replay Reopening a parked worktree could paint a stale full-width TUI frame through a narrower viewport, leaving clipped gutter fragments and mid-word omissions until the live application repainted. Replay pins xterm to the snapshot grid so soft-wrapped normal-buffer history stays exact (#7279), then the post-replay fit returns the pane to its container grid. Alternate buffers have no scrollback and do not reflow; an absolutely positioned frame remains at its capture layout, so narrowing exposes only clipped portions of those fixed-grid rows. Skip only the visual frame when its capture is wider than the grid the fit will land on. The alt buffer is still entered and cleared, so the resize signal lands on a clean screen that the live application can repaint. Equal-width and wider restores retain the frame, while normal history always replays at its capture grid before fitting. The target width comes from proposeDimensions, not terminal.cols: an unfitted pane can still read xterm's default grid even when its actual container matches the capture. * fix(terminal): preserve offline SSH prepaint frame * fix(terminal): drop a too-wide daemon alt frame on reattach The renderer-only gate did not run on the user-visible remount path. Instrumentation showed that daemon-connectResult-snapshot won before the model-snapshot branches, so the composed snapshot painted in full and the later narrower fit exposed its stale fixed-grid frame clipped at the new viewport. The daemon branch could not omit only the visual frame while main sent one merged string. Publish the normal-buffer/mode prefix and visual alt frame as additive optional metadata while retaining the merged snapshot for mixed-version fallback. New renderers can keep history and restore state without painting a frame captured for a wider grid. Replay ordering remains capture-grid, write, then fit so normal-buffer soft wrapping stays exact. The application owns the foreign-width alt frame and repaints it after the resize signal rather than Orca trying to transform an absolutely positioned screen. * fix(terminal): preserve split daemon snapshot payload * fix(terminal): preserve live state when dropping alt frame * fix(terminal): restore DECOM cursor state exactly * fix(terminal): preserve ordinary snapshot bytes * test(terminal): cover fixed-grid alt replay resize * fix(terminal): repaint after dropping mismatched frames A hidden snapshot can omit an alternate-screen frame before the pane has a measurable target grid. If reveal later lands on the capture grid, a same-size PTY resize emits no SIGWINCH, so pulse the local PTY size whenever that frame was skipped.\n\nKeep performSafeFit's measurable-pane contract intact, publish daemon snapshot prefix and frame as explicit optional strings, and fall back to the merged payload when either field is absent. Cold owner-gone restores now omit a mismatched frame while retaining history and fresh-shell reset treatment; offline SSH preconnect remains unchanged.\n\nPin the vendored SerializeAddon out-of-range-row behavior used to capture live SGR state. --- src/main/daemon/daemon-pty-adapter.test.ts | 25 ++ src/main/daemon/daemon-pty-adapter.ts | 16 +- src/main/daemon/headless-emulator.test.ts | 49 ++++ src/main/daemon/headless-emulator.ts | 6 +- .../terminal-frame-restore-sequences.ts | 97 ++++++ ...minal-snapshot-serialize-roundtrip.test.ts | 18 ++ src/main/daemon/terminal-snapshot.ts | 2 + src/main/ipc/pty.ts | 1 + src/main/providers/pty-provider-contract.ts | 2 + src/main/providers/pty-spawn-result.ts | 6 + src/main/runtime/orca-runtime.test.ts | 13 + src/main/runtime/orca-runtime.ts | 7 + src/preload/api-types.ts | 4 + src/preload/index.ts | 4 + .../terminal-pane/pty-connection.test.ts | 276 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 83 +++++- .../terminal-pane/pty-transport-types.ts | 8 + .../terminal-pane/pty-transport.test.ts | 10 +- .../components/terminal-pane/pty-transport.ts | 9 + .../terminal-snapshot-replay-paint.test.ts | 109 ++++++- .../terminal-snapshot-replay-paint.ts | 59 +++- .../src/lib/pane-manager/pane-fit.test.ts | 63 +++- src/renderer/src/lib/pane-manager/pane-fit.ts | 27 +- .../terminal-serialize-absolute-cursor.ts | 64 +++- 24 files changed, 909 insertions(+), 49 deletions(-) create mode 100644 src/main/daemon/terminal-frame-restore-sequences.ts diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 7a697e6d96c..cbc58a44274 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1321,6 +1321,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { source: 'headless' }) }) + + it('exposes live state separately from the visible frame', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[?1004h\x1b[?25lframe') + + const snapshot = await adapter.getBufferSnapshot(id) + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('frame') + expect(snapshot?.data).toContain('frame') + }) }) describe('shutdown', () => { @@ -1506,6 +1518,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.snapshot).toContain('prompt$') }) + it('publishes the alt-frame payload as explicit strings', async () => { + const sessionId = 'alt-frame-boundary' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[HSTATIC-ALT-FRAME') + await new Promise((r) => setTimeout(r, 50)) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.snapshotPrefixAnsi).toContain('\x1b[?1049h') + expect(result.snapshotFrameAnsi).toContain('STATIC-ALT-FRAME') + expect(result.snapshot).toBe([result.snapshotPrefixAnsi, result.snapshotFrameAnsi].join('')) + expect(result.snapshotFrameRestoreAnsi).not.toContain('STATIC-ALT-FRAME') + }) + it('returns the preserved sequence from attach-only adoption', async () => { const sessionId = 'attach-sequence-handoff' await adapter.spawn({ cols: 80, rows: 24, sessionId }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f8bdf30deab..94d66c639a7 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -831,10 +831,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = - result.snapshot.scrollbackAnsi + - result.snapshot.rehydrateSequences + - result.snapshot.snapshotAnsi + const snapshotPrefix = result.snapshot.scrollbackAnsi + result.snapshot.rehydrateSequences + const snapshotFrame = result.snapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { @@ -847,6 +846,14 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && result.snapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: result.snapshot.frameRestoreAnsi + } + : {}), ...(providerSequence ? { providerSequence } : {}), ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } @@ -1216,6 +1223,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } return { data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, scrollbackAnsi: snapshot.scrollbackAnsi, cols: snapshot.cols, rows: snapshot.rows, diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 37633f32334..9ee6918113f 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -575,6 +575,55 @@ describe('HeadlessEmulator', () => { }) describe('rehydration sequences', () => { + it('separates complete live state from an alternate-screen frame', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[?1004h\x1b[?25l\x1b[5;10H\x1b7\x1b[31;44;1mframe') + + const snapshot = emulator.getSnapshot() + + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1049h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot.frameRestoreAnsi).toContain('\x1b7') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[31;44;1m') + expect(snapshot.frameRestoreAnsi).not.toContain('frame') + + const restored = new HeadlessEmulator({ cols: 80, rows: 24 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + expect(restored.isAlternateScreen).toBe(true) + await restored.write('X') + expect(restored.getSnapshot().snapshotAnsi).toContain('\x1b[31;44;1mX') + restored.dispose() + }) + + it('restores current and saved cursor rows relative to origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[3;8r\x1b[?6h\x1b[2;7H\x1b7\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[3]?.[6]).toBe('S') + restored.dispose() + }) + + it('restores a saved cursor from before the current origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[1;3H\x1b7\x1b[3;8r\x1b[?6h\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[0]?.[2]).toBe('S') + restored.dispose() + }) + it('generates rehydration for non-default modes', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?2004h') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 195b97a30f6..12be643d637 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -13,6 +13,7 @@ import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences' import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' import { installTerminalViewAttributeResponder, @@ -255,6 +256,7 @@ export class HeadlessEmulator { this.restoredOscLinks ), rehydrateSequences: buildRehydrateSequences(modes), + ...buildFrameRestoreSnapshotFields(this.serializer, this.terminal, modes), cwd: this.oscText.cwd, modes, cols: this.terminal.cols, @@ -266,10 +268,6 @@ export class HeadlessEmulator { ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } - if (this.partialEscapeTail.length > 0) { - // Why a separate field: consumers write their own reset sequences after the body, and any ESC after a dangling partial would abort it. - snapshot.pendingEscapeTailAnsi = this.partialEscapeTail - } return snapshot } diff --git a/src/main/daemon/terminal-frame-restore-sequences.ts b/src/main/daemon/terminal-frame-restore-sequences.ts new file mode 100644 index 00000000000..6d91771c4d2 --- /dev/null +++ b/src/main/daemon/terminal-frame-restore-sequences.ts @@ -0,0 +1,97 @@ +import type { Terminal } from '@xterm/headless' +import type { SerializeAddon } from '@xterm/addon-serialize' +import { + buildAbsoluteCursorRestoreSequence, + readSavedCursorRegister +} from '../../shared/terminal-serialize-absolute-cursor' +import type { SavedCursorRegister } from '../../shared/terminal-serialize-absolute-cursor' +import type { TerminalModes } from './types' + +export function buildFrameRestoreSnapshotFields( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes +): { frameRestoreAnsi?: string } { + return modes.alternateScreen + ? { + frameRestoreAnsi: buildTerminalFrameRestoreSequences( + serializer, + terminal, + modes, + readSavedCursorRegister(terminal) + ) + } + : {} +} + +/** Restores live terminal state without replaying an alternate-screen frame. */ +function buildTerminalFrameRestoreSequences( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes, + savedCursor: SavedCursorRegister | null +): string { + const terminalModes = terminal.modes + const seqs: string[] = ['\x1b[0m\x1b[?1049h', serializeCurrentSgrState(serializer, terminal)] + if (terminalModes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + if (terminalModes.applicationKeypadMode) { + seqs.push('\x1b[?66h') + } + if (terminalModes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + if (terminalModes.insertMode) { + seqs.push('\x1b[4h') + } + if (terminalModes.reverseWraparoundMode) { + seqs.push('\x1b[?45h') + } + if (terminalModes.sendFocusMode) { + seqs.push('\x1b[?1004h') + } + if (!terminalModes.wraparoundMode) { + seqs.push('\x1b[?7l') + } + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + if (!terminalModes.showCursor) { + seqs.push('\x1b[?25l') + } + seqs.push( + buildAbsoluteCursorRestoreSequence(terminal, savedCursor, { + restoreModesWithoutCursor: true + }) + ) + return seqs.join('') +} + +function serializeCurrentSgrState(serializer: SerializeAddon, terminal: Terminal): string { + // Unsupported addon behavior under our vendored patch: an out-of-range row emits only its authoritative pen-state diff. + const emptyRow = terminal.buffer.normal.length + return serializer.serialize({ + range: { start: emptyRow, end: emptyRow }, + excludeAltBuffer: true, + excludeModes: true + }) +} diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts index b54227652dd..f0927fcf379 100644 --- a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -70,6 +70,24 @@ async function roundTripStyles(source: string): Promise { return replay(addon.serialize()) } +describe('out-of-range SGR state serialization', () => { + it('emits only the live pen state for a row beyond the normal buffer', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, '\x1b[31;44;1m') + + // This deliberately pins unsupported SerializeAddon behavior used by + // terminal-frame-restore-sequences under Orca's vendored addon patch. + const outOfRangeRow = terminal.buffer.normal.length + expect( + addon.serialize({ + range: { start: outOfRangeRow, end: outOfRangeRow }, + excludeAltBuffer: true, + excludeModes: true + }) + ).toBe('\x1b[31;44;1m') + }) +}) + describe('SGR intensity round-trip (BUG B, addon patch)', () => { it('restores bold set immediately after dim is cleared (minimized repro)', async () => { const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 653d8fc4bd6..6d378a8d107 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -9,6 +9,8 @@ export type TerminalSnapshot = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + /** Live modes and cursor state that can be restored without the alt frame. */ + frameRestoreAnsi?: string cwd: string | null modes: TerminalModes cols: number diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 1ca019f6f0f..24786811086 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5672,6 +5672,7 @@ export function registerPtyHandlers( args: { id?: unknown; opts?: { scrollbackRows?: unknown } } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 02b1d9acf1e..398729efe17 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -19,6 +19,8 @@ export type { export type PtyProviderBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string /** Authoritative normal buffer captured beside an alternate-screen frame. */ scrollbackAnsi?: string cols: number diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 235672e05e7..66fec74a482 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -32,6 +32,12 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame, separate so newer clients can omit it safely. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string /** Provider sequence at the attach boundary. `reset` starts a new provider * generation; `continued` resumes the existing absolute domain. */ providerSequence?: { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3336bd7c01f..031c9be80c5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11043,6 +11043,19 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.cwd).toBe('/home/me/repo/src') }) + it('projects frame-independent live state through main buffer snapshots', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-frame-state', TEST_WORKTREE_ID) + runtime.onPtyData('pty-frame-state', '\x1b[?1049h\x1b[?1004h\x1b[?25lSTATIC-FRAME', 123) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-frame-state') + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('STATIC-FRAME') + expect(snapshot?.data).toContain('STATIC-FRAME') + }) + it('keeps Windows SSH OSC7 cwd as a drive path when the desktop runtime is POSIX', () => { setPlatform('darwin') const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fd7cbb6d7be..095b6e98ee4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1595,6 +1595,8 @@ type ProviderBufferAcquisition = { type RuntimeTerminalBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11080,6 +11082,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11098,6 +11101,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11548,6 +11552,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11598,6 +11603,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -12018,6 +12024,7 @@ export class OrcaRuntimeService { return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { data, + frameRestoreAnsi: snapshot.frameRestoreAnsi, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1cae8cd2359..97f43c3140b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1566,6 +1566,9 @@ export type PreloadApi = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1632,6 +1635,7 @@ export type PreloadApi = { opts?: { scrollbackRows?: number } ) => Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/preload/index.ts b/src/preload/index.ts index 179ce5ecc2a..fc04a8d8e25 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -963,6 +963,9 @@ const api = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1073,6 +1076,7 @@ const api = { opts?: { scrollbackRows?: number } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5fee2bae59a..9391b6d614a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -8694,6 +8694,211 @@ describe('connectPanePty', () => { ) }) + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { + // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('drops a live daemon alt frame until a hidden pane has a final grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 1, rows: 1 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'LEGACY-MERGED-SNAPSHOT', + snapshotPrefixAnsi: 'INCOMPLETE-PREFIX', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('LEGACY-MERGED-SNAPSHOT') + expect(writes.join('')).not.toContain('INCOMPLETE-PREFIX') + }) + + it('keeps a daemon alt frame when the pane is not narrower than the capture', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect( + (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]).join('') + ).toContain('ALT-FRAME-BODY') + }) + + it('drops a too-wide daemon alt frame on cold restore and keeps its history', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true, + coldRestore: { scrollback: 'x', cwd: '/tmp' } + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes).toContain(POST_REPLAY_MODE_RESET) + }) + it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { // Why: the daemon serializes soft-wrapped lines flat, so reattach must resize xterm to the snapshot grid before writing, or rows rewrap wrong. const { connectPanePty } = await import('./pty-connection') @@ -15207,6 +15412,67 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('pty-id') + let onData: ConnectCallbacks['onData'] + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + onData = callbacks.onData + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + getMainBufferSnapshot.mockResolvedValue({ + data: 'TOO-WIDE-ALT-FRAME', + frameRestoreAnsi: '\x1b[?1004h\x1b[?25l', + cols: 120, + rows: 40, + seq: hidden.length + 1, + alternateScreen: true, + scrollbackAnsi: 'preserved history' + }) + const pane = createPane(1) + let paneRect = createRect(0, 0) + pane.container.getBoundingClientRect = vi.fn(() => paneRect) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + onData?.('x', { seq: hidden.length + 1, rawLength: 1 }) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME') + expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l') + expect(transport.resize).not.toHaveBeenCalled() + + xtermContainerDisplay = 'block' + paneRect = createRect(800, 600) + safeFit(pane as never) + await flushAsyncTicks(12) + + const pulseStart = transport.resize.mock.calls.findIndex( + ([cols, rows]) => cols === 119 && rows === 40 + ) + expect(pulseStart).toBeGreaterThanOrEqual(0) + expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40]) + disposable.dispose() + }) + it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -21085,7 +21351,7 @@ describe('connectPanePty', () => { expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) - it('paints a parked SSH model before the remote connection settles', async () => { + it('keeps a too-wide parked SSH alt frame while no live process can repaint it', async () => { const { connectPanePty } = await import('./pty-connection') const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1') const sshConnect = createDeferred() @@ -21094,10 +21360,12 @@ describe('connectPanePty', () => { vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ data: 'PARKED-SSH-PAINTED-WITHOUT-NETWORK\r\n', - cols: 101, + cols: 140, rows: 31, seq: 123, - source: 'headless' + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n' }) await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { @@ -21111,6 +21379,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })) const { writes } = captureCallbackTerminalWrites(pane) const binding = connectPanePty( pane as never, @@ -21124,6 +21393,7 @@ describe('connectPanePty', () => { expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' }) expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + // No live SSH process can repaint this preconnect frame after a SIGWINCH. expect(writes.join('')).toContain('PARKED-SSH-PAINTED-WITHOUT-NETWORK') expect(transport.connect).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f7a266eb90d..5ee42f9c784 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -263,7 +263,9 @@ import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-sc import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, - resolvePositiveTerminalDimensions + readProposedTerminalCols, + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch } from './terminal-snapshot-replay-paint' import { decideSshReattachPaintSource, @@ -7191,6 +7193,7 @@ export function connectPanePty( async function applyMainBufferSnapshot(snapshot: { data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -7214,6 +7217,7 @@ export function connectPanePty( const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) + let skippedAltFrame = false try { await structuralReplayCoordinator.run( async () => { @@ -7250,7 +7254,15 @@ export function connectPanePty( // Why shared: the SSH reattach model paint inlines the same // choreography (coordinator nesting would deadlock there); one // builder keeps the alt-screen branches from drifting. - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { + skippedAltFrame = + snapshot.alternateScreen === true && + snapshot.frameRestoreAnsi !== undefined && + shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), { + skipIfTargetUnknown: true + }) + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: skippedAltFrame + })) { writeReplayData(replayChunk) } // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). @@ -7284,7 +7296,14 @@ export function connectPanePty( return } const currentPtyId = transport.getPtyId() - if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + if (!currentPtyId) { + return + } + if (getFitOverrideForPty(currentPtyId)) { + safeFit(pane) + if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } return } const fit = safeFitAndThen( @@ -7298,6 +7317,10 @@ export function connectPanePty( ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay + if (skippedAltFrame) { + pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId) + return + } if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { transport.resize(pane.terminal.cols, pane.terminal.rows) if (!isRemoteRuntimePtyId(currentPtyId)) { @@ -7306,7 +7329,13 @@ export function connectPanePty( } } }, - { shouldContinue: isCurrentRestore, retryIfUnmeasurable: true } + { + shouldContinue: isCurrentRestore, + retryIfUnmeasurable: true, + // A skipped frame is blank until this fit pushes the final + // grid/SIGWINCH, so carry the continuation through reveal. + deferIfHidden: true + } ) pendingHiddenSnapshotFit = fit try { @@ -7946,6 +7975,7 @@ export function connectPanePty( } } kittyKeyboardModes.scanReplay(modelData) + // Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit. for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { writeReplayData(replayChunk) } @@ -8134,7 +8164,18 @@ export function connectPanePty( return } if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + const snapshotPrefixAnsi = connectResult.snapshotPrefixAnsi + const snapshotFrameAnsi = connectResult.snapshotFrameAnsi + const snapshotFrameRestoreAnsi = connectResult.snapshotFrameRestoreAnsi + const hasSplitDaemonAltFrame = + typeof snapshotPrefixAnsi === 'string' && + snapshotPrefixAnsi.length > 0 && + typeof snapshotFrameAnsi === 'string' && + snapshotFrameAnsi.length > 0 + const daemonSnapshotReplay = hasSplitDaemonAltFrame + ? snapshotPrefixAnsi + snapshotFrameAnsi + : connectResult.snapshot + rememberReattachPayloadAgentSignal(daemonSnapshotReplay, { fullScreenReplay: true }) // Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI. const snapshotDimensions = resolvePositiveTerminalDimensions( connectResult.snapshotCols, @@ -8154,12 +8195,26 @@ export function connectPanePty( } writeReplayData('\x1b[2J\x1b[3J\x1b[H') // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset — unless this is a cold restore, whose owner is gone. + kittyKeyboardModes.scanReplay(daemonSnapshotReplay) + // A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint. + // A dead-owner restore keeps history plus its restored-session treatment; + // a frozen foreign-width frame would look live when no owner remains. + const daemonAltFrameSkippable = + hasSplitDaemonAltFrame && + typeof snapshotFrameRestoreAnsi === 'string' && + shouldSkipAltFrameForWidthMismatch( + connectResult.snapshotCols, + readProposedTerminalCols(pane), + { skipIfTargetUnknown: true } + ) + writeReplayData( + daemonAltFrameSkippable + ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi + : daemonSnapshotReplay + ) writeReplayData( reattachReplayResetSequence( - connectResult.snapshot, + daemonSnapshotReplay, Boolean(connectResult.coldRestore), connectResult.isAlternateScreen ) @@ -8213,7 +8268,15 @@ export function connectPanePty( // ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips // the ?1049h marker when splitting scrollbackAnsi) — inlined here // because nesting structuralReplayCoordinator would deadlock. - for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot)) { + for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { + skipAltFrame: shouldSkipAltFrameForWidthMismatch( + modelCols, + readProposedTerminalCols(pane), + { + skipIfTargetUnknown: true + } + ) + })) { writeReplayData(replayChunk) } writeReplayData( diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 8c82e7584a1..d0c20224e5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -18,6 +18,8 @@ import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime- export type PtyBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -59,6 +61,12 @@ export type PtyConnectResult = { snapshot?: string snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame. Both fields are absent on older hosts. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string isAlternateScreen?: boolean sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } 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 902652ad082..14ff327146e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1763,7 +1763,7 @@ describe('createIpcPtyTransport', () => { expect(writeMock).not.toHaveBeenCalled() }) - it('preserves snapshot dimensions when reattaching', async () => { + it('preserves snapshot dimensions and split alt-frame strings when reattaching', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-reattach', @@ -1771,7 +1771,10 @@ describe('createIpcPtyTransport', () => { launchAgent: 'droid', snapshot: 'snapshot data', snapshotCols: 132, - snapshotRows: 43 + snapshotRows: 43, + snapshotPrefixAnsi: 'history and modes', + snapshotFrameAnsi: 'visual frame', + snapshotFrameRestoreAnsi: 'live state' }) ;(globalThis as { window: typeof window }).window = { @@ -1811,6 +1814,9 @@ describe('createIpcPtyTransport', () => { snapshot: 'snapshot data', snapshotCols: 132, snapshotRows: 43, + snapshotPrefixAnsi: 'history and modes', + snapshotFrameAnsi: 'visual frame', + snapshotFrameRestoreAnsi: 'live state', isAlternateScreen: undefined, coldRestore: undefined, replay: undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f7248ec704d..3198281df9b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -893,6 +893,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra snapshot: spawnResult.snapshot, snapshotCols: spawnResult.snapshotCols, snapshotRows: spawnResult.snapshotRows, + ...(spawnResult.snapshotPrefixAnsi !== undefined + ? { snapshotPrefixAnsi: spawnResult.snapshotPrefixAnsi } + : {}), + ...(spawnResult.snapshotFrameAnsi !== undefined + ? { snapshotFrameAnsi: spawnResult.snapshotFrameAnsi } + : {}), + ...(spawnResult.snapshotFrameRestoreAnsi !== undefined + ? { snapshotFrameRestoreAnsi: spawnResult.snapshotFrameRestoreAnsi } + : {}), isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index ccae12ee863..9a3aa6621c5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, - resolvePositiveTerminalDimensions + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch } from './terminal-snapshot-replay-paint' +function writeTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + describe('hasPositiveTerminalDimensions', () => { it('accepts only finite positive numeric pairs', () => { expect(hasPositiveTerminalDimensions(80, 24)).toBe(true) @@ -66,3 +72,104 @@ describe('buildMainModelSnapshotReplayWrites', () => { ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame']) }) }) + +describe('shouldSkipAltFrameForWidthMismatch', () => { + it('skips only when the snapshot is WIDER than the target', () => { + expect(shouldSkipAltFrameForWidthMismatch(135, 128)).toBe(true) + expect(shouldSkipAltFrameForWidthMismatch(129, 128)).toBe(true) + expect(shouldSkipAltFrameForWidthMismatch(128, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(100, 120)).toBe(false) + }) + + it('never skips when a width is missing or nonsensical', () => { + // Why: an unknown width must not cost the user their restored frame. + expect(shouldSkipAltFrameForWidthMismatch(undefined, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(135, undefined)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(0, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false) + }) + + it('can conservatively skip a live frame until a hidden pane has a final grid', () => { + expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe( + true + ) + expect( + shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true }) + ).toBe(false) + }) +}) + +describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { + it('keeps normal history and a clean alt grid through the real resize path', async () => { + const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) + const snapshot = { + data: '\x1b[1;1HWIDE-FRAME', + frameRestoreAnsi: '\x1b[?25l', + alternateScreen: true, + scrollbackAnsi: 'log' + } + + try { + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame: true })) { + await writeTerminal(terminal, chunk) + } + terminal.resize(4, 5) + + expect(terminal.buffer.active.type).toBe('alternate') + expect(terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('') + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('log') + } finally { + terminal.dispose() + } + }) + + it('drops only the frame paint, keeping scrollback and the alt-buffer choreography', () => { + expect( + buildMainModelSnapshotReplayWrites( + { + data: 'mode-prefixalt-frame', + frameRestoreAnsi: 'complete-live-state', + alternateScreen: true, + scrollbackAnsi: 'normal-history' + }, + { skipAltFrame: true } + ) + ).toEqual([ + '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + 'normal-history', + '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + 'complete-live-state' + ]) + }) + + it('still enters a cleared alt screen when skipping without split scrollback', () => { + // Why the clear still runs: the caller's SIGWINCH must land on a clean + // screen the application repaints, not the stale pre-park frame. + expect( + buildMainModelSnapshotReplayWrites( + { + data: 'mode-prefixalt-frame', + frameRestoreAnsi: 'complete-live-state', + alternateScreen: true + }, + { skipAltFrame: true } + ) + ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) + }) + + it('keeps composed data when an older producer omits the mode boundary', () => { + expect( + buildMainModelSnapshotReplayWrites( + { data: 'legacy-modes-and-frame', alternateScreen: true }, + { skipAltFrame: true } + ) + ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame']) + }) + + it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => { + expect( + buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true }) + ).toEqual(['\x1b[2J\x1b[3J\x1b[H', 'shell-output']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index b17bdd5435a..643ca0de925 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -1,3 +1,6 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { readProposedPaneFitDimensions } from '@/lib/pane-manager/pane-fit' + /** * Shared guards and write choreography for painting a main-model snapshot into * a (possibly fresh) xterm. One source for the reattach/hidden-restore paint @@ -28,23 +31,65 @@ export function resolvePositiveTerminalDimensions( : null } +/** + * The column count the post-replay fit will land on. Why not terminal.cols: a + * pane that has not been fitted yet still reads xterm's 80-column default, so + * comparing against it would drop frames whose width actually matches the + * container. Returns undefined when the pane cannot be measured; callers with + * a repaint owner can clear and defer, while offline preconnect paint cannot. + */ +export function readProposedTerminalCols(pane: ManagedPane): number | undefined { + return readProposedPaneFitDimensions(pane)?.cols +} + +export function shouldSkipAltFrameForWidthMismatch( + snapshotCols: number | undefined, + targetCols: number | undefined, + options: { skipIfTargetUnknown?: boolean } = {} +): boolean { + if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) { + return false + } + if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) { + // A hidden pane has no final grid; its live app can repaint after the deferred fit. + return options.skipIfTargetUnknown === true + } + // Fixed-grid alt rows clip at narrower columns; normal history remains reflowable. + return snapshotCols > targetCols +} + /** * Ordered replay writes for a main-model snapshot, including the alt-screen * choreography: main strips the `?1049h` marker when splitting scrollbackAnsi * from an alt frame, so the restorer owns the transition — rebuild the normal * buffer while on it, then paint the alt frame clean. Callers write these * before their post-replay reset/escape-tail sequences. + * + * `skipAltFrame` drops only the frame paint, never the buffer choreography or + * scrollback or mode rehydration: the alt buffer is still entered and cleared + * so the caller's SIGWINCH lands on a clean screen the application repaints. */ -export function buildMainModelSnapshotReplayWrites(snapshot: { - data: string - alternateScreen?: boolean - scrollbackAnsi?: string -}): string[] { +export function buildMainModelSnapshotReplayWrites( + snapshot: { + data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string + alternateScreen?: boolean + scrollbackAnsi?: string + }, + options: { skipAltFrame?: boolean } = {} +): string[] { if (!snapshot.alternateScreen) { // Why: \x1b[3J wipes xterm scrollback; safe here because a normal-buffer // snapshot carries its own history in data (mirrors pty-transport.ts). return ['\x1b[2J\x1b[3J\x1b[H', snapshot.data] } + // Older snapshot producers do not expose the mode/frame boundary. Keep their + // composed data rather than dropping terminal modes together with the frame. + const altFrame = + options.skipAltFrame && snapshot.frameRestoreAnsi !== undefined + ? [snapshot.frameRestoreAnsi] + : [snapshot.data] if (snapshot.scrollbackAnsi !== undefined) { // Why: main serializes normal + alt buffers separately; rebuild normal // while active, then return to a clean alt frame. @@ -52,11 +97,11 @@ export function buildMainModelSnapshotReplayWrites(snapshot: { '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', snapshot.scrollbackAnsi, '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', - snapshot.data + ...altFrame ] } // Why: the snapshot's ?1049h no-ops when already on alt screen and skips // blank cells; clear the alt buffer so the pre-hide frame can't bleed // through blank cells (spares normal-buffer scrollback). - return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', snapshot.data] + return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', ...altFrame] } diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index 70e7d20526d..aa97a9a1887 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' -import { safeFit, safeFitAndThen } from './pane-fit' +import { readProposedPaneFitDimensions, safeFit, safeFitAndThen } from './pane-fit' import { applyOrDeferPaneMetricOptions } from './pane-metric-options-deferral' import { paneFitClientSizeChanged } from './pane-reveal-fit' +import { setFitOverride } from './mobile-fit-overrides' vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() @@ -502,4 +503,64 @@ describe('deferred metric flush inside safeFit', () => { expect(pane.terminal.options.fontSize).toBe(12) expect(pane.fitAddon.fit).toHaveBeenCalled() }) + + it('reports the post-metric grid used by the next safe fit', () => { + const terminal = { cols: 80, rows: 24, options: {} as Record } + const pane = { + id: 12, + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 500, height: 300 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: vi.fn(() => + Number(terminal.options.fontSize ?? 10) >= 18 + ? { cols: 20, rows: 10 } + : { cols: 40, rows: 20 } + ) + } + } as unknown as ManagedPane + applyOrDeferPaneMetricOptions(pane, { fontSize: 18 }, false) + + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 20, rows: 10 }) + expect(pane.terminal.options.fontSize).toBe(18) + }) + + it('reports an owner override even while the pane is unmeasurable', () => { + const resize = vi.fn() + const pane = { + terminal: { cols: 80, rows: 24, options: {}, resize }, + container: { + dataset: { ptyId: 'pty-override' }, + getBoundingClientRect: () => ({ width: 0, height: 0 }) + }, + fitAddon: { proposeDimensions: vi.fn() } + } as unknown as ManagedPane + setFitOverride('pty-override', 'mobile-fit', 49, 20) + + try { + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 49, rows: 20 }) + expect(pane.fitAddon.proposeDimensions).not.toHaveBeenCalled() + expect(safeFit(pane)).toBe(false) + expect(resize).not.toHaveBeenCalled() + } finally { + setFitOverride('pty-override', 'desktop-fit', 0, 0) + } + }) + + it('flushes deferred metrics before reporting a measurable owner override', () => { + const pane = createMetricPane() + pane.container.dataset.ptyId = 'pty-metric-override' + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, false) + setFitOverride('pty-metric-override', 'mobile-fit', 49, 20) + + try { + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 49, rows: 20 }) + expect(pane.terminal.options.fontSize).toBe(12) + } finally { + setFitOverride('pty-metric-override', 'desktop-fit', 0, 0) + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index c9807dbb10a..f7165c3aad6 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -55,6 +55,28 @@ export type SafeFitContinuationHandle = { export { readFitClientSize } from './pane-fit-client-size' +/** Returns the exact dimensions the next safe fit will use after any applicable + * deferred metrics. An owner override remains measurable while its pane is hidden. */ +export function readProposedPaneFitDimensions( + pane: ManagedPane +): { cols: number; rows: number } | null { + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + const measurable = canMeasurePaneForFit(pane) + if (!measurable && !override) { + return null + } + // Re-check after the metric flush: larger cells can push a narrow pane below + // the fit floor even though it was measurable with the old metrics. + if (measurable && flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { + return null + } + if (override) { + return { cols: override.cols, rows: override.rows } + } + return getProposedPaneDimensions(pane) +} + function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { // Why: split reparent has its own delayed restore; restoring here can fight that timer. return !( @@ -70,10 +92,7 @@ function performSafeFit(pane: ManagedPane): boolean { return false } // Why here: metric options deferred while the pane was unmeasurable must land - // before this fit reads dimensions, or the fit pins cols/rows to stale metrics. - // Why re-check: the gate above measured with the old cell size — a large font - // jump on a narrow pane can drop it under the floor, and fit() would then pin - // the PTY at the tiny grid that floor exists to prevent. + // before this fit reads dimensions, then the fit floor must be checked again. if (flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { return false } diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts index 18902b27426..ee2ac371296 100644 --- a/src/shared/terminal-serialize-absolute-cursor.ts +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -19,7 +19,7 @@ type BufferSerializer = { } /** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */ -export type SavedCursorRegister = { x: number; y: number } +export type SavedCursorRegister = { x: number; y: number; originMode: boolean } // xterm keeps the DECSC register on each Buffer (savedY is absolute: // ybase-included). It is not exposed through the public API, so snapshot @@ -27,7 +27,17 @@ export type SavedCursorRegister = { x: number; y: number } // buffer, so an alt-screen TUI yields the alternate screen's own register, // matching the one a post-restore DECRC would consult. type TerminalWithSavedCursorCore = SerializeCursorTerminal & { - _core?: { buffer?: { savedX?: number; savedY?: number; ybase?: number } } + modes?: { originMode?: boolean } + _core?: { + buffer?: { + savedX?: number + savedY?: number + savedOriginMode?: boolean + ybase?: number + scrollTop?: number + scrollBottom?: number + } + } } /** Reads the source terminal's active-buffer DECSC register, or null when it @@ -48,13 +58,14 @@ export function readSavedCursorRegister( // cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp. const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1) const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1) - if (x === 0 && y === 0) { + const originMode = core.savedOriginMode === true + if (x === 0 && y === 0 && !originMode) { // Home is xterm's never-saved default: a fresh restore terminal already // sends DECRC to home, and skipping the injection avoids overwriting the // fresh terminal's default saved SGR/charset when nothing was ever saved. return null } - return { x, y } + return { x, y, originMode } } export function serializeWithAbsoluteCursor( @@ -70,15 +81,30 @@ export function serializeWithAbsoluteCursor( if (serialized.length === 0) { return serialized } + return `${serialized}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}` +} + +/** Cursor state appended after serialized modes; safe to replay without the frame body. */ +export function buildAbsoluteCursorRestoreSequence( + terminal: SerializeCursorTerminal, + savedCursor?: SavedCursorRegister | null, + options: { restoreModesWithoutCursor?: boolean } = {} +): string { const { cursorX, cursorY } = terminal.buffer.active + const terminalWithCore = terminal as TerminalWithSavedCursorCore + const buffer = terminalWithCore._core?.buffer + const scrollTop = buffer?.scrollTop ?? 0 + const scrollBottom = buffer?.scrollBottom ?? terminal.rows - 1 + const originMode = terminalWithCore.modes?.originMode === true + const cupCursorY = cursorY - (originMode ? scrollTop : 0) // Why skip wrap-pending sources (cursorX == cols): plain replay already // reproduces that state exactly, while CUP would clamp to the last column // and clear the pending-wrap flag, changing how the next byte renders. // The remaining bounds checks are defensive: never emit a clamping CUP. - // The saved-cursor injection is skipped with it — it moves the cursor, so - // it may only ride along when the absolute CUP restores the position after. - if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) { - return serialized + const canRestoreCurrentCursor = + cursorX >= 0 && cursorX < terminal.cols && cupCursorY >= 0 && cupCursorY < terminal.rows + if (!canRestoreCurrentCursor && options.restoreModesWithoutCursor !== true) { + return '' } // Why the DECSC injection: the serialized screen cannot carry the VT100 // saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC @@ -87,9 +113,21 @@ export function serializeWithAbsoluteCursor( // the source's saved position, then CUP back to the real cursor. Saved SGR/ // charset are not carried — the synthetic ESC 7 saves the serializer's // final pen, a deliberate position-only fidelity trade. - const savedRestore = savedCursor ? `\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` : '' - // cursorY is viewport-relative (0 at the buffer's base row), which is the - // same coordinate space CUP addresses after replay; scrollback length - // differences between source and destination do not shift it. - return `${serialized}${savedRestore}\x1b[${cursorY + 1};${cursorX + 1}H` + // Install the saved register against a full region so its absolute row is + // representable even when it predates the current DECSTBM/DECOM state. + const savedRestore = savedCursor + ? `\x1b[r\x1b[?6${savedCursor.originMode ? 'h' : 'l'}\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` + : '' + const mustRestoreModes = savedCursor != null || options.restoreModesWithoutCursor === true + const scrollRegionRestore = + mustRestoreModes && (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) + ? `\x1b[${scrollTop + 1};${scrollBottom + 1}r` + : '' + const originModeRestore = mustRestoreModes ? `\x1b[?6${originMode ? 'h' : 'l'}` : '' + // CUP rows become margin-relative under DECOM; ordinary snapshots keep the + // zero offset because scrollback length does not shift viewport coordinates. + const currentCursorRestore = canRestoreCurrentCursor + ? `\x1b[${cupCursorY + 1};${cursorX + 1}H` + : '' + return `${savedRestore}${scrollRegionRestore}${originModeRestore}${currentCursorRestore}` } From 1874ac325b13c43232f630e15a973117c0b73191 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:31:57 -0700 Subject: [PATCH 12/69] perf(renderer): own oversized automation output tails (#13581) --- ...on-run-output-snapshot-equivalence.test.ts | 19 ++++++++++++++++++ .../automation-run-output-snapshot.test.ts | 16 +++++++++++++++ .../automation-run-output-snapshot.ts | 6 +++++- src/shared/owned-utf16-suffix.ts | 20 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/shared/owned-utf16-suffix.ts diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts index 6397a3fa90c..b988ad96581 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts @@ -103,4 +103,23 @@ describe('automation run output snapshot queue equivalence', () => { expect(candidate.snapshot()).toEqual(reference.snapshot()) }) + + it('matches the shift reference across oversized and subsequent appends', () => { + const retained = `\ude00${'A'.repeat(MAX_OUTPUT_SNAPSHOT_CHARS - 2)}\ud83d` + const chunks = [ + 'older output', + `discarded\u001b[31m prefix${retained}`, + 'TAIL', + `${'B'.repeat(MAX_OUTPUT_SNAPSHOT_CHARS)}extra`, + '\u001b[0mDone\r\n' + ] + const reference = createShiftReferenceBuffer() + const candidate = createAutomationRunOutputSnapshotBuffer() + + for (const chunk of chunks) { + reference.append(chunk) + candidate.append(chunk) + expect(candidate.snapshot()).toEqual(reference.snapshot()) + } + }) }) diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts index 6dcc87cfc53..4bf868bf94e 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts @@ -132,6 +132,22 @@ describe('automation run output snapshot buffer', () => { }) }) + it('owns the exact UTF-16 tail of one oversized chunk', () => { + const buffer = createAutomationRunOutputSnapshotBuffer() + const retained = `\ude00${'A'.repeat(256 * 1024 - 2)}\ud83d` + + buffer.append('older output') + buffer.append(`discarded prefix${retained}`) + + expect(buffer.snapshot()).toMatchObject({ content: retained, truncated: true }) + + buffer.append('TAIL') + expect(buffer.snapshot()).toMatchObject({ + content: `${retained.slice(4)}TAIL`, + truncated: true + }) + }) + it('creates a saved snapshot from agent transcript text', () => { expect(createAutomationRunOutputSnapshotFromText('\nFinal summary.\n')).toEqual({ format: 'plain_text', diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index fb493e35cdf..1700b2b6ede 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -3,6 +3,7 @@ import { stripAnsiEscapeSequences, TERMINAL_CONTROL_CHARACTER_PATTERN } from '../../../../shared/ansi-escape-sequences' +import { copyUtf16SuffixToOwnedString } from '../../../../shared/owned-utf16-suffix' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 const OUTPUT_SNAPSHOT_COMPACTION_HEAD_THRESHOLD = 64 @@ -68,7 +69,10 @@ export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSn truncated = true continue } - chunks[firstChunkIndex] = firstChunk.slice(overflowChars) + chunks[firstChunkIndex] = + firstChunk.length > MAX_OUTPUT_SNAPSHOT_CHARS + ? copyUtf16SuffixToOwnedString(firstChunk, firstChunk.length - overflowChars) + : firstChunk.slice(overflowChars) totalChars -= overflowChars truncated = true overflowChars = 0 diff --git a/src/shared/owned-utf16-suffix.ts b/src/shared/owned-utf16-suffix.ts new file mode 100644 index 00000000000..19d623db60e --- /dev/null +++ b/src/shared/owned-utf16-suffix.ts @@ -0,0 +1,20 @@ +const COPY_BLOCK_CODE_UNITS = 4 * 1024 + +export function copyUtf16SuffixToOwnedString(value: string, suffixCodeUnits: number): string { + const suffixLength = Math.max(0, Math.min(value.length, suffixCodeUnits)) + const start = value.length - suffixLength + const blocks: string[] = [] + const codeUnits: number[] = [] + codeUnits.length = Math.min(COPY_BLOCK_CODE_UNITS, suffixLength) + + // Why: slice can retain an arbitrarily large V8 backing string. + for (let offset = start; offset < value.length; offset += COPY_BLOCK_CODE_UNITS) { + const blockLength = Math.min(COPY_BLOCK_CODE_UNITS, value.length - offset) + codeUnits.length = blockLength + for (let index = 0; index < blockLength; index += 1) { + codeUnits[index] = value.charCodeAt(offset + index) + } + blocks.push(String.fromCharCode(...codeUnits)) + } + return blocks.join('') +} From fc7387b28b5d84c82b9792bd5298f2325faed435 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:32:04 -0700 Subject: [PATCH 13/69] perf(runtime): index PTY worktree inventory lookups (#13690) --- .../folder-workspace-pty-identity.test.ts | 120 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 47 ++++++- 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index 71c18b9412a..d986fa54938 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -134,6 +134,126 @@ describe('folder workspaces sharing one directory', () => { expect([...(inventory?.livePtyIds ?? [])]).toEqual([PTY_B]) }) + it('keeps the first resolved worktree when normalized identities collide', async () => { + const firstId = `${ROOT_ID}/duplicate/` + const equivalentId = `${ROOT_ID}/duplicate` + const laterId = `${ROOT_ID}/later` + const internals = createRuntimeInternals({ + sessions: [ + { id: 'later-owner-pty', worktreeId: laterId, cwd: FOLDER_PATH, title: 'shell' }, + { id: 'duplicate-owner-pty', worktreeId: equivalentId, cwd: FOLDER_PATH, title: 'shell' } + ] + }) + const resolvedWorktrees = [firstId, equivalentId, laterId].map((id) => + internals.buildResolvedWorktreeFromId(id) + ) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('duplicate-owner-pty')?.worktreeId).toBe(firstId) + }) + + it('stops indexing after a sparse owner match', async () => { + const ownerId = `${ROOT_ID}/first` + const internals = createRuntimeInternals({ + sessions: [{ id: 'sparse-owner-pty', worktreeId: ownerId, cwd: FOLDER_PATH, title: 'shell' }] + }) + let identityReads = 0 + const resolvedWorktrees = [ownerId, `${ROOT_ID}/unused-a`, `${ROOT_ID}/unused-b`].map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('sparse-owner-pty')?.worktreeId).toBe(ownerId) + expect(identityReads).toBe(2) + }) + + it('keeps parsed and raw identity domains distinct', async () => { + const parsedId = `${ROOT_ID}/collision` + const rawId = `${REPO_ID}\0${FOLDER_PATH}/collision` + const internals = createRuntimeInternals({ + sessions: [{ id: 'raw-owner-pty', worktreeId: rawId, cwd: FOLDER_PATH, title: 'shell' }] + }) + const parsedWorktree = internals.buildResolvedWorktreeFromId(parsedId) + if (!parsedWorktree || typeof parsedWorktree !== 'object') { + throw new Error(`Failed to resolve ${parsedId}`) + } + const rawWorktree = { ...parsedWorktree, id: rawId } + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([parsedWorktree, rawWorktree]) + + expect(internals.ptysById.get('raw-owner-pty')?.worktreeId).toBe(rawId) + }) + + it('bounds provider and persisted worktree resolution to linear identity reads', async () => { + const worktreeCount = 128 + const worktreeIds = Array.from( + { length: worktreeCount }, + (_, index) => `${ROOT_ID}/worktree-${index}` + ) + const persistedSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: Object.fromEntries( + worktreeIds.map((worktreeId, index) => [ + worktreeId, + [ + { + id: `indexed-tab-${index}`, + ptyId: `indexed-pty-${index}`, + worktreeId, + title: 'shell' + } + ] + ]) + ) as never + } + const internals = createRuntimeInternals({ + session: persistedSession, + sessions: worktreeIds.map((worktreeId, index) => ({ + id: `indexed-pty-${index}`, + worktreeId, + cwd: `${FOLDER_PATH}/worktree-${index}`, + title: 'shell' + })) + }) + let identityReads = 0 + const resolvedWorktrees = worktreeIds.map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.size).toBe(worktreeCount) + expect(internals.ptysById.get('indexed-pty-0')?.worktreeId).toBe(worktreeIds[0]) + expect(internals.ptysById.get(`indexed-pty-${worktreeCount - 1}`)?.worktreeId).toBe( + worktreeIds[worktreeCount - 1] + ) + expect(identityReads).toBe(worktreeCount * 2) + }) + it('reports live PTYs per workspace instance and for the folder root separately', () => { const internals = createRuntimeInternals() internals.recordPtyWorktree(PTY_A, WORKSPACE_A, { connected: true }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 095b6e98ee4..b0bff00d79d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29308,6 +29308,7 @@ export class OrcaRuntimeService { for (const ptyId of ambiguousControllerPtyIds) { controllerIdentityByPtyId.delete(ptyId) } + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) const persistedIndexesByHostId = new Map< ExecutionHostId, { @@ -29339,14 +29340,12 @@ export class OrcaRuntimeService { ) const controllerIdentity = controllerIdentityByPtyId.get(session.id) const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) - const providerWorktree = resolvedWorktrees.find( - (worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId) - ) + const providerWorktree = session.worktreeId + ? findResolvedWorktree(session.worktreeId) + : undefined const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) const persistedWorktree = persistedWorktreeId - ? resolvedWorktrees.find((worktree) => - runtimeWorktreeIdsEqual(worktree.id, persistedWorktreeId) - ) + ? findResolvedWorktree(persistedWorktreeId) : undefined const hasMigrationEvidence = Boolean(session.worktreeId) && @@ -36868,6 +36867,42 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string { : worktreeId } +function runtimeWorktreeLookupKey(worktreeId: string): string { + const parsed = splitWorktreeId(worktreeId) + return JSON.stringify( + parsed + ? ['parsed', parsed.repoId, normalizeRuntimePathForComparison(parsed.worktreePath)] + : ['raw', worktreeId] + ) +} + +function createIncrementalResolvedWorktreeLookup( + resolvedWorktrees: ResolvedWorktree[] +): (worktreeId: string) => ResolvedWorktree | undefined { + const worktreeByIdentity = new Map() + let indexedCount = 0 + return (worktreeId) => { + const lookupKey = runtimeWorktreeLookupKey(worktreeId) + const indexed = worktreeByIdentity.get(lookupKey) + if (indexed) { + return indexed + } + while (indexedCount < resolvedWorktrees.length) { + const worktree = resolvedWorktrees[indexedCount] + indexedCount += 1 + const key = runtimeWorktreeLookupKey(worktree.id) + // Why: preserve Array.find's first match when normalized identities collide. + if (!worktreeByIdentity.has(key)) { + worktreeByIdentity.set(key, worktree) + } + if (key === lookupKey) { + return worktreeByIdentity.get(key) + } + } + return undefined + } +} + function resolveTerminalSessionWorktreeId( session: WorkspaceSessionState, targetWorktreeId: string From 5cd7b0a9046fa0fac7341517c24180c4f9a4cb6b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:32:12 -0700 Subject: [PATCH 14/69] perf(renderer): keep shutdown rollback buffering linear (#13510) --- .../pty-shutdown-data-suspension.test.ts | 79 ++++- .../pty-shutdown-data-suspension.ts | 68 ++-- .../pty-shutdown-output-queue.test.ts | 290 ++++++++++++++++++ .../pty-shutdown-output-queue.ts | 149 +++++++++ 4 files changed, 538 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts index 45846d08143..fcaf70e0a5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts @@ -3,12 +3,16 @@ import { bufferPtyShutdownData, bufferPtyShutdownReplayData, drainRolledBackPtyShutdownData, + isPtyDataHandlerShutdownPending, ptyDataHandlers, + ptyDataSidecars, ptyReplayHandlers, ptyShutdownLifecycleHandlers, ptyTeardownHandlers, unregisterPtyDataHandlers } from './pty-shutdown-data-suspension' +import { bufferPreHandlerPtyData } from './pty-pre-handler-buffer' +import { PtyShutdownOutputQueue } from './pty-shutdown-output-queue' it('does not remove handlers installed by a remount while sleep is pending', () => { const ptyId = 'pty-shutdown-remount' @@ -33,16 +37,45 @@ it('does not remove handlers installed by a remount while sleep is pending', () it('replays rollback output in its original replay and live arrival order', () => { const ptyId = 'pty-shutdown-output-order' const delivered: string[] = [] + const meta = { seq: 9, rawLength: 3, transformed: true } + const sidecar = vi.fn((data: string) => delivered.push(`sidecar:${data}`)) ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`)) ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) + ptyDataSidecars.set(ptyId, new Set([sidecar])) const [snapshot] = unregisterPtyDataHandlers([ptyId]) bufferPtyShutdownReplayData(ptyId, 'old') - bufferPtyShutdownData(ptyId, 'new') + bufferPtyShutdownData(ptyId, 'new', meta) snapshot.rollback() - expect(delivered).toEqual(['replay:old', 'data:new']) + expect(delivered).toEqual(['replay:old', 'data:new', 'sidecar:new']) + expect(sidecar).toHaveBeenCalledOnce() ptyDataHandlers.delete(ptyId) + ptyDataSidecars.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) + +it('drains pre-handler data before ordered shutdown output', () => { + const ptyId = 'pty-shutdown-pre-handler-order' + const delivered: string[] = [] + const preHandlerMeta = { seq: 1, rawLength: 3 } + const shutdownMeta = { seq: 2, rawLength: 4, background: true } + const dataHandler = vi.fn((data: string) => delivered.push(`data:${data}`)) + ptyDataHandlers.set(ptyId, dataHandler) + ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) + ptyDataSidecars.set(ptyId, new Set([(data) => delivered.push(`sidecar:${data}`)])) + + const [snapshot] = unregisterPtyDataHandlers([ptyId]) + bufferPreHandlerPtyData(ptyId, 'pre', preHandlerMeta) + bufferPtyShutdownReplayData(ptyId, 'old') + bufferPtyShutdownData(ptyId, 'live', shutdownMeta) + snapshot.rollback() + + expect(delivered).toEqual(['data:pre', 'sidecar:pre', 'replay:old', 'data:live', 'sidecar:live']) + expect(dataHandler).toHaveBeenNthCalledWith(1, 'pre', preHandlerMeta) + expect(dataHandler).toHaveBeenNthCalledWith(2, 'live', shutdownMeta) + ptyDataHandlers.delete(ptyId) + ptyDataSidecars.delete(ptyId) ptyReplayHandlers.delete(ptyId) }) @@ -50,27 +83,65 @@ it('retains ordered rollback output across detach and another pending shutdown', const ptyId = 'pty-shutdown-detached-overlap' const originalData = vi.fn() const originalReplay = vi.fn() + const drain = vi.spyOn(PtyShutdownOutputQueue.prototype, 'drain') + const encode = vi.spyOn(TextEncoder.prototype, 'encode') ptyDataHandlers.set(ptyId, originalData) ptyReplayHandlers.set(ptyId, originalReplay) const [first] = unregisterPtyDataHandlers([ptyId]) ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) - bufferPtyShutdownData(ptyId, 'live-first') + const meta = { seq: 3, rawLength: 10, transformed: true } + bufferPtyShutdownData(ptyId, 'live-first', meta) bufferPtyShutdownReplayData(ptyId, 'replay-second') first.rollback() + expect(drain).not.toHaveBeenCalled() const [second] = unregisterPtyDataHandlers([ptyId]) + expect(encode).not.toHaveBeenCalled() const delivered: string[] = [] - ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`)) + const replacementData = vi.fn((data: string) => delivered.push(`data:${data}`)) + ptyDataHandlers.set(ptyId, replacementData) ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) drainRolledBackPtyShutdownData(ptyId) expect(delivered).toEqual([]) + expect(drain).not.toHaveBeenCalled() second.rollback() expect(delivered).toEqual(['data:live-first', 'replay:replay-second']) + expect(replacementData).toHaveBeenCalledWith('live-first', meta) + expect(drain).toHaveBeenCalledOnce() + drain.mockRestore() + encode.mockRestore() ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) ptyTeardownHandlers.delete(ptyId) ptyShutdownLifecycleHandlers.delete(ptyId) }) + +it('detaches the delivered batch from a reentrant shutdown queue', () => { + const ptyId = 'pty-shutdown-reentrant-delivery' + const delivered: string[] = [] + let reentrantSnapshot: ReturnType[number] | undefined + ptyDataHandlers.set(ptyId, (data) => { + delivered.push(data) + if (data === 'old-first') { + reentrantSnapshot = unregisterPtyDataHandlers([ptyId])[0] + bufferPtyShutdownData(ptyId, 'new-pending') + } + }) + ptyReplayHandlers.set(ptyId, vi.fn()) + + const [first] = unregisterPtyDataHandlers([ptyId]) + bufferPtyShutdownData(ptyId, 'old-first') + bufferPtyShutdownData(ptyId, 'old-second') + first.rollback() + + expect(delivered).toEqual(['old-first', 'old-second']) + expect(isPtyDataHandlerShutdownPending(ptyId)).toBe(true) + reentrantSnapshot?.rollback() + expect(delivered).toEqual(['old-first', 'old-second', 'new-pending']) + expect(isPtyDataHandlerShutdownPending(ptyId)).toBe(false) + ptyDataHandlers.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 0bc654bfe8e..508ea88b2be 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -1,7 +1,6 @@ -import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { clampUtf8Tail } from './pty-eager-buffer-clamp' import { clearPreHandlerPtyState, drainPreHandlerPtyData } from './pty-pre-handler-buffer' import type { PtyDataMeta } from './pty-dispatcher' +import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' export const ptyDataHandlers = new Map void>() export const ptyDataSidecars = new Map void>>() @@ -25,8 +24,7 @@ export type PtyDataHandlerShutdownSnapshot = { type PendingPtyHandlerShutdown = { owners: number committed: boolean - bufferedBytes: number - events: PtyShutdownOutputEvent[] + outputQueue: PtyShutdownOutputQueue dataHandler?: (data: string, meta?: PtyDataMeta) => void replayHandler?: (data: string) => void teardownHandler?: () => void @@ -34,13 +32,8 @@ type PendingPtyHandlerShutdown = { } const pendingPtyHandlerShutdowns = new Map() -type PtyShutdownOutputEvent = - | { kind: 'data'; data: string; meta?: PtyDataMeta } - | { kind: 'replay'; data: string } - -const rolledBackShutdownEvents = new Map() +const rolledBackShutdownEvents = new Map() const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64 -const shutdownBufferTextEncoder = new TextEncoder() /** Suspend delivery until every overlapping shutdown owner commits or rolls back. */ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] { @@ -50,16 +43,12 @@ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutd if (pending) { pending.owners += 1 } else { - const retainedEvents = rolledBackShutdownEvents.get(id) ?? [] + const outputQueue = rolledBackShutdownEvents.get(id) ?? new PtyShutdownOutputQueue() rolledBackShutdownEvents.delete(id) pending = { owners: 1, committed: false, - bufferedBytes: retainedEvents.reduce( - (total, event) => total + shutdownBufferTextEncoder.encode(event.data).byteLength, - 0 - ), - events: retainedEvents, + outputQueue, dataHandler: ptyDataHandlers.get(id), replayHandler: ptyReplayHandlers.get(id), teardownHandler: ptyTeardownHandlers.get(id), @@ -113,17 +102,7 @@ function bufferPtyShutdownOutput(ptyId: string, event: PtyShutdownOutputEvent): if (!pending) { return false } - const clamped = clampUtf8Tail(event.data, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) - pending.events.push({ ...event, data: clamped.data }) - pending.bufferedBytes += clamped.bytes - while ( - pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT && - pending.events.length > 1 - ) { - pending.bufferedBytes -= shutdownBufferTextEncoder.encode( - pending.events.shift()?.data ?? '' - ).byteLength - } + pending.outputQueue.enqueue(event) return true } @@ -131,14 +110,14 @@ export function drainRolledBackPtyShutdownData(ptyId: string): void { if (pendingPtyHandlerShutdowns.has(ptyId)) { return } - const events = rolledBackShutdownEvents.get(ptyId) + const outputQueue = rolledBackShutdownEvents.get(ptyId) const dataHandler = ptyDataHandlers.get(ptyId) const replayHandler = ptyReplayHandlers.get(ptyId) - if (!events || !dataHandler || !replayHandler) { + if (!outputQueue || !dataHandler || !replayHandler) { return } rolledBackShutdownEvents.delete(ptyId) - deliverShutdownEvents(ptyId, events, dataHandler, replayHandler) + outputQueue.drain((event) => deliverShutdownEvent(ptyId, event, dataHandler, replayHandler)) } function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { @@ -154,6 +133,7 @@ function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { pendingPtyHandlerShutdowns.delete(ptyId) if (pending.committed) { rolledBackShutdownEvents.delete(ptyId) + pending.outputQueue.discard() pending.lifecycleHandler?.commit() deleteCapturedHandler(ptyDataHandlers, ptyId, pending.dataHandler) deleteCapturedHandler(ptyReplayHandlers, ptyId, pending.replayHandler) @@ -173,10 +153,12 @@ function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { const dataHandler = ptyDataHandlers.get(ptyId) const replayHandler = ptyReplayHandlers.get(ptyId) if (dataHandler && replayHandler) { - deliverShutdownEvents(ptyId, pending.events, dataHandler, replayHandler) - } else if (pending.events.length > 0) { + pending.outputQueue.drain((event) => + deliverShutdownEvent(ptyId, event, dataHandler, replayHandler) + ) + } else if (pending.outputQueue.length > 0) { // Why: a hidden pane can detach during the RPC; retain rollback output until both ordered channels reattach. - rolledBackShutdownEvents.set(ptyId, pending.events) + rolledBackShutdownEvents.set(ptyId, pending.outputQueue) while (rolledBackShutdownEvents.size > ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS) { const oldestPtyId = rolledBackShutdownEvents.keys().next().value if (typeof oldestPtyId !== 'string') { @@ -197,20 +179,18 @@ function deleteCapturedHandler( } } -function deliverShutdownEvents( +function deliverShutdownEvent( ptyId: string, - events: readonly PtyShutdownOutputEvent[], + event: PtyShutdownOutputEvent, dataHandler: (data: string, meta?: PtyDataMeta) => void, replayHandler: (data: string) => void ): void { - for (const event of events) { - if (event.kind === 'replay') { - replayHandler(event.data) - continue - } - dataHandler(event.data, event.meta) - for (const sidecar of Array.from(ptyDataSidecars.get(ptyId) ?? [])) { - sidecar(event.data) - } + if (event.kind === 'replay') { + replayHandler(event.data) + return + } + dataHandler(event.data, event.meta) + for (const sidecar of Array.from(ptyDataSidecars.get(ptyId) ?? [])) { + sidecar(event.data) } } diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts new file mode 100644 index 00000000000..b2d835e4258 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' + +const BYTE_LIMIT = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT +const CHUNK_SIZE = 64 + +class ReferenceShutdownOutputQueue { + private events: PtyShutdownOutputEvent[] = [] + private retainedBytes = 0 + private readonly encoder = new TextEncoder() + + enqueue(event: PtyShutdownOutputEvent): void { + const clamped = clampUtf8Tail(event.data, BYTE_LIMIT) + this.events.push({ ...event, data: clamped.data }) + this.retainedBytes += clamped.bytes + while (this.retainedBytes > BYTE_LIMIT && this.events.length > 1) { + this.retainedBytes -= this.encoder.encode(this.events.shift()?.data ?? '').byteLength + } + } + + takeAll(): PtyShutdownOutputEvent[] { + const events = this.events + this.events = [] + this.retainedBytes = 0 + return events + } +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } +} + +function createDifferentialData(value: number): string { + const bucket = value % 100 + if (bucket < 8) { + return '' + } + if (bucket < 48) { + return String.fromCharCode(65 + (value % 26)).repeat(value % 513) + } + if (bucket < 70) { + return '界'.repeat(value % 257) + } + if (bucket < 88) { + return '😀'.repeat(value % 129) + } + if (bucket < 96) { + return `\ud800${'x'.repeat(value % 511)}` + } + return String.fromCharCode(65 + (value % 26)).repeat(64 * 1024) +} + +describe('PTY shutdown output queue', () => { + it('does not shift or re-encode retained events while saturated', () => { + const originalShift = Array.prototype.shift + const originalEncode = TextEncoder.prototype.encode + const queue = new PtyShutdownOutputQueue() + + try { + Object.defineProperties(Array.prototype, { + shift: { + configurable: true, + writable: true, + value() { + throw new Error('Array.shift should not run on enqueue') + } + } + }) + Object.defineProperties(TextEncoder.prototype, { + encode: { + configurable: true, + writable: true, + value() { + throw new Error('TextEncoder.encode should not run on enqueue') + } + } + }) + for (let index = 0; index < 1_536; index += 1) { + queue.enqueue({ kind: 'data', data: 'x'.repeat(1_024), meta: { seq: index } }) + } + } finally { + Object.defineProperties(Array.prototype, { + shift: { configurable: true, writable: true, value: originalShift } + }) + Object.defineProperties(TextEncoder.prototype, { + encode: { configurable: true, writable: true, value: originalEncode } + }) + } + + expect(queue.takeAll()).toHaveLength(512) + }) + + it('keeps exact UTF-8 boundaries, Unicode tails, and metadata identity', () => { + const boundaryQueue = new PtyShutdownOutputQueue() + const emptyMeta = { rawLength: 9, transformed: true, droppedOutput: true } + boundaryQueue.enqueue({ kind: 'replay', data: 'a'.repeat(BYTE_LIMIT) }) + boundaryQueue.enqueue({ kind: 'data', data: '', meta: emptyMeta }) + expect(boundaryQueue.getStorageForTest()).toMatchObject({ + retainedBytes: BYTE_LIMIT, + retainedEvents: 2 + }) + boundaryQueue.enqueue({ kind: 'replay', data: 'b' }) + + const boundary = boundaryQueue.takeAll() + expect(boundary).toEqual([ + { kind: 'data', data: '', meta: emptyMeta }, + { kind: 'replay', data: 'b' } + ]) + expect(boundary[0]?.kind === 'data' ? boundary[0].meta : undefined).toBe(emptyMeta) + + const unicodeQueue = new PtyShutdownOutputQueue() + const emojiTail = '😀'.repeat(BYTE_LIMIT / 4) + const unicodeMeta = { seq: 7, rawLength: emojiTail.length + 1, background: true } + unicodeQueue.enqueue({ kind: 'data', data: `P${emojiTail}`, meta: unicodeMeta }) + const [unicode] = unicodeQueue.takeAll() + expect(unicode?.data).toBe(emojiTail) + expect(unicode?.kind === 'data' ? unicode.meta : undefined).toBe(unicodeMeta) + + const loneSurrogateQueue = new PtyShutdownOutputQueue() + const loneSurrogateTail = `\ud800${'z'.repeat(BYTE_LIMIT - 3)}` + loneSurrogateQueue.enqueue({ kind: 'replay', data: `P${loneSurrogateTail}` }) + expect(loneSurrogateQueue.takeAll()[0]?.data).toBe(loneSurrogateTail) + }) + + it('evicts whole oldest events without merging replay and live boundaries', () => { + const queue = new PtyShutdownOutputQueue() + const meta = { seq: 42, rawLength: BYTE_LIMIT - 3, transformed: true } + queue.enqueue({ kind: 'replay', data: 'old' }) + queue.enqueue({ kind: 'data', data: 'x'.repeat(BYTE_LIMIT - 3), meta }) + queue.enqueue({ kind: 'replay', data: 'new' }) + + const retained = queue.takeAll() + expect(retained).toEqual([ + { kind: 'data', data: 'x'.repeat(BYTE_LIMIT - 3), meta }, + { kind: 'replay', data: 'new' } + ]) + expect(retained[0]?.kind === 'data' ? retained[0].meta : undefined).toBe(meta) + }) + + it('retains zero-byte event boundaries without imposing a count cap', () => { + const queue = new PtyShutdownOutputQueue() + for (let index = 0; index < 4_097; index += 1) { + queue.enqueue({ kind: 'data', data: '', meta: { seq: index, transformed: true } }) + } + + expect(queue.getStorageForTest()).toMatchObject({ + retainedBytes: 0, + retainedEvents: 4_097 + }) + const retained = queue.takeAll() + expect(retained).toHaveLength(4_097) + expect(retained[0]).toEqual({ kind: 'data', data: '', meta: { seq: 0, transformed: true } }) + expect(retained.at(-1)).toEqual({ + kind: 'data', + data: '', + meta: { seq: 4_096, transformed: true } + }) + }) + + it('repeatedly compacts cleared prefixes and resets transferred storage', () => { + const queue = new PtyShutdownOutputQueue() + const fullEvent = '界'.repeat(Math.floor(BYTE_LIMIT / 3)) + for (let index = 0; index < 513; index += 1) { + queue.enqueue({ kind: 'replay', data: fullEvent }) + const storage = queue.getStorageForTest() + expect(storage.retainedEvents).toBe(1) + expect(storage.retainedBytes).toBe(BYTE_LIMIT - 2) + expect(storage.backingLength).toBeLessThanOrEqual(CHUNK_SIZE) + expect(storage.byteBackingLength).toBeLessThanOrEqual(CHUNK_SIZE) + } + + expect(queue.takeAll()).toEqual([{ kind: 'replay', data: fullEvent }]) + expect(queue.getStorageForTest()).toEqual({ + backingLength: 0, + byteBackingLength: 0, + chunkBackingLength: 0, + head: 0, + headChunk: 0, + retainedBytes: 0, + retainedEvents: 0 + }) + + queue.enqueue({ kind: 'data', data: 'discarded' }) + queue.discard() + expect(queue.takeAll()).toEqual([]) + }) + + it('releases dead backing storage under release-scale one-byte churn', () => { + const queue = new PtyShutdownOutputQueue() + for (let index = 0; index < BYTE_LIMIT + CHUNK_SIZE * 3 + 17; index += 1) { + queue.enqueue({ kind: 'replay', data: 'x' }) + } + + const storage = queue.getStorageForTest() + expect(storage.retainedEvents).toBe(BYTE_LIMIT) + expect(storage.retainedBytes).toBe(BYTE_LIMIT) + expect(storage.backingLength - storage.retainedEvents).toBeLessThan(CHUNK_SIZE) + expect(storage.byteBackingLength).toBe(0) + expect(storage.head).toBeLessThan(CHUNK_SIZE) + expect(storage.headChunk).toBeLessThan(storage.chunkBackingLength) + }) + + it('repeatedly compacts the consumed chunk cursor', () => { + const queue = new PtyShutdownOutputQueue() + const data = 'x'.repeat(BYTE_LIMIT / CHUNK_SIZE) + for (let index = 0; index < CHUNK_SIZE; index += 1) { + queue.enqueue({ kind: 'replay', data }) + } + + for (let cycle = 0; cycle < 3; cycle += 1) { + for (let index = 0; index < CHUNK_SIZE * CHUNK_SIZE; index += 1) { + queue.enqueue({ kind: 'replay', data }) + } + expect(queue.getStorageForTest()).toEqual({ + backingLength: CHUNK_SIZE, + byteBackingLength: 0, + chunkBackingLength: 1, + head: 0, + headChunk: 0, + retainedBytes: BYTE_LIMIT, + retainedEvents: CHUNK_SIZE + }) + } + }) + + it('recounts a returned Unicode event after mutation and re-enqueue', () => { + const queue = new PtyShutdownOutputQueue() + const reference = new ReferenceShutdownOutputQueue() + const unicode: PtyShutdownOutputEvent = { kind: 'replay', data: '界' } + queue.enqueue(unicode) + reference.enqueue(unicode) + const returned = queue.takeAll()[0] + const referenceReturned = reference.takeAll()[0] + if (!returned || !referenceReturned) { + throw new Error('Expected queued events') + } + returned.data = 'x' + referenceReturned.data = 'x' + queue.enqueue(returned) + reference.enqueue(referenceReturned) + const newest: PtyShutdownOutputEvent = { kind: 'data', data: 'y'.repeat(BYTE_LIMIT) } + queue.enqueue(newest) + reference.enqueue(newest) + expect(queue.getStorageForTest().retainedBytes).toBe(BYTE_LIMIT) + const overflow: PtyShutdownOutputEvent = { kind: 'replay', data: 'z' } + queue.enqueue(overflow) + reference.enqueue(overflow) + + expect(queue.takeAll()).toEqual(reference.takeAll()) + }) + + it('matches the previous algorithm across a seeded adversarial stream', () => { + const queue = new PtyShutdownOutputQueue() + const reference = new ReferenceShutdownOutputQueue() + const random = createSeededRandom(0x15c0ffee) + + for (let index = 0; index < 5_000; index += 1) { + const value = random() + const data = createDifferentialData(value) + const event: PtyShutdownOutputEvent = + value % 3 === 0 + ? { kind: 'replay', data } + : { + kind: 'data', + data, + meta: { + seq: index, + rawLength: data.length + (value % 5), + transformed: value % 2 === 0, + ...(value % 7 === 0 ? { background: true } : {}), + ...(value % 97 === 0 ? { droppedOutput: true } : {}) + } + } + queue.enqueue(event) + reference.enqueue(event) + + if ((index + 1) % 1_000 === 0) { + expect(queue.takeAll()).toEqual(reference.takeAll()) + } + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts new file mode 100644 index 00000000000..b54b787309f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts @@ -0,0 +1,149 @@ +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import type { PtyDataMeta } from './pty-dispatcher' + +const EVENT_CHUNK_SIZE = 64 +const COMPACTION_MIN_HEAD_CHUNKS = 64 + +export type PtyShutdownOutputEvent = + | { kind: 'data'; data: string; meta?: PtyDataMeta } + | { kind: 'replay'; data: string } + +type PtyShutdownOutputChunk = { + events: (PtyShutdownOutputEvent | undefined)[] + eventBytes?: Uint32Array +} + +export type PtyShutdownOutputQueueStorage = { + backingLength: number + byteBackingLength: number + chunkBackingLength: number + head: number + headChunk: number + retainedBytes: number + retainedEvents: number +} + +export class PtyShutdownOutputQueue { + private chunks: (PtyShutdownOutputChunk | undefined)[] = [] + private headChunk = 0 + private headOffset = 0 + private retainedBytes = 0 + private retainedEvents = 0 + + enqueue(event: PtyShutdownOutputEvent): void { + const clamped = clampUtf8Tail(event.data, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) + let tail = this.chunks.at(-1) + if (!tail || tail.events.length >= EVENT_CHUNK_SIZE) { + tail = { events: [] } + this.chunks.push(tail) + } + const tailOffset = tail.events.length + tail.events.push({ ...event, data: clamped.data }) + if (clamped.bytes !== clamped.data.length) { + tail.eventBytes ??= new Uint32Array(EVENT_CHUNK_SIZE) + tail.eventBytes[tailOffset] = clamped.bytes + } + this.retainedBytes += clamped.bytes + this.retainedEvents += 1 + + while (this.length > 1 && this.retainedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) { + this.evictOldest() + } + } + + takeAll(): PtyShutdownOutputEvent[] { + const events: PtyShutdownOutputEvent[] = [] + this.drain((event) => events.push(event)) + return events + } + + drain(consumer: (event: PtyShutdownOutputEvent) => void): void { + const chunks = this.chunks + const headChunk = this.headChunk + const headOffset = this.headOffset + this.reset() + for (let chunkIndex = headChunk; chunkIndex < chunks.length; chunkIndex += 1) { + const chunk = chunks[chunkIndex] + if (!chunk) { + continue + } + const start = chunkIndex === headChunk ? headOffset : 0 + for (let eventIndex = start; eventIndex < chunk.events.length; eventIndex += 1) { + const event = chunk.events[eventIndex] + if (event) { + consumer(event) + } + } + } + } + + discard(): void { + this.reset() + } + + get length(): number { + return this.retainedEvents + } + + getStorageForTest(): PtyShutdownOutputQueueStorage { + let backingLength = 0 + let byteBackingLength = 0 + for (const chunk of this.chunks) { + if (chunk) { + backingLength += chunk.events.length + byteBackingLength += chunk.eventBytes?.length ?? 0 + } + } + return { + backingLength, + byteBackingLength, + chunkBackingLength: this.chunks.length, + head: this.headOffset, + headChunk: this.headChunk, + retainedBytes: this.retainedBytes, + retainedEvents: this.length + } + } + + private evictOldest(): void { + const chunk = this.chunks[this.headChunk] + if (!chunk) { + throw new Error('Missing PTY shutdown output queue chunk') + } + const event = chunk.events[this.headOffset] + if (!event) { + throw new Error('Missing PTY shutdown output queue event') + } + this.retainedBytes -= chunk.eventBytes?.[this.headOffset] || event.data.length + chunk.events[this.headOffset] = undefined + if (chunk.eventBytes) { + chunk.eventBytes[this.headOffset] = 0 + } + this.headOffset += 1 + this.retainedEvents -= 1 + if (this.headOffset < chunk.events.length) { + return + } + this.chunks[this.headChunk] = undefined + this.headChunk += 1 + this.headOffset = 0 + this.compactChunks() + } + + private compactChunks(): void { + if (this.headChunk < COMPACTION_MIN_HEAD_CHUNKS || this.headChunk * 2 < this.chunks.length) { + return + } + this.chunks = this.chunks.slice(this.headChunk) + this.headChunk = 0 + } + + private reset(): void { + this.chunks = [] + this.headChunk = 0 + this.headOffset = 0 + this.retainedBytes = 0 + this.retainedEvents = 0 + } +} From f3f654cd1f01629af4124d0a31889e6ef9e7f9bd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:32:19 -0700 Subject: [PATCH 15/69] perf(renderer): collapse terminal host selectors (#13643) --- .../components/terminal-pane/TerminalPane.tsx | 54 ++----- .../terminal-pane-host-state.test.ts | 134 ++++++++++++++++++ .../terminal-pane/terminal-pane-host-state.ts | 61 ++++++++ 3 files changed, 206 insertions(+), 43 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 28d36689bcf..3c4cecdac8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -32,16 +32,8 @@ import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import type { PtyTransportRecoveryState } from './pty-transport-types' import { fitPanes, isWindowsUserAgent } from './pane-helpers' -import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context' -import { - getExplicitRuntimeEnvironmentIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' -import { - selectRuntimeAwareSshStatus, - selectRuntimeAwareSshTargetLabel, - selectRuntimeAwareSshTargetRemoved -} from '@/store/slices/runtime-environment-ssh' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' @@ -123,7 +115,6 @@ import { resolveNativeChatLeafRoute, type NativeChatLeafRoute } from '../native-chat/native-chat-leaf-routing' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' @@ -169,7 +160,6 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' @@ -243,6 +233,7 @@ import { getCachedUnifiedTerminalTabForWorktree } from './terminal-unified-tab-lookup' import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' +import { selectTerminalPaneHostState } from './terminal-pane-host-state' import { isXtermHelperTextarea, releaseTerminalFocusForOutsidePointerDown, @@ -349,37 +340,14 @@ function TerminalPane( const isRendererVisible = isVisible && isWorktreeActive const isVisibleRef = useRef(isRendererVisible) isVisibleRef.current = isRendererVisible - const sshReconnectTargetId = useAppStore((store) => { - const connectionId = getConnectionIdFromState(store, worktreeId) - // Why: runtime-owned SSH targets are internal plumbing users can't connect to, so a reconnect prompt would mislead. - if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { - return null - } - return connectionId - }) - const nativeChatTranscriptIsLocalReadable = useAppStore((store) => - isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) - ) - // Which machine's SSH store this target belongs to: a remote server's per-environment bucket, or null for this machine's local SSH maps. - const sshReconnectEnvironmentId = useAppStore((store) => - sshReconnectTargetId ? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId) : null - ) - const sshReconnectStatus = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshStatus(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : null - ) - const sshReconnectTargetLabel = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshTargetLabel(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : '' - ) - // Why: a ghost target (removed from its host) can only fail reconnect, so the overlay offers Remove instead of Connect. - const sshReconnectTargetRemoved = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshTargetRemoved(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : false - ) + const { + nativeChatTranscriptIsLocalReadable, + sshReconnectEnvironmentId, + sshReconnectStatus, + sshReconnectTargetId, + sshReconnectTargetLabel, + sshReconnectTargetRemoved + } = useAppStore(useShallow((store) => selectTerminalPaneHostState(store, worktreeId))) useEffect(() => { if (!sshReconnectEnvironmentId) { return diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts new file mode 100644 index 00000000000..90825f42980 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { selectTerminalPaneHostState } from './terminal-pane-host-state' + +function makeState(overrides: Record): AppState { + return { + activeWorktreeId: null, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + removedSshTargetLabels: new Map(), + repos: [], + runtimeStatusByEnvironmentId: new Map(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + sshTargetLabels: new Map(), + sshTargetsHydrated: true, + worktreesByRepo: {}, + ...overrides + } as unknown as AppState +} + +describe('selectTerminalPaneHostState', () => { + it('resolves local and direct SSH workspaces without changing reconnect semantics', () => { + const localState = makeState({ + repos: [{ id: 'repo-local' }], + worktreesByRepo: { + 'repo-local': [{ id: 'wt-local', repoId: 'repo-local' }] + } + }) + + expect(selectTerminalPaneHostState(localState, 'wt-local')).toEqual({ + nativeChatTranscriptIsLocalReadable: true, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + }) + + const sshState = makeState({ + repos: [{ id: 'repo-ssh', connectionId: 'ssh-a' }], + sshConnectionStates: new Map([ + ['ssh-a', { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + sshTargetLabels: new Map([['ssh-a', 'devbox']]), + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' }] + } + }) + + expect(selectTerminalPaneHostState(sshState, 'wt-ssh')).toEqual({ + nativeChatTranscriptIsLocalReadable: false, + sshReconnectEnvironmentId: null, + sshReconnectStatus: 'connected', + sshReconnectTargetId: 'ssh-a', + sshReconnectTargetLabel: 'devbox', + sshReconnectTargetRemoved: false + }) + }) + + it('reads nested SSH state from the worktree owner runtime', () => { + const state = makeState({ + repos: [ + { + id: 'repo-runtime', + connectionId: 'ssh-nested', + executionHostId: 'runtime:env-a' + } + ], + runtimeStatusByEnvironmentId: new Map([ + ['env-a', { status: { runtimeId: 'runtime-a' }, checkedAt: 1 }] + ]), + sshStateByEnvironment: new Map([ + [ + 'env-a', + { + connectionStates: new Map([ + [ + 'ssh-nested', + { + targetId: 'ssh-nested', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + targetLabels: new Map([['ssh-nested', 'build box']]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]), + worktreesByRepo: { + 'repo-runtime': [ + { + id: 'wt-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:env-a', + runtimeOwnerEnvironmentId: 'env-a' + } + ] + } + }) + + expect(selectTerminalPaneHostState(state, 'wt-runtime')).toEqual({ + nativeChatTranscriptIsLocalReadable: false, + sshReconnectEnvironmentId: 'env-a', + sshReconnectStatus: 'disconnected', + sshReconnectTargetId: 'ssh-nested', + sshReconnectTargetLabel: 'build box', + sshReconnectTargetRemoved: false + }) + }) + + it('keeps runtime-owned SSH plumbing out of reconnect UI', () => { + const state = makeState({ + repos: [{ id: 'repo-ephemeral', connectionId: 'runtime-ssh-vm-a' }], + worktreesByRepo: { + 'repo-ephemeral': [{ id: 'wt-ephemeral', repoId: 'repo-ephemeral' }] + } + }) + + expect(selectTerminalPaneHostState(state, 'wt-ephemeral')).toEqual({ + nativeChatTranscriptIsLocalReadable: true, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts new file mode 100644 index 00000000000..17a7e1a354c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts @@ -0,0 +1,61 @@ +import type { AppState } from '@/store/types' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from '@/store/slices/runtime-environment-ssh' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' + +export type TerminalPaneHostState = { + nativeChatTranscriptIsLocalReadable: boolean + sshReconnectEnvironmentId: string | null + sshReconnectStatus: ReturnType + sshReconnectTargetId: string | null + sshReconnectTargetLabel: string + sshReconnectTargetRemoved: boolean +} + +export function selectTerminalPaneHostState( + state: AppState, + worktreeId: string +): TerminalPaneHostState { + const connectionId = getConnectionIdFromState(state, worktreeId) + const nativeChatTranscriptIsLocalReadableResult = + isNativeChatTranscriptLocalReadable(connectionId) + const sshReconnectTargetId = + connectionId && !isRuntimeOwnedSshTargetId(connectionId) ? connectionId : null + if (!sshReconnectTargetId) { + return { + nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + } + } + const sshReconnectEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + return { + nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult, + sshReconnectEnvironmentId, + sshReconnectStatus: selectRuntimeAwareSshStatus( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ), + sshReconnectTargetId, + sshReconnectTargetLabel: selectRuntimeAwareSshTargetLabel( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ), + sshReconnectTargetRemoved: selectRuntimeAwareSshTargetRemoved( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ) + } +} From b015b1643687b3eef5e55169ded437d7496b193e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:32:26 -0700 Subject: [PATCH 16/69] perf(native-chat): stop abandoned tail reads (#13588) --- ...ranscript-tail-reader-cancellation.test.ts | 78 +++++++++++++++---- .../native-chat/transcript-tail-reader.ts | 23 +++++- 2 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts index cf3eaf47e7f..d211805337f 100644 --- a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts +++ b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts @@ -1,5 +1,5 @@ import type * as NodeFsPromisesModule from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const fsMocks = vi.hoisted(() => ({ open: vi.fn(), @@ -15,19 +15,33 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ import { readNativeChatTranscriptTail } from './transcript-tail-reader' describe('native chat transcript tail cancellation', () => { - it('rejects after pending tail I/O closes when the request is canceled', async () => { - let finishRead: (() => void) | undefined + beforeEach(() => { + fsMocks.open.mockReset() + fsMocks.stat.mockReset() + }) + + it('closes after a pending tail read without reading another chunk when canceled', async () => { + let finishTailRead: (() => void) | undefined + let readCount = 0 const close = vi.fn(async () => {}) - const read = vi.fn( - (buffer: Buffer) => - new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { - finishRead = () => { - buffer[0] = 0x0a - resolve({ bytesRead: 1, buffer }) - } - }) - ) - fsMocks.stat.mockResolvedValue({ size: 1 }) + const read = vi.fn((buffer: Buffer) => { + readCount++ + if (readCount === 1) { + buffer[0] = 0x0a + return Promise.resolve({ bytesRead: 1, buffer }) + } + if (readCount > 2) { + buffer.fill(0x0a) + return Promise.resolve({ bytesRead: buffer.length, buffer }) + } + return new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { + finishTailRead = () => { + buffer.fill(0x0a) + resolve({ bytesRead: buffer.length, buffer }) + } + }) + }) + fsMocks.stat.mockResolvedValue({ size: 8 * 64 * 1024 + 1 }) fsMocks.open.mockResolvedValue({ close, read }) const controller = new AbortController() const canceled = new Error('request canceled') @@ -41,12 +55,46 @@ describe('native chat transcript tail cancellation', () => { controller.signal ) const rejection = expect(pending).rejects.toBe(canceled) - await vi.waitFor(() => expect(read).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) controller.abort(canceled) - finishRead?.() + finishTailRead?.() await rejection + expect(read).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + }) + + it('closes a handle opened after cancellation without starting a read', async () => { + let finishOpen: (() => void) | undefined + const close = vi.fn(async () => {}) + const read = vi.fn() + fsMocks.stat.mockResolvedValue({ size: 1 }) + fsMocks.open.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = () => resolve({ close, read }) + }) + ) + const controller = new AbortController() + const canceled = new Error('request canceled while opening') + const pending = readNativeChatTranscriptTail( + { + agent: 'claude', + sessionId: 'session-id', + filePath: 'transcript.jsonl', + limit: 40 + }, + controller.signal + ) + const rejection = expect(pending).rejects.toBe(canceled) + await vi.waitFor(() => expect(fsMocks.open).toHaveBeenCalledOnce()) + + controller.abort(canceled) + finishOpen?.() + + await rejection + expect(read).not.toHaveBeenCalled() expect(close).toHaveBeenCalledOnce() }) }) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index f5cbed8efdd..c7b1527858a 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -46,7 +46,8 @@ export async function readNativeChatTranscriptTailFile( decode: NativeChatLineDecoder, includeTrailingLine = false, endOffset?: number, - decodeLifecycle?: NativeChatTurnLifecycleDecoder | null + decodeLifecycle?: NativeChatTurnLifecycleDecoder | null, + signal?: AbortSignal ): Promise<{ messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle @@ -56,7 +57,9 @@ export async function readNativeChatTranscriptTailFile( malformedRecordCount?: number oversizedRecordCount?: number }> { + signal?.throwIfAborted() const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + signal?.throwIfAborted() if (end === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } @@ -69,19 +72,25 @@ export async function readNativeChatTranscriptTailFile( let oversizedRecordCount = 0 let ignoreNextMalformedRecord = false try { - const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + signal?.throwIfAborted() + const consumedTo = includeTrailingLine + ? end + : await findLastCompleteLineEnd(handle, end, signal) if (consumedTo === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } const newestFirst: { message: NativeChatMessage; offset: number }[] = [] const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) + signal?.throwIfAborted() ignoreNextMalformedRecord = finalByte[0] !== 0x0a let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) while (cursor > 0 && newestFirst.length <= limit) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { @@ -175,18 +184,23 @@ export async function readNativeChatTranscriptTailFile( async function findLastCompleteLineEnd( handle: Awaited>, - end: number + end: number, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const lastByte = Buffer.allocUnsafe(1) await handle.read(lastByte, 0, 1, end - 1) + signal?.throwIfAborted() if (lastByte[0] === 0x0a) { return end } let cursor = end while (cursor > 0) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) if (newline >= 0) { return start + newline + 1 @@ -235,7 +249,8 @@ export async function readNativeChatTranscriptTail( decode, true, args.beforeOffset, - decodeLifecycle + decodeLifecycle, + signal ) signal?.throwIfAborted() return { From 550eabe9215dc26d87c81c7810cbf3ce1ec731ea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:37:45 -0700 Subject: [PATCH 17/69] feat(workspaces): review preserved branches after bulk delete (#13693) --- src/main/ipc/worktrees.ts | 94 ++++++-- src/main/runtime/orca-runtime.ts | 75 +++++-- .../runtime/rpc/methods/worktree-schemas.ts | 2 + src/main/runtime/rpc/methods/worktree.ts | 20 +- src/preload/api-types.ts | 1 + src/renderer/src/App.tsx | 13 ++ .../PreservedBranchBatchReviewDialog.tsx | 188 ++++++++++++++++ .../PreservedBranchBatchReviewModal.tsx | 52 +++++ .../sidebar/delete-worktree-flow.test.ts | 10 +- .../delete-worktree-parallel-flow.test.ts | 69 +++++- .../preserved-branch-batch-toast.test.tsx | 208 ++++++++++++++++++ .../sidebar/preserved-branch-batch-toast.tsx | 190 ++++++++++++++++ .../sidebar/preserved-branch-toast.tsx | 2 +- .../sidebar/worktree-delete-execution.ts | 36 ++- .../sidebar/worktree-delete-request.ts | 3 + src/renderer/src/components/ui/checkbox.tsx | 15 +- ...rkspace-cleanup-background-removal.test.ts | 54 +++++ .../workspace-cleanup-background-removal.ts | 9 +- .../workspace-cleanup-removal-toasts.ts | 5 +- src/renderer/src/i18n/locales/en.json | 39 ++++ .../src/lib/preserved-branch-cleanup.ts | 5 + .../src/store/slices/store-cascades.test.ts | 4 +- src/renderer/src/store/slices/ui.ts | 1 + ...orkspace-cleanup-removal-preflight.test.ts | 32 +++ .../src/store/slices/workspace-cleanup.ts | 16 +- .../src/store/slices/worktree-helpers.ts | 16 +- .../src/store/slices/worktrees.test.ts | 144 +++++++++++- src/renderer/src/store/slices/worktrees.ts | 117 ++++++++-- src/renderer/src/web/web-preload-api.ts | 8 +- src/shared/preserved-branch-cleanup.ts | 23 ++ 30 files changed, 1365 insertions(+), 86 deletions(-) create mode 100644 src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx create mode 100644 src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx create mode 100644 src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx create mode 100644 src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx create mode 100644 src/renderer/src/lib/preserved-branch-cleanup.ts create mode 100644 src/shared/preserved-branch-cleanup.ts diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 88caac3c938..cc0b4b0452f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -142,6 +142,7 @@ import { registerSshProviderRequestAbort } from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -522,15 +523,18 @@ type WorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget } -const preservedBranchCleanupByWorktreeId = new Map() +const preservedBranchCleanupByScope = new Map() function rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -542,14 +546,16 @@ function rememberPreservedBranchCleanupTarget( `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - preservedBranchCleanupByWorktreeId.set(worktreeId, { + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, branchName: result.preservedBranch.branchName, head, ...(pushTarget ? { pushTarget } : {}) }) return } - preservedBranchCleanupByWorktreeId.delete(worktreeId) + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) } function preserveBranchHeadFallback( @@ -571,9 +577,21 @@ function preserveBranchHeadFallback( function getPreservedBranchCleanupTarget( worktreeId: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: ExecutionHostId ): PreservedBranchCleanupTarget { - const target = preservedBranchCleanupByWorktreeId.get(worktreeId) + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if (!target || target.branchName !== branchName || target.head !== expectedHead) { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } @@ -2400,7 +2418,9 @@ export function registerWorktreeHandlers( await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2509,7 +2529,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2554,7 +2579,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return {} @@ -2586,7 +2616,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2634,6 +2669,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2731,6 +2767,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2884,7 +2921,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) removalCompleted = true @@ -2908,6 +2950,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -3011,7 +3054,17 @@ export function registerWorktreeHandlers( removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } notifyWorktreesChanged(mainWindow, repoId) return {} })() @@ -3030,15 +3083,21 @@ export function registerWorktreeHandlers( 'worktrees:forceDeletePreservedBranch', async ( _event, - args: { worktreeId: string; branchName: string; expectedHead: string } + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const cleanupTarget = getPreservedBranchCleanupTarget( args.worktreeId, args.branchName, - args.expectedHead + args.expectedHead, + args.hostId ) - const repo = store.getRepo(repoId) + const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -3081,7 +3140,12 @@ export function registerWorktreeHandlers( ) } - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b0bff00d79d..99f9f5aad07 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -250,6 +250,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRegisteredSshState } from '../ipc/ssh' import type { AgentProviderSessionMetadata, @@ -2121,6 +2122,8 @@ type RuntimeWorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId?: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget @@ -3184,7 +3187,7 @@ export class OrcaRuntimeService { private canonicalFetchKeyCache = new Map() private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() - private preservedBranchCleanupByWorktreeId = new Map() + private preservedBranchCleanupByScope = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null private readonly onPtyStopped: ((ptyId: string) => void) | null @@ -24086,6 +24089,7 @@ export class OrcaRuntimeService { private rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId | undefined, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -24097,14 +24101,21 @@ export class OrcaRuntimeService { `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - this.preservedBranchCleanupByWorktreeId.set(worktreeId, { - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) + this.preservedBranchCleanupByScope.set( + preservedBranchCleanupScopeKey({ worktreeId, hostId }), + { + worktreeId, + ...(hostId ? { hostId } : {}), + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + } + ) return } - this.preservedBranchCleanupByWorktreeId.delete(worktreeId) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId, hostId }) + ) } private preserveBranchHeadFallback( @@ -24126,15 +24137,29 @@ export class OrcaRuntimeService { async forceDeletePreservedBranch( worktreeSelector: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) - const cleanupTarget = removalTarget - ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) + const normalizedHostId = parseExecutionHostId(hostId)?.id + const exactTarget = removalTarget + ? this.preservedBranchCleanupByScope.get( + preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: normalizedHostId }) + ) : undefined + const legacyMatches = + removalTarget && !hostId + ? [...this.preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === removalTarget.id && + target.branchName === branchName && + target.head === expectedHead + ) + : [] + const cleanupTarget = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if ( !removalTarget || !cleanupTarget || @@ -24188,7 +24213,12 @@ export class OrcaRuntimeService { ) } - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } @@ -24198,13 +24228,19 @@ export class OrcaRuntimeService { runHooks = false, // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` // alone is already set by the ordinary delete confirmation. - allowUnverifiedPtyStop = false + allowUnverifiedPtyStop = false, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const store = this.store + const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) + const cleanupScopeKey = preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupHostId + }) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) if (inFlightRemoval) { @@ -24268,7 +24304,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24311,7 +24347,7 @@ export class OrcaRuntimeService { }) } this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) return {} @@ -24416,7 +24452,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24465,7 +24501,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24501,7 +24537,7 @@ export class OrcaRuntimeService { )) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24548,6 +24584,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24593,6 +24630,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24755,7 +24793,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24782,6 +24820,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 57b8a9202a5..a3e562e8516 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -275,6 +275,7 @@ export const WorktreeSet = WorktreeSelector.extend({ }) export const WorktreeRemove = WorktreeSelector.extend({ + hostId: OptionalString, force: OptionalBoolean, // Why (#11960): the CLI's --force is an unambiguous force affordance, but the // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop @@ -284,6 +285,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + hostId: OptionalString, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 758e6b83891..acc2dab0537 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -271,12 +271,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - const result = await runtime.removeManagedWorktree( + const removalArgs = [ params.worktree, params.force === true, params.runHooks === true, params.allowUnverifiedPtyStop === true - ) + ] as const + const result = params.hostId + ? await runtime.removeManagedWorktree(...removalArgs, params.hostId) + : await runtime.removeManagedWorktree(...removalArgs) return { removed: true, ...result } } }), @@ -284,6 +287,17 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, handler: async (params, { runtime }) => - runtime.forceDeletePreservedBranch(params.worktree, params.branchName, params.expectedHead) + params.hostId + ? runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead, + params.hostId + ) + : runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead + ) }) ] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 97f43c3140b..ed05b86ea0b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1482,6 +1482,7 @@ export type PreloadApi = { worktreeId: string branchName: string expectedHead: string + hostId?: ExecutionHostId }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise listLineage: () => Promise<{ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 25c47a5806d..141049b04c2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -361,6 +361,9 @@ const AddProjectFromFolderDialog = lazy( ) const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog')) const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog')) +const PreservedBranchBatchReviewModal = lazy( + () => import('./components/sidebar/PreservedBranchBatchReviewModal') +) const DictationController = lazy(() => import('./components/dictation/DictationController').then((module) => ({ default: module.DictationController @@ -2704,6 +2707,16 @@ function App(): React.JSX.Element { ) : null} + {activeModal === 'preserved-branch-review' ? ( + + + + ) : null} {hasSshCredentialRequest ? ( diff --git a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx new file mode 100644 index 00000000000..c1340b79327 --- /dev/null +++ b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { Trash2 } from 'lucide-react' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { translate } from '@/i18n/i18n' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { Checkbox } from '../ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { Label } from '../ui/label' +import { preservedBranchCleanupKey } from '@/lib/preserved-branch-cleanup' + +type ActionablePreservedBranch = PreservedBranchCleanup & { expectedHead: string } + +function selectionKey(branch: PreservedBranchCleanup): string { + return preservedBranchCleanupKey(branch) +} + +function getRepositoryLabel(branch: PreservedBranchCleanup): string { + const repoId = getRepoIdFromWorktreeId(branch.worktreeId) + return useAppStore.getState().repos?.find((repo) => repo.id === repoId)?.displayName || repoId +} + +export function PreservedBranchBatchReviewDialog({ + branches, + open, + onOpenChange, + onForceDelete +}: { + branches: readonly PreservedBranchCleanup[] + open: boolean + onOpenChange: (open: boolean) => void + onForceDelete: (branches: readonly ActionablePreservedBranch[]) => void +}): React.JSX.Element { + const actionableBranches = useMemo( + () => + branches.filter((branch): branch is ActionablePreservedBranch => + Boolean(branch.expectedHead) + ), + [branches] + ) + const actionableKeys = useMemo( + () => actionableBranches.map((branch) => selectionKey(branch)), + [actionableBranches] + ) + const [selectedKeys, setSelectedKeys] = useState>( + () => new Set(actionableKeys) + ) + const selectedBranches = actionableBranches.filter((branch) => + selectedKeys.has(selectionKey(branch)) + ) + const allSelected = selectedBranches.length === actionableBranches.length + const someSelected = selectedBranches.length > 0 && !allSelected + + const setBranchSelected = (branch: ActionablePreservedBranch, selected: boolean): void => { + setSelectedKeys((current) => { + const next = new Set(current) + if (selected) { + next.add(selectionKey(branch)) + } else { + next.delete(selectionKey(branch)) + } + return next + }) + } + + return ( + + + + + {translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.c4bf8e7eaf', + 'Review kept branches' + )} + + + {translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.f21976c9a8', + 'Select the local branches you want to force delete. Unselected branches stay in their repositories.' + )} + + + +
+
+ + + {translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.9602129d38', + '{{value0}} of {{value1}} selected', + { value0: selectedBranches.length, value1: actionableBranches.length } + )} + +
+ +
    + {branches.map((branch, index) => { + const actionableBranch = branch.expectedHead + ? (branch as ActionablePreservedBranch) + : null + const branchKey = selectionKey(branch) + const checkboxId = `preserved-branch-${index}` + return ( +
  • + { + if (actionableBranch) { + setBranchSelected(actionableBranch, checked === true) + } + }} + /> + + + {actionableBranch + ? translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.ee39e872d5', + 'May be unmerged' + ) + : translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.676db406fd', + 'Head unavailable' + )} + +
  • + ) + })} +
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx new file mode 100644 index 00000000000..34bdbc9d4e8 --- /dev/null +++ b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import { useAppStore } from '@/store' +import { PreservedBranchBatchReviewDialog } from './PreservedBranchBatchReviewDialog' +import { + forceDeletePreservedBranchBatch, + type ActionablePreservedBranch +} from './preserved-branch-batch-toast' + +function isPreservedBranchCleanup(value: unknown): value is PreservedBranchCleanup { + if (!value || typeof value !== 'object') { + return false + } + const branch = value as Partial + return ( + typeof branch.worktreeId === 'string' && + typeof branch.branchName === 'string' && + (branch.expectedHead === undefined || typeof branch.expectedHead === 'string') && + (branch.hostId === undefined || typeof branch.hostId === 'string') && + (branch.runtimeEnvironmentId === undefined || typeof branch.runtimeEnvironmentId === 'string') + ) +} + +function getModalBranches(value: unknown): PreservedBranchCleanup[] { + return Array.isArray(value) ? value.filter(isPreservedBranchCleanup) : [] +} + +export default function PreservedBranchBatchReviewModal(): React.JSX.Element { + const activeModal = useAppStore((state) => state.activeModal) + const modalData = useAppStore((state) => state.modalData) + const closeModal = useAppStore((state) => state.closeModal) + const branches = useMemo(() => getModalBranches(modalData.branches), [modalData.branches]) + const open = activeModal === 'preserved-branch-review' && branches.length > 0 + + const handleForceDelete = (selectedBranches: readonly ActionablePreservedBranch[]): void => { + closeModal() + void forceDeletePreservedBranchBatch(selectedBranches) + } + + return ( + { + if (!nextOpen) { + closeModal() + } + }} + onForceDelete={handleForceDelete} + /> + ) +} 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 9436f67bfcf..248b8883ae0 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -224,7 +224,11 @@ describe('delete worktree flow', () => { ) const deletion = runWorktreeDeletesInParallel(targets) - await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)) + await vi.waitFor(() => + expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false, { + suppressPreservedBranchToast: true + }) + ) setWorktrees([ { id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' }, { id: 'wt-2', instanceId: 'replacement-instance', path: '/workspaces/second' } @@ -232,7 +236,9 @@ describe('delete worktree flow', () => { finishFirst({ ok: true }) await expect(deletion).resolves.toEqual(['wt-1']) - expect(mocks.state.removeWorktree).not.toHaveBeenCalledWith('wt-2', false) + expect(mocks.state.removeWorktree).not.toHaveBeenCalledWith('wt-2', false, { + suppressPreservedBranchToast: true + }) expect(toast.info).toHaveBeenCalledWith( 'Workspace list changed', expect.objectContaining({ 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 8def3dedde2..42411ff6fce 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 @@ -46,8 +46,13 @@ vi.mock('sonner', () => ({ } })) +vi.mock('./preserved-branch-batch-toast', () => ({ + showPreservedBranchBatchToast: vi.fn() +})) + import { toast } from 'sonner' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' +import { showPreservedBranchBatchToast } from './preserved-branch-batch-toast' function runDeletesForCurrentWorktrees( targets: Parameters[0], @@ -70,13 +75,14 @@ function deferredDeleteResult(): { describe('runWorktreeDeletesInParallel', () => { beforeEach(() => { - mocks.state.removeWorktree.mockClear().mockResolvedValue({ ok: true }) + mocks.state.removeWorktree.mockReset().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() + vi.mocked(showPreservedBranchBatchToast).mockClear() }) it('starts every selected delete before waiting for earlier deletes to finish', async () => { @@ -92,8 +98,12 @@ describe('runWorktreeDeletesInParallel', () => { ]) expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(2) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'wt-1', false) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', false) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'wt-1', false, { + suppressPreservedBranchToast: true + }) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', false, { + suppressPreservedBranchToast: true + }) expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1', 'wt-2']) second.resolve({ ok: true }) @@ -124,12 +134,16 @@ describe('runWorktreeDeletesInParallel', () => { canForceDelete: false }) expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'child', false) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'child', false, { + suppressPreservedBranchToast: true + }) childDelete.resolve({ ok: true }) await expect(deleted).resolves.toEqual(['parent', 'child']) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'parent', false) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'parent', false, { + suppressPreservedBranchToast: true + }) }) it('deletes nested workspaces before their parent within the same repo', async () => { @@ -138,8 +152,12 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' } ]) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'child', false) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'parent', false) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'child', false, { + suppressPreservedBranchToast: true + }) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'parent', false, { + suppressPreservedBranchToast: true + }) }) it('passes confirmed force to each delete', async () => { @@ -151,8 +169,12 @@ describe('runWorktreeDeletesInParallel', () => { { force: true } ) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'wt-1', true) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', true) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'wt-1', true, { + suppressPreservedBranchToast: true + }) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', true, { + suppressPreservedBranchToast: true + }) }) it('deletes a duplicated target identity only once', async () => { @@ -192,8 +214,35 @@ describe('runWorktreeDeletesInParallel', () => { ).resolves.toEqual([]) expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) - expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'child', false) + expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(1, 'child', false, { + suppressPreservedBranchToast: true + }) expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('parent') expect(mocks.state.deleteStateByWorktreeId['parent']).toBeUndefined() }) + + it('replaces per-workspace branch warnings with one batch result', async () => { + mocks.state.removeWorktree + .mockResolvedValueOnce({ + ok: true, + preservedBranch: { branchName: 'feature/one', head: 'head-one' } + }) + .mockResolvedValueOnce({ + ok: true, + preservedBranch: { branchName: 'feature/two', head: 'head-two' } + }) + + await expect( + runDeletesForCurrentWorktrees([ + { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' }, + { id: 'wt-2', displayName: 'two', repoId: 'repo-b', path: '/workspaces/two' } + ]) + ).resolves.toEqual(['wt-1', 'wt-2']) + + expect(showPreservedBranchBatchToast).toHaveBeenCalledOnce() + expect(showPreservedBranchBatchToast).toHaveBeenCalledWith(2, [ + { worktreeId: 'wt-1', branchName: 'feature/one', expectedHead: 'head-one' }, + { worktreeId: 'wt-2', branchName: 'feature/two', expectedHead: 'head-two' } + ]) + }) }) diff --git a/src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx b/src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx new file mode 100644 index 00000000000..cba83d4c26e --- /dev/null +++ b/src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx @@ -0,0 +1,208 @@ +// @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 { toast } from 'sonner' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import PreservedBranchBatchReviewModal from './PreservedBranchBatchReviewModal' +import { + forceDeletePreservedBranchBatch, + showPreservedBranchBatchToast +} from './preserved-branch-batch-toast' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const mocks = vi.hoisted(() => { + const state = { + activeModal: 'none', + modalData: {} as Record, + repos: [], + closeModal: vi.fn(), + openModal: vi.fn(), + forceDeletePreservedBranch: vi.fn() + } + return { state } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + { getState: () => mocks.state } + ) + return { useAppStore } +}) + +vi.mock('sonner', () => ({ + toast: { + warning: vi.fn(), + loading: vi.fn(), + success: vi.fn(), + error: vi.fn(), + dismiss: vi.fn() + } +})) + +const mountedRoots: Root[] = [] + +function renderToastBody(): HTMLElement { + const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1] + ?.description as React.ReactElement + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + act(() => root.render(description)) + return container +} + +function renderReviewModal(branches: readonly PreservedBranchCleanup[]): void { + mocks.state.activeModal = 'preserved-branch-review' + mocks.state.modalData = { branches } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + act(() => root.render()) +} + +async function clickButton(container: HTMLElement, label: string): Promise { + const button = [...container.querySelectorAll('button')].find( + (element) => element.textContent?.trim() === label + ) + if (!button) { + throw new Error(`button "${label}" not found`) + } + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) +} + +async function clickCheckbox(id: string): Promise { + const checkbox = document.getElementById(id) + if (!checkbox) { + throw new Error(`checkbox "${id}" not found`) + } + await act(async () => { + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) +} + +beforeEach(() => { + mocks.state.activeModal = 'none' + mocks.state.modalData = {} + mocks.state.closeModal.mockReset() + mocks.state.openModal.mockReset() + mocks.state.forceDeletePreservedBranch.mockReset().mockResolvedValue({ ok: true, deleted: true }) +}) + +afterEach(() => { + mountedRoots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +describe('showPreservedBranchBatchToast', () => { + it('explains disk cleanup and opens one review dialog for the batch', async () => { + const branches = [ + { worktreeId: 'repo-a::one', branchName: 'feature/one', expectedHead: 'head-one' }, + { worktreeId: 'repo-b::two', branchName: 'feature/two', expectedHead: 'head-two' } + ] + showPreservedBranchBatchToast(12, branches) + const body = renderToastBody() + + expect(toast.warning).toHaveBeenCalledWith( + '12 workspaces removed, 2 branches kept', + expect.objectContaining({ dismissible: true, duration: Infinity }) + ) + expect(body.textContent).toContain('Kept branches do not retain workspace folders') + expect(body.textContent).toContain('Orca may continue freeing workspace disk space') + + await clickButton(body, 'Review 2 Branches') + + expect(mocks.state.openModal).toHaveBeenCalledWith('preserved-branch-review', { branches }) + expect(toast.dismiss).toHaveBeenCalledWith('preserved-branch-batch:repo-a::one:2') + expect(mocks.state.forceDeletePreservedBranch).not.toHaveBeenCalled() + }) + + it('force-deletes only the branches selected in review', async () => { + renderReviewModal([ + { worktreeId: 'repo-a::one', branchName: 'feature/one', expectedHead: 'head-one' }, + { worktreeId: 'repo-b::two', branchName: 'feature/two', expectedHead: 'head-two' } + ]) + + expect(document.body.textContent).toContain('Review kept branches') + expect(document.body.textContent).toContain('2 of 2 selected') + await clickCheckbox('preserved-branch-1') + expect(document.body.textContent).toContain('1 of 2 selected') + expect( + document.getElementById('preserved-branch-select-all')?.querySelector('.lucide-minus') + ).not.toBeNull() + await clickButton(document.body, 'Force Delete 1 Branch') + + expect(mocks.state.closeModal).toHaveBeenCalledOnce() + expect(mocks.state.forceDeletePreservedBranch).toHaveBeenCalledOnce() + expect(mocks.state.forceDeletePreservedBranch).toHaveBeenCalledWith( + 'repo-a::one', + 'feature/one', + 'head-one', + { suppressToast: true } + ) + expect(toast.success).toHaveBeenCalledWith('Local branches deleted: 1', { + id: 'force-delete-branch-batch:repo-a::one:1' + }) + }) + + it('keeps older-server branches visible without offering an unsafe delete', () => { + showPreservedBranchBatchToast(3, [{ worktreeId: 'repo-a::one', branchName: 'feature/one' }]) + const body = renderToastBody() + + expect(body.querySelector('button')).toBeNull() + expect(toast.warning).toHaveBeenCalledWith( + '3 workspaces removed, 1 branch kept', + expect.not.objectContaining({ duration: Infinity }) + ) + }) + + it('serializes branch deletion within one repository', async () => { + let resolveFirst: (result: { ok: true; deleted: true }) => void = () => {} + mocks.state.forceDeletePreservedBranch + .mockReset() + .mockReturnValueOnce( + new Promise<{ ok: true; deleted: true }>((resolve) => { + resolveFirst = resolve + }) + ) + .mockResolvedValueOnce({ ok: true, deleted: true }) + const deletion = forceDeletePreservedBranchBatch([ + { worktreeId: 'repo-a::one', branchName: 'feature/one', expectedHead: 'head-one' }, + { worktreeId: 'repo-a::two', branchName: 'feature/two', expectedHead: 'head-two' } + ]) + + expect(mocks.state.forceDeletePreservedBranch).toHaveBeenCalledTimes(1) + resolveFirst({ ok: true, deleted: true }) + await deletion + expect(mocks.state.forceDeletePreservedBranch).toHaveBeenCalledTimes(2) + }) + + it('shows an unavailable branch without blocking review of actionable branches', async () => { + renderReviewModal([ + { worktreeId: 'repo-a::one', branchName: 'feature/one', expectedHead: 'head-one' }, + { worktreeId: 'repo-b::two', branchName: 'feature/two' } + ]) + + expect(document.body.textContent).toContain('Head unavailable') + expect((document.getElementById('preserved-branch-1') as HTMLButtonElement).disabled).toBe(true) + await clickButton(document.body, 'Force Delete 1 Branch') + + expect(mocks.state.forceDeletePreservedBranch).toHaveBeenCalledOnce() + expect(mocks.state.forceDeletePreservedBranch).toHaveBeenCalledWith( + 'repo-a::one', + 'feature/one', + 'head-one', + { suppressToast: true } + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx b/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx new file mode 100644 index 00000000000..d98cd37a438 --- /dev/null +++ b/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx @@ -0,0 +1,190 @@ +import { Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { Button } from '../ui/button' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' + +const BRANCH_REPO_DELETE_CONCURRENCY = 4 + +export type ActionablePreservedBranch = PreservedBranchCleanup & { expectedHead: string } + +type PreservedBranchDeleteResult = { + branch: ActionablePreservedBranch + result: { ok: true; deleted: true } | { ok: false; error: string } +} + +function PreservedBranchBatchToastBody({ + branches, + onReview +}: { + branches: readonly PreservedBranchCleanup[] + onReview: () => void +}): React.JSX.Element { + const actionableCount = branches.filter((branch) => branch.expectedHead).length + return ( +
+

+ {translate( + 'auto.components.sidebar.preserved.branch.batch.toast.a3cdd9d9e6', + 'Git kept {{count}} local branches because they may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.', + { count: branches.length } + )} +

+ {actionableCount > 0 ? ( + + ) : null} +
+ ) +} + +export function showPreservedBranchBatchToast( + workspaceCount: number, + branches: readonly PreservedBranchCleanup[] +): void { + if (branches.length === 0) { + return + } + const actionableCount = branches.filter((branch) => branch.expectedHead).length + const toastId = `preserved-branch-batch:${branches[0].worktreeId}:${branches.length}` + const removedWorkspaces = translate( + 'auto.components.sidebar.preserved.branch.batch.toast.cea24c2b7d', + '{{count}} workspaces removed', + { count: workspaceCount } + ) + const keptBranches = translate( + 'auto.components.sidebar.preserved.branch.batch.toast.0e0379f24a', + '{{count}} branches kept', + { count: branches.length } + ) + const onReview = (): void => { + useAppStore.getState().openModal('preserved-branch-review', { branches }) + toast.dismiss(toastId) + } + + toast.warning( + translate( + 'auto.components.sidebar.preserved.branch.batch.toast.4cf75caab7', + '{{value0}}, {{value1}}', + { value0: removedWorkspaces, value1: keptBranches } + ), + { + id: toastId, + description: , + dismissible: true, + ...(actionableCount > 0 ? { duration: Infinity } : {}) + } + ) +} + +export async function forceDeletePreservedBranchBatch( + branches: readonly ActionablePreservedBranch[] +): Promise { + if (branches.length === 0) { + return + } + const progressToastId = `force-delete-branch-batch:${branches[0].worktreeId}:${branches.length}` + toast.loading( + translate( + 'auto.components.sidebar.preserved.branch.batch.toast.e61d78054f', + 'Deleting local branches: {{value0}}', + { value0: branches.length } + ), + { id: progressToastId } + ) + const branchesByRepo = new Map() + for (const branch of branches) { + const repoId = getRepoIdFromWorktreeId(branch.worktreeId) + const repoBranches = branchesByRepo.get(repoId) + if (repoBranches) { + repoBranches.push(branch) + } else { + branchesByRepo.set(repoId, [branch]) + } + } + const groupResults = await mapWithConcurrency( + [...branchesByRepo.values()], + BRANCH_REPO_DELETE_CONCURRENCY, + async (repoBranches) => { + const results: PreservedBranchDeleteResult[] = [] + for (const branch of repoBranches) { + results.push({ + branch, + result: await useAppStore + .getState() + .forceDeletePreservedBranch(branch.worktreeId, branch.branchName, branch.expectedHead, { + suppressToast: true, + ...(branch.hostId ? { hostId: branch.hostId } : {}), + ...(branch.runtimeEnvironmentId + ? { runtimeEnvironmentId: branch.runtimeEnvironmentId } + : {}) + }) + }) + } + return results + } + ) + const results = groupResults.flat() + const failures = results.filter((result) => !result.result.ok) + if (failures.length === 0) { + toast.success( + translate( + 'auto.components.sidebar.preserved.branch.batch.toast.1e1a5f6763', + 'Local branches deleted: {{value0}}', + { value0: branches.length } + ), + { id: progressToastId } + ) + return + } + const deletedCount = branches.length - failures.length + const description = failures + .map(({ branch, result }) => (result.ok ? '' : `${branch.branchName}: ${result.error}`)) + .filter(Boolean) + .join('; ') + const failedBranches = failures.map(({ branch }) => branch) + toast.error( + translate( + 'auto.components.sidebar.preserved.branch.batch.toast.43d9395605', + '{{value0}} deleted, {{value1}} not deleted', + { value0: deletedCount, value1: failures.length } + ), + { + id: progressToastId, + description: ( +
+

+ {description} +

+ +
+ ), + duration: Infinity, + dismissible: true + } + ) +} diff --git a/src/renderer/src/components/sidebar/preserved-branch-toast.tsx b/src/renderer/src/components/sidebar/preserved-branch-toast.tsx index b7b343aaceb..03529ad4657 100644 --- a/src/renderer/src/components/sidebar/preserved-branch-toast.tsx +++ b/src/renderer/src/components/sidebar/preserved-branch-toast.tsx @@ -63,7 +63,7 @@ function PreservedBranchToastBody({
- - - {translate( - 'auto.components.sidebar.WorktreeCard.4eba2ea99e', - 'Auto-name failed. Click to see details.' - )} - - - ) : null} - {!compactCards && worktree.isMainWorktree && !isFolder && ( - - - - {translate('auto.components.sidebar.WorktreeCard.7d517f82e2', 'primary')} - - - - {translate( - 'auto.components.sidebar.WorktreeCard.0777de5970', - 'Primary worktree (original clone directory)' - )} - - - )} - - {worktree.isSparse && ( - - - - {translate('auto.components.sidebar.WorktreeCard.4f964d5e8c', 'sparse')} - - - -
-
- {translate( - 'auto.components.sidebar.WorktreeCard.0f33af979b', - 'Partial checkout. Files outside these paths are not on disk.' - )} -
- {worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? ( -
- {formatSparseDirectoryPreview(worktree.sparseDirectories)} -
- ) : null} -
-
-
- )} - - {showTitleRowIndicators && titleRowIndicators} -
- - {showHeaderActions && ( -
- {showTitleRowPrimary && ( - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeCard.0777de5970', - 'Primary worktree (original clone directory)' - )} - - - )} - - {showDeleteQuickAction && ( - - - - - - {translate( - 'auto.components.sidebar.WorktreeCard.6f09f58541', - 'Delete workspace' - )} - - - )} -
- )} -
- - {hasMetaRow && ( -
-
- {showRepoBadgeInMetaRow && repo && ( -
- - - {repo.displayName} - -
- )} - - {showHostContextBadge && ( - - {hostContextLabel} - - )} - - {showIdentityInNewCard ? ( - - ) : isFolder && !newCardStyle ? ( - - {getDirectoryName(worktree.path)} - - ) : showBranch ? ( - - ) : showDetachedHeadInMetaRow && detachedHeadDisplay ? ( - - ) : null} - - {showConflictOperationBadge && ( - - - {CONFLICT_OPERATION_LABELS[conflictOperation]} - - )} - - {cacheStartedAt != null && ( - - )} -
- - {showMetaRowDetails && ( -
- {detailsAndPorts} -
- )} -
- )} - - {remoteBranchConflict && ( -
- - - {translate( - 'auto.components.sidebar.WorktreeCard.a88c92d0e3', - '{{value0}}/{{value1}} already exists.', - { - value0: remoteBranchConflict.remote, - value1: remoteBranchConflict.branchName - } - )} - -
- )} - - {isActive && worktree.linkedLinearIssue ? ( - - ) : null} - - {/* Why: counterbalance the card stack gap (-mt-1) so agents right after the title read as one header group. */} - {showInlineAgentList && ( - - )} - - {showLineageChildChip && ( -
- - - - - - {lineageCollapsed - ? translate( - 'auto.components.sidebar.WorktreeCard.8cb634cda6', - 'Show child workspaces' - ) - : translate( - 'auto.components.sidebar.WorktreeCard.57eaa61b55', - 'Hide child workspaces' - )} - - -
- )} - - {!newCardStyle && lineageChildren && ( -
- {lineageChildren} -
- )} -
-
- ) - - const parentHoverTriggerBody = ( -
- {parentCardContent} -
- ) - - const parentCardBodyWithHoverDetails = - hasHoverDetails && !titleRenaming ? ( - 0 ? : null - } - openDelay={100} - hoverControl={detailsHoverControl} - onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} - onEditIssue={affiliateListMode ? undefined : handleEditIssue} - onEditComment={affiliateListMode ? undefined : handleEditComment} - onOpenGitHubIssueInOrca={ - hoverIssue && 'url' in hoverIssue && hoverIssue.url - ? handleOpenGitHubIssueInOrca - : undefined - } - onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} - onOpenReviewInOrca={ - hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined - } - onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} - onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} - // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. - onUnlinkReview={ - !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined - } - > - {parentHoverTriggerBody} - - ) : ( - parentHoverTriggerBody - ) - - const cardBody = ( -
- {isDeleting && ( -
-
- {!isQueuedForDeletion ? ( - - ) : null} - {deleteLabel} -
-
- )} - {parentCardBodyWithHoverDetails} - - {newCardStyle && lineageChildren ? ( -
- {lineageChildren} -
- ) : null} -
- ) - - return ( - <> - {affiliateListMode ? ( - cardBody - ) : ( - - {cardBody} - - )} - - {typeof worktree.firstAgentMessageRenameError === 'string' && - worktree.firstAgentMessageRenameError.length > 0 && ( - - )} - - ) + return }) export default WorktreeCard diff --git a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts new file mode 100644 index 00000000000..cfbc652ef05 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts @@ -0,0 +1,150 @@ +import React, { useCallback } from 'react' + +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' +import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' +import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' +import type { WorktreeCardProps } from './worktree-card-model' +import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' +import type { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details' + +type Foundation = ReturnType +type LinkedDetails = ReturnType + +export function useWorktreeCardActivationActions({ + worktree, + repo, + affiliateListMode, + onSelectionGesture, + isActive, + activationRowKey, + onActivate, + onImmediateActivate, + isDeleting, + isSshDisconnected, + updateWorktreeMeta, + openModal +}: Pick< + WorktreeCardProps, + | 'worktree' + | 'repo' + | 'affiliateListMode' + | 'onSelectionGesture' + | 'isActive' + | 'activationRowKey' + | 'onActivate' + | 'onImmediateActivate' +> & + Pick & + Pick) { + // Stable click handler – ignore clicks that are really text selections. + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!isEventTargetInsideCurrentTarget(event.currentTarget, event.target)) { + return + } + const selection = window.getSelection() + // Why: only suppress the click for a selection inside this card; a foreign selection must not block worktree switching. + if (selection && selection.toString().length > 0) { + const card = event.currentTarget + const anchor = selection.anchorNode + const focus = selection.focusNode + const selectionInsideCard = + (anchor instanceof Node && card.contains(anchor)) || + (focus instanceof Node && card.contains(focus)) + if (selectionInsideCard) { + return + } + } + const selectionOnly = affiliateListMode + ? false + : (onSelectionGesture?.(event, worktree.id) ?? false) + if (selectionOnly) { + event.preventDefault() + event.stopPropagation() + return + } + if (isDeleting) { + event.preventDefault() + event.stopPropagation() + return + } + // Why: route sidebar clicks through the shared activation path so the back/forward stack stays complete. + recordRendererCrashBreadcrumb('sidebar_worktree_activate', { + worktreeId: worktree.id, + repoId: worktree.repoId, + wasActive: isActive, + sshDisconnected: isSshDisconnected + }) + onImmediateActivate?.(worktree.id, activationRowKey) + void activateWorktreeFromSidebar( + worktree.id, + worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) + ) + onActivate?.() + }, + [ + affiliateListMode, + worktree.id, + worktree.repoId, + worktree.hostId, + repo, + isActive, + isDeleting, + activationRowKey, + isSshDisconnected, + onActivate, + onImmediateActivate, + onSelectionGesture + ] + ) + + const handleRenameTitle = useCallback( + // Inline rename has no surface for the failure; the store already logs and + // refetches, which reverts the optimistic title in place. + async (displayName: string): Promise => { + await updateWorktreeMeta(worktree.id, { displayName }) + }, + [updateWorktreeMeta, worktree.id] + ) + + const handleDoubleClick = useCallback( + (event: React.MouseEvent) => { + if (affiliateListMode) { + return + } + if (!isEventTargetInsideCurrentTarget(event.currentTarget, event.target)) { + return + } + openModal('edit-meta', { + worktreeId: worktree.id, + repoId: worktree.repoId, + currentDisplayName: worktree.displayName, + currentIssue: worktree.linkedIssue, + currentPR: worktree.linkedPR, + currentComment: worktree.comment + }) + }, + [ + openModal, + affiliateListMode, + worktree.comment, + worktree.displayName, + worktree.id, + worktree.linkedIssue, + worktree.linkedPR, + worktree.repoId + ] + ) + + const handleToggleUnreadQuick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread }) + }, + [worktree.id, worktree.isUnread, updateWorktreeMeta] + ) + + return { handleClick, handleRenameTitle, handleDoubleClick, handleToggleUnreadQuick } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts new file mode 100644 index 00000000000..e4668bb0a4c --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts @@ -0,0 +1,164 @@ +import { canShowWorkspaceDeleteQuickAction } from './workspace-delete-quick-action' +import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' +import type { ResolvedWorktreeCardProps } from './worktree-card-model' +import { useWorktreeCardActivationActions } from './use-worktree-card-activation-actions' +import { useWorktreeCardFoundation } from './use-worktree-card-foundation' +import { useWorktreeCardLifecycleEffects } from './use-worktree-card-lifecycle-effects' +import { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details' +import { useWorktreeCardReviewDetails } from './use-worktree-card-review-details' +import { useWorktreeCardSecondaryDetails } from './use-worktree-card-secondary-details' +import { useWorktreeCardWorkspaceActions } from './use-worktree-card-workspace-actions' + +export function useWorktreeCardController(props: ResolvedWorktreeCardProps) { + const { worktree, repo } = props + const foundation = useWorktreeCardFoundation({ worktree, repo }) + const review = useWorktreeCardReviewDetails({ + worktree, + repo, + settings: foundation.settings, + projectGroups: foundation.projectGroups, + cardProps: foundation.cardProps, + newCardStyle: foundation.newCardStyle + }) + const linked = useWorktreeCardLinkedDetails({ + worktree, + newCardStyle: foundation.newCardStyle, + deleteState: foundation.deleteState, + branch: review.branch, + issueEntry: review.issueEntry, + linearIssueEntry: review.linearIssueEntry, + linearIssueFallbackEntry: review.linearIssueFallbackEntry, + prDisplay: review.prDisplay + }) + + const showStatus = foundation.cardProps.includes('status') + const showIssue = foundation.cardProps.includes('issue') + const showLinearIssue = foundation.cardProps.includes('linear-issue') + const showJiraIssue = foundation.cardProps.includes('jira-issue') + const showPR = foundation.cardProps.includes('pr') + const showAutomation = foundation.cardProps.includes('automation') + const showCli = foundation.cardProps.includes('cli') + const showComment = foundation.cardProps.includes('comment') + const showPorts = foundation.cardProps.includes('ports') + const shouldRefreshHostedReview = foundation.newCardStyle ? showStatus : showPR + const detailsHoverControl = useWorktreeCardDetailsHoverControl() + const hoverDetailsOpen = detailsHoverControl.hoverOpen + + useWorktreeCardLifecycleEffects({ + worktree, + repo, + isFolder: review.isFolder, + hostedReviewCacheKey: review.hostedReviewCacheKey, + cachedBranchFallbackGitHubPRNumber: review.cachedBranchFallbackGitHubPRNumber, + linkedGitLabMR: review.linkedGitLabMR, + linkedBitbucketPR: review.linkedBitbucketPR, + linkedAzureDevOpsPR: review.linkedAzureDevOpsPR, + linkedGiteaPR: review.linkedGiteaPR, + branch: review.branch, + fetchHostedReviewForBranch: foundation.fetchHostedReviewForBranch, + shouldRefreshHostedReview, + newCardStyle: foundation.newCardStyle, + hoverDetailsOpen, + showIssue, + issueCacheKey: review.issueCacheKey, + fetchIssue: foundation.fetchIssue, + showLinearIssue, + fetchLinearIssue: foundation.fetchLinearIssue + }) + + const activation = useWorktreeCardActivationActions({ + worktree, + repo, + affiliateListMode: props.affiliateListMode, + onSelectionGesture: props.onSelectionGesture, + isActive: props.isActive, + activationRowKey: props.activationRowKey, + onActivate: props.onActivate, + onImmediateActivate: props.onImmediateActivate, + isDeleting: linked.isDeleting, + isSshDisconnected: foundation.isSshDisconnected, + updateWorktreeMeta: foundation.updateWorktreeMeta, + openModal: foundation.openModal + }) + + // Why: delete is destructive, so it only appears while holding Option/Alt, not in the ordinary hover chrome. + const showDeleteQuickAction = + !props.affiliateListMode && + canShowWorkspaceDeleteQuickAction({ + deleteModifierPressed: linked.deleteModifierPressed, + isDeleting: linked.isDeleting, + isMainWorktree: worktree.isMainWorktree + }) + const workspaceActions = useWorktreeCardWorkspaceActions({ + worktree, + lineageChildCount: props.lineageChildCount, + lineageCollapsed: props.lineageCollapsed, + onLineageToggle: props.onLineageToggle, + isMultiSelected: props.isMultiSelected, + selectedWorktrees: props.selectedWorktrees, + onCardDragStart: props.onCardDragStart, + onCardDragEnd: props.onCardDragEnd, + onContextMenuSelect: props.onContextMenuSelect, + folderWorkspaceId: review.folderWorkspaceId, + deleteFolderWorkspace: foundation.deleteFolderWorkspace, + setActiveWorktree: foundation.setActiveWorktree, + setShowRenameErrorDialog: foundation.setShowRenameErrorDialog, + isDeleting: linked.isDeleting, + showDeleteQuickAction + }) + + const secondary = useWorktreeCardSecondaryDetails({ + worktree, + repo, + statusPrDisplay: props.statusPrDisplay, + showStatus, + showIssue, + showLinearIssue, + showJiraIssue, + showPR, + showAutomation, + showCli, + showComment, + showPorts, + issueDisplay: linked.issueDisplay, + linearIssue: linked.linearIssue, + linearIssueDisplay: linked.linearIssueDisplay, + jiraIssueDisplay: linked.jiraIssueDisplay, + prDisplay: review.prDisplay, + linkedGitLabMR: review.linkedGitLabMR, + linkedBitbucketPR: review.linkedBitbucketPR, + linkedAzureDevOpsPR: review.linkedAzureDevOpsPR, + linkedGiteaPR: review.linkedGiteaPR, + cardProps: foundation.cardProps, + newCardStyle: foundation.newCardStyle, + compactCards: foundation.compactCards, + agentActivityDisplayMode: foundation.agentActivityDisplayMode, + workspacePorts: foundation.workspacePorts, + openTaskPage: foundation.openTaskPage, + updateWorktreeMeta: foundation.updateWorktreeMeta + }) + + return { + ...props, + ...foundation, + ...review, + ...linked, + detailsHoverControl, + showStatus, + showIssue, + showLinearIssue, + showJiraIssue, + showPR, + showAutomation, + showCli, + showComment, + showPorts, + shouldRefreshHostedReview, + ...activation, + showDeleteQuickAction, + ...workspaceActions, + ...secondary + } +} + +export type WorktreeCardController = ReturnType diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts new file mode 100644 index 00000000000..0f607473cac --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -0,0 +1,229 @@ +import React, { useCallback, useEffect, useState } from 'react' + +import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants' +import { + isRuntimeOwnedSshTargetId, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' +import { useAppStore } from '@/store' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from '@/store/slices/runtime-environment-ssh' +import { EMPTY_WORKSPACE_PORTS, type WorktreeCardProps } from './worktree-card-model' + +export function useWorktreeCardFoundation({ + worktree, + repo +}: Pick) { + const openModal = useAppStore((s) => s.openModal) + const openTaskPage = useAppStore((s) => s.openTaskPage) + const openAutomationsPage = useAppStore((s) => s.openAutomationsPage) + const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation) + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId) + const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) + const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) + const settings = useAppStore((s) => s.settings) + const fetchIssue = useAppStore((s) => s.fetchIssue) + const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) + const cardProps = useAppStore((s) => s.worktreeCardProperties) + const agentActivityDisplayMode = + useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE + const projectGroups = useAppStore((s) => s.projectGroups) + const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true + const compactCards = !newCardStyle && settings?.compactWorktreeCards === true + const handleEditIssue = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + openModal('edit-meta', { + worktreeId: worktree.id, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on the clicked row instead of the ambiguous lookup. + repoId: worktree.repoId, + currentDisplayName: worktree.displayName, + currentIssue: worktree.linkedIssue, + currentPR: worktree.linkedPR, + currentComment: worktree.comment, + focus: 'issue' + }) + }, + [worktree, openModal] + ) + + const handleEditComment = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + openModal('edit-meta', { + worktreeId: worktree.id, + repoId: worktree.repoId, + currentDisplayName: worktree.displayName, + currentIssue: worktree.linkedIssue, + currentPR: worktree.linkedPR, + currentComment: worktree.comment, + focus: 'comment' + }) + }, + [worktree, openModal] + ) + + const handleOpenAutomation = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const automationId = worktree.automationProvenance?.automationId + if (!automationId) { + return + } + const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId + setPendingAutomationRunNavigation({ + automationId, + runId: null, + ...(hostId ? { hostId } : {}) + }) + openAutomationsPage() + }, + [ + openAutomationsPage, + setPendingAutomationRunNavigation, + worktree.automationProvenance?.automationId, + worktree.automationProvenance?.hostId, + worktree.hostId + ] + ) + + const handleOpenAutomationRun = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const provenance = worktree.automationProvenance + if (!provenance) { + return + } + const hostId = provenance.hostId ?? worktree.hostId + setPendingAutomationRunNavigation({ + automationId: provenance.automationId, + runId: provenance.automationRunId, + ...(hostId ? { hostId } : {}) + }) + openAutomationsPage() + }, + [ + openAutomationsPage, + setPendingAutomationRunNavigation, + worktree.automationProvenance, + worktree.hostId + ] + ) + + const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) + const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id]) + const remoteBranchConflict = useAppStore((s) => s.remoteBranchConflictByWorktreeId[worktree.id]) + const workspacePorts = useAppStore( + (s) => + getWorkspacePortsByWorktreeId(s.workspacePortScan?.result).get(worktree.id) ?? + EMPTY_WORKSPACE_PORTS + ) + + const sshOwnerEnvironmentId = useAppStore((s) => + repo?.connectionId ? getExplicitRuntimeEnvironmentIdForWorktree(s, worktree.id) : null + ) + const sshStatus = useAppStore((s) => { + // Why: runtime-owned SSH targets suppress their ssh:state-changed broadcasts, so don't show a false "disconnected" chip for them. + if (!repo?.connectionId || isRuntimeOwnedSshTargetId(repo.connectionId)) { + return null + } + return selectRuntimeAwareSshStatus(s, sshOwnerEnvironmentId, repo.connectionId) + }) + useEffect(() => { + if (sshOwnerEnvironmentId) { + void hydrateRuntimeEnvironmentSshState(sshOwnerEnvironmentId).catch(() => {}) + } + }, [sshOwnerEnvironmentId]) + const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' + // Why: only reported on positive evidence, so a removed host never offers a Connect that can + // only fail. Runtime-owned targets are excluded for the same reason sshStatus excludes them — + // ssh:listTargets filters them out, so "absent from the target list" is not evidence of removal. + const sshTargetRemoved = useAppStore((s) => + repo?.connectionId && !isRuntimeOwnedSshTargetId(repo.connectionId) + ? selectRuntimeAwareSshTargetRemoved(s, sshOwnerEnvironmentId, repo.connectionId) + : false + ) + + const parsedRepoHost = parseExecutionHostId(repo?.executionHostId) + const runtimeOwnerEnvironmentId = + worktree.runtimeOwnerEnvironmentId ?? + (parsedRepoHost?.kind === 'runtime' ? parsedRepoHost.environmentId : null) + const runtimeHostId = runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(runtimeOwnerEnvironmentId) + : null + const runtimeEnvironmentName = useAppStore((s) => + runtimeOwnerEnvironmentId + ? (s.runtimeEnvironments.find((environment) => environment.id === runtimeOwnerEnvironmentId) + ?.name ?? null) + : null + ) + const runtimeHostLabel = runtimeHostId + ? (getHostDisplayLabelOverrides(settings).get(runtimeHostId) ?? runtimeEnvironmentName) + : null + // Why: runtime ("Orca server") hosts get the same disconnected dimming as SSH when their environment has no live status. + const isRuntimeDisconnected = useAppStore((s) => { + if (!runtimeOwnerEnvironmentId) { + return false + } + return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status + }) + const [titleRenaming, setTitleRenaming] = useState(false) + const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) + // Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state. + const sshTargetLabel = useAppStore((s) => + repo?.connectionId + ? selectRuntimeAwareSshTargetLabel(s, sshOwnerEnvironmentId, repo.connectionId) + : '' + ) + + return { + openModal, + openTaskPage, + updateWorktreeMeta, + deleteFolderWorkspace, + setActiveWorktree, + renamingWorktreeId, + setRenamingWorktreeId, + fetchHostedReviewForBranch, + settings, + fetchIssue, + fetchLinearIssue, + cardProps, + agentActivityDisplayMode, + projectGroups, + newCardStyle, + compactCards, + handleEditIssue, + handleEditComment, + handleOpenAutomation, + handleOpenAutomationRun, + deleteState, + conflictOperation, + remoteBranchConflict, + workspacePorts, + sshOwnerEnvironmentId, + sshStatus, + isSshDisconnected, + sshTargetRemoved, + parsedRepoHost, + runtimeHostLabel, + isRuntimeDisconnected, + titleRenaming, + setTitleRenaming, + showRenameErrorDialog, + setShowRenameErrorDialog, + sshTargetLabel + } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts b/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts new file mode 100644 index 00000000000..77dfa0331c3 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts @@ -0,0 +1,239 @@ +import { useEffect } from 'react' + +import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' +import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' +import { + HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS, + isWebClient, + type WorktreeCardProps +} from './worktree-card-model' +import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' +import type { useWorktreeCardReviewDetails } from './use-worktree-card-review-details' + +type Foundation = ReturnType +type ReviewDetails = ReturnType + +export function useWorktreeCardLifecycleEffects({ + worktree, + repo, + isFolder, + hostedReviewCacheKey, + cachedBranchFallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + branch, + fetchHostedReviewForBranch, + shouldRefreshHostedReview, + newCardStyle, + hoverDetailsOpen, + showIssue, + issueCacheKey, + fetchIssue, + showLinearIssue, + fetchLinearIssue +}: Pick & + Pick< + ReviewDetails, + | 'isFolder' + | 'hostedReviewCacheKey' + | 'cachedBranchFallbackGitHubPRNumber' + | 'linkedGitLabMR' + | 'linkedBitbucketPR' + | 'linkedAzureDevOpsPR' + | 'linkedGiteaPR' + | 'branch' + | 'issueCacheKey' + > & + Pick< + Foundation, + 'fetchHostedReviewForBranch' | 'newCardStyle' | 'fetchIssue' | 'fetchLinearIssue' + > & { + shouldRefreshHostedReview: boolean + hoverDetailsOpen: boolean + showIssue: boolean + showLinearIssue: boolean + }): void { + // Why: card surfaces are presentational, so skip hosted-review fetches when hidden to save rate-limit budget. + useEffect(() => { + // Why: paired web must not fan out per-card decoration RPCs during startup; host session/tab parity is critical. + if (isWebClient()) { + return + } + if ( + !repo || + isFolder || + worktree.isBare || + !hostedReviewCacheKey || + !shouldRefreshHostedReview || + isMacAppDataPath(repo.path) + ) { + return + } + const refreshHostedReview = (): void => { + // Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR number from explicit metadata when we have one. + void fetchHostedReviewForBranch(repo.path, branch, { + repoId: repo.id, + linkedGitHubPR: worktree.linkedPR ?? null, + ...(cachedBranchFallbackGitHubPRNumber !== null + ? { fallbackGitHubPR: cachedBranchFallbackGitHubPRNumber } + : {}), + currentHeadOid: worktree.head ?? null, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + staleWhileRevalidate: true + }) + } + // Why: PRs created outside Orca (e.g. `gh pr create`) emit no renderer event; poll visible cards to discover them. + return installWindowVisibilityInterval({ + run: refreshHostedReview, + intervalMs: HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS + }) + }, [ + repo, + isFolder, + worktree.isBare, + worktree.linkedPR, + worktree.head, + cachedBranchFallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + fetchHostedReviewForBranch, + branch, + hostedReviewCacheKey, + shouldRefreshHostedReview + ]) + + useEffect(() => { + if ( + !newCardStyle || + !hoverDetailsOpen || + shouldRefreshHostedReview || + isWebClient() || + !repo || + isFolder || + worktree.isBare || + !hostedReviewCacheKey || + isMacAppDataPath(repo.path) + ) { + return + } + // Why: hidden card metadata is revealed on whole-card hover, so fetch lazily instead of always-on polling. + void fetchHostedReviewForBranch(repo.path, branch, { + repoId: repo.id, + linkedGitHubPR: worktree.linkedPR ?? null, + ...(cachedBranchFallbackGitHubPRNumber !== null + ? { fallbackGitHubPR: cachedBranchFallbackGitHubPRNumber } + : {}), + currentHeadOid: worktree.head ?? null, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + staleWhileRevalidate: true + }) + }, [ + hoverDetailsOpen, + newCardStyle, + shouldRefreshHostedReview, + repo, + isFolder, + worktree.isBare, + worktree.linkedPR, + worktree.head, + cachedBranchFallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + fetchHostedReviewForBranch, + branch, + hostedReviewCacheKey + ]) + + // Why: same as above for issues — hidden-surface polling only burns GitHub calls for invisible data. + useEffect(() => { + // Why: per-card decoration lookups from the browser flood the RPC path at paired-web startup; the host is authoritative. + if ( + isWebClient() || + !repo || + isFolder || + !worktree.linkedIssue || + !issueCacheKey || + !showIssue + ) { + return + } + + const issueNumber = worktree.linkedIssue + + // Why: fallback poll behind activity triggers; stopped while hidden to avoid waking idle workspaces. + return installWindowVisibilityInterval({ + run: () => void fetchIssue(repo.path, issueNumber, { repoId: repo.id }), + intervalMs: 5 * 60_000 + }) + }, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue]) + + useEffect(() => { + if ( + !newCardStyle || + !hoverDetailsOpen || + showIssue || + isWebClient() || + !repo || + isFolder || + !worktree.linkedIssue || + !issueCacheKey + ) { + return + } + void fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) + }, [ + newCardStyle, + hoverDetailsOpen, + showIssue, + repo, + isFolder, + worktree.linkedIssue, + fetchIssue, + issueCacheKey + ]) + + useEffect(() => { + if (!worktree.linkedLinearIssue || !showLinearIssue) { + return + } + const linearIssueId = worktree.linkedLinearIssue + const refreshLinearIssueIfVisible = (): void => { + if (!isWindowVisible()) { + return + } + void fetchLinearIssue(linearIssueId, 'all') + } + refreshLinearIssueIfVisible() + window.addEventListener('focus', refreshLinearIssueIfVisible) + document.addEventListener('visibilitychange', refreshLinearIssueIfVisible) + return () => { + window.removeEventListener('focus', refreshLinearIssueIfVisible) + document.removeEventListener('visibilitychange', refreshLinearIssueIfVisible) + } + }, [worktree.linkedLinearIssue, fetchLinearIssue, showLinearIssue]) + + useEffect(() => { + if (!newCardStyle || !hoverDetailsOpen || showLinearIssue || !worktree.linkedLinearIssue) { + return + } + void fetchLinearIssue(worktree.linkedLinearIssue, 'all') + }, [ + newCardStyle, + hoverDetailsOpen, + showLinearIssue, + worktree.linkedLinearIssue, + fetchLinearIssue + ]) +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts new file mode 100644 index 00000000000..fcf91b71a93 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-linked-details.ts @@ -0,0 +1,137 @@ +import React from 'react' + +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { IssueInfo, LinearIssue } from '../../../../shared/types' +import { getWorktreeCardJiraIssueDisplay } from './worktree-card-jira-issue-display' +import type { WorktreeCardIssueDisplay } from './WorktreeCardMeta' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' +import { useWorkspaceDeleteModifierPressed } from './workspace-delete-quick-action' +import type { WorktreeCardProps } from './worktree-card-model' +import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' +import type { useWorktreeCardReviewDetails } from './use-worktree-card-review-details' + +type Foundation = ReturnType +type ReviewDetails = ReturnType + +export function useWorktreeCardLinkedDetails({ + worktree, + newCardStyle, + deleteState, + branch, + issueEntry, + linearIssueEntry, + linearIssueFallbackEntry, + prDisplay +}: Pick & + Pick & + Pick< + ReviewDetails, + 'branch' | 'issueEntry' | 'linearIssueEntry' | 'linearIssueFallbackEntry' | 'prDisplay' + >) { + const issue: IssueInfo | null | undefined = worktree.linkedIssue + ? issueEntry !== undefined + ? issueEntry.data + : undefined + : null + const issueDisplay: WorktreeCardIssueDisplay | null = + issue ?? + (worktree.linkedIssue + ? { + number: worktree.linkedIssue, + // Why: linked metadata persists immediately but GitHub details arrive async; show the link number so it doesn't look unlinked. + title: issue === null ? 'Issue details unavailable' : 'Loading issue...' + } + : null) + const linearStatus = useAppStore((s) => s.linearStatus) + const linearIssue: LinearIssue | null | undefined = worktree.linkedLinearIssue + ? (linearIssueEntry?.data ?? linearIssueFallbackEntry?.data) + : null + + // Why: build a fallback Linear URL from org key + identifier while full issue data is still loading, so the link stays navigable. + const linearOrgUrlKey = linearStatus?.viewer?.organizationUrlKey + const linearWorkspaceUrlKeys = linearStatus?.workspaces?.map((ws) => ({ + id: ws.id, + organizationUrlKey: ws.organizationUrlKey + })) + const linearIssueUrlFallback = React.useMemo(() => { + if (!worktree.linkedLinearIssue || linearIssue?.url) { + return undefined + } + + // Try to get the orgUrlKey from the issue's workspace if we have workspaceId + let orgUrlKey: string | undefined + if (linearIssue?.workspaceId && linearWorkspaceUrlKeys) { + const issueWorkspace = linearWorkspaceUrlKeys.find((ws) => ws.id === linearIssue.workspaceId) + orgUrlKey = issueWorkspace?.organizationUrlKey + } + + // Fall back to current viewer's org if no workspace match + if (!orgUrlKey) { + orgUrlKey = linearOrgUrlKey + } + + if (!orgUrlKey) { + return undefined + } + + return `https://linear.app/${encodeURIComponent(orgUrlKey)}/issue/${encodeURIComponent(worktree.linkedLinearIssue)}` + }, [ + worktree.linkedLinearIssue, + linearIssue?.url, + linearIssue?.workspaceId, + linearOrgUrlKey, + linearWorkspaceUrlKeys + ]) + + const linearIssueDisplay = worktree.linkedLinearIssue + ? linearIssue + ? { + identifier: linearIssue.identifier, + title: linearIssue.title, + url: linearIssue.url, + stateName: linearIssue.state?.name, + labels: linearIssue.labels + } + : { + identifier: worktree.linkedLinearIssue, + title: + linearIssueEntry || linearIssueFallbackEntry + ? 'Linear issue details unavailable' + : 'Loading Linear issue...', + url: linearIssueUrlFallback + } + : null + const jiraIssueDisplay = getWorktreeCardJiraIssueDisplay(worktree) + const cardTitleDisplay = getWorktreeCardTitleDisplay({ + storedDisplayName: worktree.displayName, + branchName: branch, + linearIssueTitle: linearIssueDisplay?.title, + jiraIssueTitle: jiraIssueDisplay?.title, + issueTitle: issueDisplay?.title, + reviewTitle: prDisplay?.title + }) + const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) + const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay + const isDeleting = deleteState?.isDeleting ?? false + const isQueuedForDeletion = deleteState?.phase === 'queued' + const deleteLabel = isQueuedForDeletion + ? translate('auto.components.sidebar.WorktreeCard.ef18787206', 'Queued for deletion') + : translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…') + const deleteModifierPressed = useWorkspaceDeleteModifierPressed() + + return { + issueDisplay, + linearIssue, + linearIssueDisplay, + jiraIssueDisplay, + visibleCardTitle, + isDeleting, + isQueuedForDeletion, + deleteLabel, + deleteModifierPressed + } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts new file mode 100644 index 00000000000..23129542be2 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -0,0 +1,198 @@ +import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { useAppStore } from '@/store' +import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + getWorktreeCardPrDisplay, + isCachedMergedBranchPRCurrentForWorktree +} from './worktree-card-pr-display' +import type { WorktreeCardProps } from './worktree-card-model' +import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' + +type Foundation = ReturnType + +export function useWorktreeCardReviewDetails({ + worktree, + repo, + settings, + projectGroups, + cardProps, + newCardStyle +}: Pick & + Pick) { + const gitIdentityDisplay = getWorktreeGitIdentityDisplay(worktree) + const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null + const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' + const workspaceScope = parseWorkspaceKey(worktree.id) + const folderWorkspaceId = + workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null + const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null + // Why: project groups gate folder workspaces, so folder paths stay hidden from identity surfaces until that capability exists. + const hasProjectGroups = projectGroups.length > 0 + const branchIdentityDisplay = !isFolder && branch.length > 0 ? branch : undefined + const folderPathIdentityDisplay = + isFolder && hasProjectGroups && worktree.path.trim().length > 0 ? worktree.path : undefined + const identityDisplay = branchIdentityDisplay ?? folderPathIdentityDisplay + const hasPathIdentityEnabled = cardProps.includes('branch') + const showIdentityInNewCard = newCardStyle && hasPathIdentityEnabled && Boolean(identityDisplay) + const folderMetaRowContent = newCardStyle + ? hasPathIdentityEnabled && Boolean(folderPathIdentityDisplay) + : isFolder + const hostedReviewCacheKey = + repo && branch + ? getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId, + true + ) + : '' + const prCacheKey = + repo && branch + ? getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId, + true + ) + : '' + const issueCacheKey = + repo && worktree.linkedIssue + ? getIssueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + settings, + repo.connectionId, + repo.executionHostId, + true + ) + : '' + // Why: use 'all' — the issue may belong to a different Linear workspace than the selected one. + const linearIssueCacheKey = worktree.linkedLinearIssue ? `all::${worktree.linkedLinearIssue}` : '' + + // Subscribe to ONLY the specific cache entry, not entire review/issue caches. + const hostedReviewEntry = useAppStore((s) => + hostedReviewCacheKey ? s.hostedReviewCache[hostedReviewCacheKey] : undefined + ) + const prCacheEntry = useAppStore((s) => (prCacheKey ? s.prCache?.[prCacheKey] : undefined)) + const issueEntry = useAppStore((s) => (issueCacheKey ? s.issueCache[issueCacheKey] : undefined)) + const linearIssueEntry = useAppStore((s) => + linearIssueCacheKey ? s.linearIssueCache[linearIssueCacheKey] : undefined + ) + const linearIssueFallbackEntry = useAppStore((s) => + worktree.linkedLinearIssue ? s.linearIssueCache[worktree.linkedLinearIssue] : undefined + ) + + const hostedReview: HostedReviewInfo | null | undefined = + hostedReviewEntry !== undefined ? hostedReviewEntry.data : undefined + const linkedGitHubPR = worktree.linkedPR ?? null + const linkedGitLabMR = worktree.linkedGitLabMR ?? null + const linkedBitbucketPR = worktree.linkedBitbucketPR ?? null + const linkedAzureDevOpsPR = worktree.linkedAzureDevOpsPR ?? null + const linkedGiteaPR = worktree.linkedGiteaPR ?? null + const hasNonGitHubLinkedReview = + linkedGitLabMR !== null || + linkedBitbucketPR !== null || + linkedAzureDevOpsPR !== null || + linkedGiteaPR !== null + const hasLinkedReview = + linkedGitHubPR !== null || + linkedGitLabMR !== null || + linkedBitbucketPR !== null || + linkedAzureDevOpsPR !== null || + linkedGiteaPR !== null + // Why: a newer hosted-review miss trusts the merged-PR cache only when the stored head proves it still describes the current commit. + const cachedBranchPR = prCacheEntry?.data + const cachedBranchPRFetchedAt = prCacheEntry?.fetchedAt + const cachedMergedBranchPRMatchesCurrentHead = isCachedMergedBranchPRCurrentForWorktree( + cachedBranchPR, + worktree + ) + const cachedBranchFallbackGitHubPRNumber = + linkedGitHubPR === null && + !hasNonGitHubLinkedReview && + cachedBranchPR?.number !== undefined && + (cachedBranchPR.state !== 'merged' || cachedMergedBranchPRMatchesCurrentHead) + ? cachedBranchPR.number + : null + const cachedBranchPRCanDriveDisplay = + cachedBranchPR?.state !== 'merged' || cachedMergedBranchPRMatchesCurrentHead + const hostedReviewMatchesHeadMatchedCachedMergedPR = + cachedMergedBranchPRMatchesCurrentHead && + cachedBranchPR !== null && + cachedBranchPR !== undefined && + hostedReview?.provider === 'github' && + hostedReview.number === cachedBranchPR.number + const useCachedBranchReview = + cachedBranchPR !== undefined && + cachedBranchPR !== null && + !hasNonGitHubLinkedReview && + cachedBranchPRCanDriveDisplay && + (hostedReview === undefined || + (cachedMergedBranchPRMatchesCurrentHead && !hostedReviewMatchesHeadMatchedCachedMergedPR) || + (hostedReview === null && + ((cachedBranchPRFetchedAt !== undefined && + cachedBranchPRFetchedAt > (hostedReviewEntry?.fetchedAt ?? 0)) || + cachedMergedBranchPRMatchesCurrentHead))) + const cachedBranchReview = useCachedBranchReview + ? hostedReviewInfoFromGitHubPRInfo(cachedBranchPR) + : hostedReview + // Why: branch provenance does not supersede the head-ownership gate for merged PRs. + const branchLookupGitHubPRNumber = + hostedReview?.provider === 'github' && + hostedReview.state === 'merged' && + !isCachedMergedBranchPRCurrentForWorktree(hostedReview, worktree) + ? null + : hostedReviewEntry?.branchLookupGitHubPRNumber + const prDisplay = getWorktreeCardPrDisplay( + cachedBranchReview, + linkedGitHubPR, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + { + reviewHintKey: + (useCachedBranchReview || cachedMergedBranchPRMatchesCurrentHead) && !hasLinkedReview + ? '' + : hostedReviewEntry?.linkedReviewHintKey, + branchLookupGitHubPRNumber + } + ) + + return { + detachedHeadDisplay, + branch, + folderWorkspaceId, + isFolder, + branchIdentityDisplay, + folderPathIdentityDisplay, + identityDisplay, + showIdentityInNewCard, + folderMetaRowContent, + hostedReviewCacheKey, + issueCacheKey, + issueEntry, + linearIssueEntry, + linearIssueFallbackEntry, + linkedGitHubPR, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + cachedBranchFallbackGitHubPRNumber, + prDisplay + } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts new file mode 100644 index 00000000000..b5a8a6292ba --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-secondary-details.ts @@ -0,0 +1,224 @@ +import React, { useCallback } from 'react' + +import type { GitHubWorkItem } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { hasWorktreeCardDetails } from './WorktreeCardMeta' +import { usePromptCacheCountdownStartedAt } from './CacheTimer' +import { useWorktreeAgentRows } from './useWorktreeAgentRows' +import type { WorktreeCardProps } from './worktree-card-model' +import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' +import type { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details' +import type { useWorktreeCardReviewDetails } from './use-worktree-card-review-details' + +type Foundation = ReturnType +type LinkedDetails = ReturnType +type ReviewDetails = ReturnType + +export function useWorktreeCardSecondaryDetails({ + worktree, + repo, + statusPrDisplay, + showStatus, + showIssue, + showLinearIssue, + showJiraIssue, + showPR, + showAutomation, + showCli, + showComment, + showPorts, + issueDisplay, + linearIssue, + linearIssueDisplay, + jiraIssueDisplay, + prDisplay, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + cardProps, + newCardStyle, + compactCards, + agentActivityDisplayMode, + workspacePorts, + openTaskPage, + updateWorktreeMeta +}: Pick & + Pick< + Foundation, + | 'cardProps' + | 'newCardStyle' + | 'compactCards' + | 'agentActivityDisplayMode' + | 'workspacePorts' + | 'openTaskPage' + | 'updateWorktreeMeta' + > & + Pick & + Pick< + ReviewDetails, + 'prDisplay' | 'linkedGitLabMR' | 'linkedBitbucketPR' | 'linkedAzureDevOpsPR' | 'linkedGiteaPR' + > & { + showStatus: boolean + showIssue: boolean + showLinearIssue: boolean + showJiraIssue: boolean + showPR: boolean + showAutomation: boolean + showCli: boolean + showComment: boolean + showPorts: boolean + }) { + // Why: unread lives in the left status lane, so the Status toggle owns both the dot/PR slot and unread emphasis. + const showUnreadEmphasis = showStatus && worktree.isUnread + const hoverIssue = issueDisplay + const hoverLinearIssue = linearIssueDisplay + const hoverJiraIssue = jiraIssueDisplay + const hoverReview = prDisplay + const statusLaneReview = statusPrDisplay ?? hoverReview + const hoverComment = worktree.comment + const metaIssue = showIssue ? hoverIssue : null + const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null + const metaJiraIssue = showJiraIssue ? hoverJiraIssue : null + const metaReview = showPR ? hoverReview : null + const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null + const metaCliProvenance = showCli ? worktree.cliProvenance : null + const metaComment = showComment ? hoverComment : null + const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards) + const compactInlineAgentRows = useWorktreeAgentRows( + worktree.id, + showInlineAgentList && agentActivityDisplayMode === 'compact' + ) + const compactInlineAgentRowsVisible = + showInlineAgentList && + agentActivityDisplayMode === 'compact' && + compactInlineAgentRows.length > 0 + const showAggregateCacheTimer = !compactCards && !compactInlineAgentRowsVisible + const handleOpenGitHubIssueInOrca = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const issueUrl = hoverIssue && 'url' in hoverIssue ? hoverIssue.url : undefined + if (!repo || !hoverIssue || !issueUrl) { + return + } + const item: GitHubWorkItem = { + id: issueUrl, + type: 'issue', + number: hoverIssue.number, + title: hoverIssue.title, + state: 'state' in hoverIssue ? (hoverIssue.state ?? 'open') : 'open', + url: issueUrl, + labels: 'labels' in hoverIssue ? (hoverIssue.labels ?? []) : [], + updatedAt: new Date().toISOString(), + author: null, + repoId: repo.id + } + openTaskPage({ taskSource: 'github', preselectedRepoId: repo.id, openGitHubWorkItem: item }) + }, + [hoverIssue, openTaskPage, repo] + ) + const handleOpenReviewInOrca = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!repo || !hoverReview?.url || hoverReview.provider !== 'github') { + return + } + const item: GitHubWorkItem = { + id: hoverReview.url, + type: 'pr', + number: hoverReview.number, + title: hoverReview.title, + state: hoverReview.state ?? 'open', + url: hoverReview.url, + labels: [], + updatedAt: 'updatedAt' in hoverReview ? hoverReview.updatedAt : new Date().toISOString(), + author: null, + headSha: 'headSha' in hoverReview ? hoverReview.headSha : undefined, + repoId: repo.id + } + openTaskPage({ taskSource: 'github', preselectedRepoId: repo.id, openGitHubWorkItem: item }) + }, + [hoverReview, openTaskPage, repo] + ) + const hoverReviewProvider = hoverReview?.provider + const hasExplicitLinkedReview = + (hoverReviewProvider === 'github' && worktree.linkedPR !== null) || + (hoverReviewProvider === 'gitlab' && linkedGitLabMR !== null) || + (hoverReviewProvider === 'bitbucket' && linkedBitbucketPR !== null) || + (hoverReviewProvider === 'azure-devops' && linkedAzureDevOpsPR !== null) || + (hoverReviewProvider === 'gitea' && linkedGiteaPR !== null) + const handleUnlinkReview = useCallback(() => { + switch (hoverReviewProvider) { + case 'github': + void updateWorktreeMeta(worktree.id, { linkedPR: null }) + return + case 'gitlab': + void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null }) + return + case 'bitbucket': + void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null }) + return + case 'azure-devops': + void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null }) + return + case 'gitea': + void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null }) + break + case 'unsupported': + case undefined: + break + } + }, [hoverReviewProvider, updateWorktreeMeta, worktree.id]) + const handleOpenLinearIssueInOrca = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!linearIssue) { + return + } + openTaskPage({ taskSource: 'linear', openLinearIssue: linearIssue }) + }, + [linearIssue, openTaskPage] + ) + const hasDetails = hasWorktreeCardDetails({ + issue: metaIssue, + linearIssue: metaLinearIssue, + jiraIssue: metaJiraIssue, + review: newCardStyle ? null : metaReview, + comment: metaComment, + automationProvenance: metaAutomationProvenance, + cliProvenance: metaCliProvenance + }) + const hasPorts = showPorts && workspacePorts.length > 0 + const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id, showAggregateCacheTimer) + const cacheTtlMs = useAppStore((s) => + showAggregateCacheTimer ? (s.settings?.promptCacheTtlMs ?? 0) : 0 + ) + + return { + showUnreadEmphasis, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + statusLaneReview, + hoverComment, + metaIssue, + metaLinearIssue, + metaJiraIssue, + metaReview, + metaAutomationProvenance, + metaCliProvenance, + metaComment, + showInlineAgentList, + compactInlineAgentRows, + handleOpenGitHubIssueInOrca, + handleOpenReviewInOrca, + hasExplicitLinkedReview, + handleUnlinkReview, + handleOpenLinearIssueInOrca, + hasDetails, + hasPorts, + cacheStartedAt, + cacheTtlMs + } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts b/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts new file mode 100644 index 00000000000..7ea43da8911 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts @@ -0,0 +1,173 @@ +import React, { useCallback } from 'react' + +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { runWorktreeDelete } from './delete-worktree-flow' +import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' +import type { ResolvedWorktreeCardProps } from './worktree-card-model' +import { writeWorkspaceDragData } from './workspace-status' +import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' +import type { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details' +import type { useWorktreeCardReviewDetails } from './use-worktree-card-review-details' + +type Foundation = ReturnType +type LinkedDetails = ReturnType +type ReviewDetails = ReturnType + +export function useWorktreeCardWorkspaceActions({ + worktree, + lineageChildCount, + lineageCollapsed, + onLineageToggle, + isMultiSelected, + selectedWorktrees, + onCardDragStart, + onCardDragEnd, + onContextMenuSelect, + folderWorkspaceId, + deleteFolderWorkspace, + setActiveWorktree, + setShowRenameErrorDialog, + isDeleting, + showDeleteQuickAction +}: Pick< + ResolvedWorktreeCardProps, + | 'worktree' + | 'lineageChildCount' + | 'lineageCollapsed' + | 'onLineageToggle' + | 'isMultiSelected' + | 'selectedWorktrees' + | 'onCardDragStart' + | 'onCardDragEnd' + | 'onContextMenuSelect' +> & + Pick & + Pick & + Pick & { + showDeleteQuickAction: boolean + }) { + const handleWorkspaceQuickAction = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + if (showDeleteQuickAction) { + if (folderWorkspaceId) { + void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => { + if ( + deleted && + useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) + ) { + setActiveWorktree(null) + } + }) + return + } + runWorktreeDelete(worktree.id) + } + }, + [ + deleteFolderWorkspace, + folderWorkspaceId, + setActiveWorktree, + showDeleteQuickAction, + worktree.id + ] + ) + const handleOpenRenameErrorDialog = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setShowRenameErrorDialog(true) + }, + [setShowRenameErrorDialog] + ) + const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread' + const lineageChildAriaLabel = + lineageChildCount === 1 + ? lineageCollapsed + ? translate( + 'auto.components.sidebar.WorktreeList.20bebf9c7f', + 'Show {{value0}} child workspace', + { value0: lineageChildCount } + ) + : translate( + 'auto.components.sidebar.WorktreeList.e97297cb75', + 'Hide {{value0}} child workspace', + { value0: lineageChildCount } + ) + : lineageCollapsed + ? translate( + 'auto.components.sidebar.WorktreeList.c1f4a31623', + 'Show {{value0}} child workspaces', + { value0: lineageChildCount } + ) + : translate( + 'auto.components.sidebar.WorktreeList.0cd15956d4', + 'Hide {{value0}} child workspaces', + { value0: lineageChildCount } + ) + const childWorkspaceShortLabel = `${lineageChildCount} ${ + lineageChildCount === 1 + ? translate('auto.components.sidebar.WorktreeList.0c6ee14f23', 'child') + : translate('auto.components.sidebar.WorktreeList.045a8aed48', 'children') + }` + const showLineageChildChip = lineageChildCount > 0 && onLineageToggle !== undefined + + const handleDragStart = useCallback( + (event: React.DragEvent) => { + if (!isEventTargetInsideCurrentTarget(event.currentTarget, event.target)) { + event.preventDefault() + return + } + if (isDeleting) { + event.preventDefault() + return + } + const dragIds = + isMultiSelected && selectedWorktrees && selectedWorktrees.length > 1 + ? selectedWorktrees.map((item) => item.id) + : worktree.id + writeWorkspaceDragData(event.dataTransfer, dragIds) + onCardDragStart?.(event, worktree.id, Array.isArray(dragIds) ? dragIds : [dragIds]) + }, + [isDeleting, isMultiSelected, onCardDragStart, selectedWorktrees, worktree.id] + ) + + const handleDragEnd = useCallback( + (event: React.DragEvent) => { + if (!isEventTargetInsideCurrentTarget(event.currentTarget, event.target)) { + return + } + onCardDragEnd?.(event) + }, + [onCardDragEnd] + ) + + const handleContextMenuSelect = useCallback( + (event: React.MouseEvent) => onContextMenuSelect?.(event, worktree) ?? [worktree], + [onContextMenuSelect, worktree] + ) + + const stopQuickActionPointerPropagation = useCallback( + (event: React.PointerEvent) => { + // Why: document-level pointer handling dismisses the Kanban board; quick actions must not count as card activation. + event.stopPropagation() + }, + [] + ) + + return { + handleWorkspaceQuickAction, + handleOpenRenameErrorDialog, + unreadTooltip, + lineageChildAriaLabel, + childWorkspaceShortLabel, + showLineageChildChip, + handleDragStart, + handleDragEnd, + handleContextMenuSelect, + stopQuickActionPointerPropagation + } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-header.tsx b/src/renderer/src/components/sidebar/worktree-card-header.tsx new file mode 100644 index 00000000000..0c063e28dee --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-header.tsx @@ -0,0 +1,321 @@ +import React from 'react' +import { AlertCircle, Server, ServerOff, Star, Trash2 } from 'lucide-react' + +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { Repo } from '../../../../shared/types' +import { resolveRepoHeaderColor } from './project-header-color' +import { formatSparseDirectoryPreview, shouldBeginWorktreeRename } from './worktree-card-model' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl' +import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename' +import type { WorktreeCardController } from './use-worktree-card-controller' + +// Why: pinned repo icon and compact inline badge share this chip shell so both repo cues read as the same affordance. +function RepoIdentityChip({ + repo, + children +}: { + repo: Repo + children: React.ReactNode +}): React.JSX.Element { + return ( + + + + {children} + + + + {repo.displayName} + + + ) +} + +export function WorktreeCardHeader({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + affiliateListMode, + renameRowKey, + compactCards, + newCardStyle, + parsedRepoHost, + sshTargetLabel, + sshStatus, + sshTargetRemoved, + sshOwnerEnvironmentId, + stopQuickActionPointerPropagation, + isRuntimeDisconnected, + runtimeHostLabel, + visibleCardTitle, + isDeleting, + showUnreadEmphasis, + setTitleRenaming, + handleRenameTitle, + renamingWorktreeId, + setRenamingWorktreeId, + titleRenaming, + handleOpenRenameErrorDialog, + isFolder, + handleWorkspaceQuickAction + } = card + const { + showPinnedRepoIcon, + showInlineRepoBadge, + showHeaderActions, + showTitleRowPrimary, + showDeleteQuickAction, + showTitleRowIndicators, + titleRowIndicators, + titleWrapper + } = presentation + + return ( +
+
+ {showPinnedRepoIcon && ( + + + + )} + + {repo?.connectionId && ( + + )} + + {!repo?.connectionId && parsedRepoHost?.kind === 'runtime' && ( + + + + {isRuntimeDisconnected ? ( + // Passive by design: runtime ("Orca server") hosts have no + // renderer-reachable connect API, unlike the SSH glyph above which is + // now a control. Don't "fix" the inconsistency by wiring one up. + + ) : ( + + )} + + + + {isRuntimeDisconnected + ? runtimeHostLabel + ? translate( + 'auto.components.sidebar.WorktreeCard.runtimeHostDisconnectedNamed', + '{{hostName}} disconnected', + { hostName: runtimeHostLabel } + ) + : translate( + 'auto.components.sidebar.WorktreeCard.runtimeHostDisconnected', + 'Server disconnected' + ) + : runtimeHostLabel + ? translate( + 'auto.components.sidebar.WorktreeCard.runtimeHostProjectNamed', + 'Project on {{hostName}}', + { hostName: runtimeHostLabel } + ) + : translate( + 'auto.components.sidebar.WorktreeCard.runtimeHostProject', + 'Project on Orca server' + )} + + + )} + + {showInlineRepoBadge && ( + + + + )} + + {/* Why: unread alert lives in the left status lane; title-row contrast comes from weight and dimmed read titles. */} + setRenamingWorktreeId(null)} + /> + + {typeof worktree.firstAgentMessageRenameError === 'string' && + worktree.firstAgentMessageRenameError.length > 0 && + !titleRenaming ? ( + // Why: the error can be raw agent CLI output, so the badge opens a dialog rather than a tooltip. + + + + + + {translate( + 'auto.components.sidebar.WorktreeCard.4eba2ea99e', + 'Auto-name failed. Click to see details.' + )} + + + ) : null} + {!compactCards && worktree.isMainWorktree && !isFolder && ( + + + + {translate('auto.components.sidebar.WorktreeCard.7d517f82e2', 'primary')} + + + + {translate( + 'auto.components.sidebar.WorktreeCard.0777de5970', + 'Primary worktree (original clone directory)' + )} + + + )} + + {worktree.isSparse && ( + + + + {translate('auto.components.sidebar.WorktreeCard.4f964d5e8c', 'sparse')} + + + +
+
+ {translate( + 'auto.components.sidebar.WorktreeCard.0f33af979b', + 'Partial checkout. Files outside these paths are not on disk.' + )} +
+ {worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? ( +
+ {formatSparseDirectoryPreview(worktree.sparseDirectories)} +
+ ) : null} +
+
+
+ )} + + {showTitleRowIndicators && titleRowIndicators} +
+ + {showHeaderActions && ( +
+ {showTitleRowPrimary && ( + + + + + + + + {translate( + 'auto.components.sidebar.WorktreeCard.0777de5970', + 'Primary worktree (original clone directory)' + )} + + + )} + + {showDeleteQuickAction && ( + + + + + + {translate('auto.components.sidebar.WorktreeCard.6f09f58541', 'Delete workspace')} + + + )} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx b/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx new file mode 100644 index 00000000000..1c4e3343af5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { GitMerge } from 'lucide-react' + +import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { Badge } from '@/components/ui/badge' +import CacheTimer from './CacheTimer' +import { CONFLICT_OPERATION_LABELS } from './WorktreeCardHelpers' +import { TruncatedSidebarLabel } from './truncated-sidebar-label' +import { getDirectoryName } from './worktree-card-model' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardMetaRow({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + hostContextLabel, + identityDisplay, + isFolder, + newCardStyle, + branch, + detachedHeadDisplay, + conflictOperation, + cacheStartedAt, + cacheTtlMs + } = card + const { + showRepoBadgeInMetaRow, + showHostContextBadge, + showIdentityInNewCard, + hasHoverDetails, + showBranch, + showDetachedHeadInMetaRow, + showConflictOperationBadge, + showMetaRowDetails, + detailsAndPorts + } = presentation + + return ( +
+
+ {showRepoBadgeInMetaRow && repo && ( +
+ + + {repo.displayName} + +
+ )} + + {showHostContextBadge && ( + + {hostContextLabel} + + )} + + {showIdentityInNewCard ? ( + + ) : isFolder && !newCardStyle ? ( + + {getDirectoryName(worktree.path)} + + ) : showBranch ? ( + + ) : showDetachedHeadInMetaRow && detachedHeadDisplay ? ( + + ) : null} + + {showConflictOperationBadge && ( + + + {CONFLICT_OPERATION_LABELS[conflictOperation]} + + )} + + {cacheStartedAt != null && } +
+ + {showMetaRowDetails && ( +
{detailsAndPorts}
+ )} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts new file mode 100644 index 00000000000..2331b92d2cd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -0,0 +1,116 @@ +import type React from 'react' + +import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' + +export type WorktreeRenameRequest = { + worktreeId: string + rowKey?: string +} + +export type ActiveSurfaceVariant = 'primary' | 'secondary' + +export type WorktreeCardProps = { + worktree: Worktree + repo: Repo | undefined + isActive: boolean + isCurrentWorktree?: boolean + isActiveSurface?: boolean + activeSurfaceVariant?: ActiveSurfaceVariant + isMultiSelected?: boolean + revealHighlight?: boolean + revealHighlightTone?: 'default' | 'ai' + selectedWorktrees?: readonly Worktree[] + hideRepoBadge?: boolean + hostContextLabel?: string + inPinnedSection?: boolean + activationRowKey?: string + renameRowKey?: string + contentIndent?: number + flushSurface?: boolean + lineageChildCount?: number + lineageCollapsed?: boolean + lineageChildren?: React.ReactNode + lineageChildrenStyle?: React.CSSProperties + onLineageToggle?: (event: React.MouseEvent) => void + isLineageDropTarget?: boolean + onActivate?: () => void + onImmediateActivate?: (worktreeId: string, rowKey: string | undefined) => void + onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect?: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onCardDragStart?: ( + event: React.DragEvent, + worktreeId: string, + draggedIds: readonly string[] + ) => void + onCardDragEnd?: (event: React.DragEvent) => void + nativeDragEnabled?: boolean + affiliateListMode?: boolean + statusPrDisplay?: WorktreeCardPrDisplay | null +} + +type DefaultedWorktreeCardProp = + | 'isActiveSurface' + | 'activeSurfaceVariant' + | 'isMultiSelected' + | 'revealHighlight' + | 'revealHighlightTone' + | 'nativeDragEnabled' + | 'inPinnedSection' + | 'contentIndent' + | 'flushSurface' + | 'lineageChildCount' + | 'lineageCollapsed' + | 'isLineageDropTarget' + | 'affiliateListMode' + | 'statusPrDisplay' + +export type ResolvedWorktreeCardProps = Omit & { + isActiveSurface: boolean + activeSurfaceVariant: ActiveSurfaceVariant + isMultiSelected: boolean + revealHighlight: boolean + revealHighlightTone: 'default' | 'ai' + nativeDragEnabled: boolean + inPinnedSection: boolean + contentIndent: number + flushSurface: boolean + lineageChildCount: number + lineageCollapsed: boolean + isLineageDropTarget: boolean + affiliateListMode: boolean + statusPrDisplay: WorktreeCardPrDisplay | null +} + +export const EMPTY_WORKSPACE_PORTS = [] +export const HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS = 60_000 + +export function shouldBeginWorktreeRename( + request: WorktreeRenameRequest | null, + worktreeId: string, + rowKey: string | undefined +): boolean { + return ( + request?.worktreeId === worktreeId && + (request.rowKey === undefined || request.rowKey === rowKey) + ) +} + +export function formatSparseDirectoryPreview(directories: string[]): string { + const preview = directories.slice(0, 4).join(', ') + return directories.length <= 4 ? preview : `${preview}, +${directories.length - 4} more` +} + +export function isWebClient(): boolean { + return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +} + +export function getDirectoryName(folderPath: string): string { + const normalized = folderPath.replace(/[\\/]+$/, '') + const parts = normalized.split(/[\\/]+/) + return parts.at(-1) || normalized || folderPath +} diff --git a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx new file mode 100644 index 00000000000..d1b24cca2ad --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx @@ -0,0 +1,86 @@ +import React from 'react' + +import { cn } from '@/lib/utils' +import { WorktreeCardHeader } from './worktree-card-header' +import { WorktreeCardMetaRow } from './worktree-card-meta-row' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import { WorktreeCardSecondaryRows } from './worktree-card-secondary-rows' +import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardParentContent({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + affiliateListMode, + newCardStyle, + lineageChildren, + showStatus, + unreadTooltip, + stopQuickActionPointerPropagation, + handleToggleUnreadQuick, + statusLaneReview, + branchIdentityDisplay, + showInlineAgentList + } = card + const { titleOnlyCard, parentContentMarginLeft, showCombinedStatusSlot, showUnreadQuickAction } = + presentation + + return ( +
+ {showCombinedStatusSlot ? ( +
+ +
+ ) : null} + + {/* Content area */} +
+ {/* Header row: Title */} + + {presentation.hasMetaRow && } + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx new file mode 100644 index 00000000000..5ce1e94501c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -0,0 +1,288 @@ +import React from 'react' + +import { + getFlushWorktreeCardPaddingLeft, + getNewCardStyleParentContentMarginLeft +} from './worktree-list-indentation' +import { + hasWorktreeCardDetails, + WorktreeCardDetailsHover, + WorktreeCardMetaBadges +} from './WorktreeCardMeta' +import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function buildWorktreeCardPresentation(card: WorktreeCardController) { + const { + worktree, + repo, + inPinnedSection, + hideRepoBadge, + hostContextLabel, + affiliateListMode, + flushSurface, + contentIndent, + newCardStyle, + compactCards, + isFolder, + detachedHeadDisplay, + branch, + identityDisplay, + folderMetaRowContent, + showIdentityInNewCard, + conflictOperation, + cardProps, + cacheStartedAt, + hasDetails, + hasPorts, + showStatus, + showInlineAgentList, + showLineageChildChip, + remoteBranchConflict, + visibleCardTitle, + workspacePorts, + metaIssue, + metaLinearIssue, + metaJiraIssue, + metaReview, + metaComment, + metaAutomationProvenance, + metaCliProvenance, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + linearIssue, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview, + detailsHoverControl, + showDeleteQuickAction + } = card + + // Why: pinned trees mix repos, so the repo icon shows regardless of groupBy's hideRepoBadge. + const showPinnedRepoIcon = inPinnedSection && !!repo + // Why: new card style retired the Compact/Detailed switch; repo identity uses the compact chip, not a lower pill. + const showRepoIdentityInTitle = newCardStyle || compactCards + const showInlineRepoBadge = + showRepoIdentityInTitle && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon + const showRepoBadgeInMetaRow = + !showRepoIdentityInTitle && !!repo && !hideRepoBadge && !showPinnedRepoIcon + const showHostContextBadge = !compactCards && !!hostContextLabel + const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null + const showBranch = + !isFolder && + branch.length > 0 && + !newCardStyle && + (!compactCards || branch !== worktree.displayName) + // Why: rebases already surface in source control, so dense cards skip the persistent rebase chip. + const showConflictOperationBadge = + !!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase' + const hasMetadataBadge = showConflictOperationBadge + const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle + // Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive. + const showCombinedStatusSlot = showStatus + const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder + const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) + const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) + // Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content. + const hasDetailedMetaRowContent = Boolean( + (showRepoBadgeInMetaRow && repo) || + showHostContextBadge || + folderMetaRowContent || + showBranch || + showIdentityInNewCard || + showDetachedHeadInMetaRow || + showConflictOperationBadge || + cacheStartedAt != null || + showMetaRowDetails + ) + const hasMetaRow = compactCards + ? hasMetadataBadge || cacheStartedAt != null + : hasDetailedMetaRowContent + const showHeaderActions = showTitleRowPrimary || showDeleteQuickAction + // Why: normalize the title once so title/branch de-dupe and identity-only hover eligibility stay in sync. + const trimmedVisibleCardTitle = visibleCardTitle.trim() + const showBranchIdentityHover = newCardStyle + ? Boolean(identityDisplay) && + !cardProps.includes('branch') && + identityDisplay !== trimmedVisibleCardTitle + : compactCards && showBranch + const hoverBranchName = newCardStyle + ? identityDisplay + : showBranchIdentityHover + ? branch + : undefined + const hoverWorkspaceTitle = + trimmedVisibleCardTitle.length > 0 && trimmedVisibleCardTitle !== hoverBranchName + ? trimmedVisibleCardTitle + : undefined + const hasHoverIdentity = Boolean(hoverWorkspaceTitle || hoverBranchName) + const hasHoverDetails = + newCardStyle && + (hasWorktreeCardDetails({ + issue: hoverIssue, + linearIssue: hoverLinearIssue, + jiraIssue: hoverJiraIssue, + review: hoverReview, + comment: hoverComment, + automationProvenance: metaAutomationProvenance, + cliProvenance: metaCliProvenance + }) || + workspacePorts.length > 0 || + hasHoverIdentity) + // Why: the parent row owns metadata hover; don't stack the title's truncation tooltip on the details popover. + const titleWrapper = newCardStyle + ? hasHoverDetails + ? (title: React.ReactElement): React.ReactElement => title + : undefined + : compactCards && (showBranchIdentityHover || hasDetails || hasPorts) + ? (title: React.ReactElement): React.ReactElement => ( + : null} + openDelay={100} + // Why: compact mode also renders the plug/badge hover root; sharing one open-state made hovering the + // plug force-open the wider title card and race it closed (#9304), so let this title hover own its state. + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + metaIssue && 'url' in metaIssue && metaIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + metaReview?.url && metaReview.provider === 'github' + ? handleOpenReviewInOrca + : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: compact mode hides the metadata badge row, so title hover carries the explicit-link affordance. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {title} + + ) + : undefined + // Why: sidebar rows need a small surface inset while content stays aligned with the pre-inset layout. + const applyNewCardStyleStatusLaneOffset = newCardStyle && showCombinedStatusSlot + const cardPaddingLeft = flushSurface + ? getFlushWorktreeCardPaddingLeft(contentIndent, applyNewCardStyleStatusLaneOffset) + : contentIndent > 0 + ? `calc(0.125rem + ${contentIndent}px)` + : null + const parentContentMarginLeft = + flushSurface && applyNewCardStyleStatusLaneOffset + ? getNewCardStyleParentContentMarginLeft(contentIndent) + : 0 + const cardStyle = cardPaddingLeft ? { paddingLeft: cardPaddingLeft } : undefined + const detailsAndPortsContent = + hasDetails || hasPorts ? ( +
+ {hasPorts && } + {hasDetails && ( + + )} +
+ ) : null + const detailsAndPorts = + detailsAndPortsContent && !newCardStyle ? ( + : null} + hoverControl={detailsHoverControl} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + metaIssue && 'url' in metaIssue && metaIssue.url ? handleOpenGitHubIssueInOrca : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {detailsAndPortsContent} + + ) : ( + detailsAndPortsContent + ) + const titleRowIndicators = showTitleRowIndicators ? ( +
{detailsAndPorts}
+ ) : null + const hasSecondaryCardContent = + hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip + const titleOnlyCard = !hasSecondaryCardContent + + return { + showPinnedRepoIcon, + showInlineRepoBadge, + showRepoBadgeInMetaRow, + showHostContextBadge, + showIdentityInNewCard, + showDetachedHeadInMetaRow, + showBranch, + showConflictOperationBadge, + showUnreadQuickAction, + showCombinedStatusSlot, + showTitleRowPrimary, + showMetaRowDetails, + showTitleRowIndicators, + hasMetaRow, + showHeaderActions, + showDeleteQuickAction, + hoverBranchName, + hoverWorkspaceTitle, + hasHoverDetails, + titleWrapper, + parentContentMarginLeft, + cardStyle, + detailsAndPorts, + titleRowIndicators, + titleOnlyCard + } +} + +export type WorktreeCardPresentation = ReturnType diff --git a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx new file mode 100644 index 00000000000..c71d9d6b0cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { AlertTriangle, ChevronDown, Workflow } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' +import WorktreeCardAgents from './WorktreeCardAgents' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardSecondaryRows({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + settings, + isActive, + newCardStyle, + lineageChildren, + lineageCollapsed, + onLineageToggle, + remoteBranchConflict, + showInlineAgentList, + agentActivityDisplayMode, + compactInlineAgentRows, + showLineageChildChip, + lineageChildAriaLabel, + childWorkspaceShortLabel + } = card + const { hasMetaRow } = presentation + + return ( + <> + {remoteBranchConflict && ( +
+ + + {translate( + 'auto.components.sidebar.WorktreeCard.a88c92d0e3', + '{{value0}}/{{value1}} already exists.', + { + value0: remoteBranchConflict.remote, + value1: remoteBranchConflict.branchName + } + )} + +
+ )} + + {isActive && worktree.linkedLinearIssue ? ( + + ) : null} + + {/* Why: counterbalance the card stack gap (-mt-1) so agents right after the title read as one header group. */} + {showInlineAgentList && ( + + )} + + {showLineageChildChip && ( +
+ + + + + + {lineageCollapsed + ? translate( + 'auto.components.sidebar.WorktreeCard.8cb634cda6', + 'Show child workspaces' + ) + : translate( + 'auto.components.sidebar.WorktreeCard.57eaa61b55', + 'Hide child workspaces' + )} + + +
+ )} + + {!newCardStyle && lineageChildren && ( +
+ {lineageChildren} +
+ )} + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx new file mode 100644 index 00000000000..7bc0abdcb89 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -0,0 +1,208 @@ +import React from 'react' +import { LoaderCircle } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' +import WorktreeContextMenu from './WorktreeContextMenu' +import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +import { WorktreeCardPortsDetails } from './WorktreeCardPorts' +import { WorktreeCardParentContent } from './worktree-card-parent-content' +import { buildWorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): React.JSX.Element { + const presentation = buildWorktreeCardPresentation(card) + const { + worktree, + selectedWorktrees, + onAssignWorkspaceStatus, + affiliateListMode, + isActiveSurface, + activeSurfaceVariant, + isMultiSelected, + revealHighlight, + revealHighlightTone, + flushSurface, + isLineageDropTarget, + nativeDragEnabled, + lineageChildren, + lineageChildrenStyle, + newCardStyle, + titleRenaming, + isDeleting, + isRuntimeDisconnected, + isQueuedForDeletion, + deleteLabel, + handleClick, + handleDoubleClick, + handleDragStart, + handleDragEnd, + handleContextMenuSelect, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + metaAutomationProvenance, + metaCliProvenance, + workspacePorts, + detailsHoverControl, + handleRenameTitle, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + linearIssue, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview, + showRenameErrorDialog, + setShowRenameErrorDialog + } = card + const { titleOnlyCard, hasHoverDetails, hoverBranchName, hoverWorkspaceTitle, cardStyle } = + presentation + + const parentCardContent = + + const parentHoverTriggerBody = ( +
+ {parentCardContent} +
+ ) + + const parentCardBodyWithHoverDetails = + hasHoverDetails && !titleRenaming ? ( + 0 ? : null + } + openDelay={100} + hoverControl={detailsHoverControl} + onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + hoverIssue && 'url' in hoverIssue && hoverIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {parentHoverTriggerBody} + + ) : ( + parentHoverTriggerBody + ) + + const cardBody = ( +
+ {isDeleting && ( +
+
+ {!isQueuedForDeletion ? ( + + ) : null} + {deleteLabel} +
+
+ )} + {parentCardBodyWithHoverDetails} + + {newCardStyle && lineageChildren ? ( +
+ {lineageChildren} +
+ ) : null} +
+ ) + + return ( + <> + {affiliateListMode ? ( + cardBody + ) : ( + + {cardBody} + + )} + + {typeof worktree.firstAgentMessageRenameError === 'string' && + worktree.firstAgentMessageRenameError.length > 0 && ( + + )} + + ) +} From 2f26e61647b5a90ef72a5758547d7bcc6da7b6e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:04:50 -0700 Subject: [PATCH 19/69] fix(dashboard): keep agent map rings open while the pointer works inside them (#13682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): keep agent map rings open while the pointer works inside them The project and workspace rings drove their hover styling from `:hover` on the ring element itself. A ring is a sibling of everything drawn inside it, so the style dropped the moment the pointer crossed onto a workspace or an agent, and a pan drag killed it outright — pointer capture retargets `:hover` to the `` for the whole gesture. Both read as the ring pulsing open and shut. Move the trigger to the containing group, wrapped in `:where()` so the existing `.is-open` / `.is-selected` / status rules keep winning the same properties, and add `.is-held` for the pan, driven by which rings the press landed in. The hold releases on the first move after the button comes up, when the browser recomputes `:hover` in that same event, so the two signals never both read off. Hovering a workspace now also always shows its name and agent count. That label was gated behind the declutter pass, which suppresses any label overlapping an agent — at fleet zoom that is nearly all of them, so hover revealed nothing. It now bypasses declutter and draws in a layer above every project, so a neighbouring ring cannot wash it out. * fix(dashboard): complete agent map ring interaction lifecycle --- .../dashboard-popout/AgentMap.test.tsx | 162 ++---------------- .../dashboard-popout/AgentMapCanvas.tsx | 26 ++- .../AgentMapProjectLabel.test.tsx | 2 + .../AgentMapRingHover.test.tsx | 124 ++++++++++++++ .../dashboard-popout/AgentMapScene.tsx | 76 ++++++-- .../AgentMapStatusGlow.test.tsx | 26 +++ .../AgentMapWorktreeLabel.tsx | 3 +- .../AgentMapWorktreeRingNode.tsx | 20 ++- .../agent-map-hover-containment.test.ts | 49 ++++++ .../agent-map-label-declutter.test.ts | 13 -- .../agent-map-label-declutter.ts | 6 +- .../agent-map-render-test-harness.tsx | 133 ++++++++++++++ .../components/dashboard-popout/agent-map.css | 34 +++- .../useAgentMapPointerHold.ts | 47 +++++ 14 files changed, 517 insertions(+), 204 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index a100ecaabef..699a698f008 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -1,137 +1,16 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - DashboardCard, - DashboardSleepWorkspaceArgs, - DashboardSpawnAgentArgs -} from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' import { AgentMap } from './AgentMap' import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' import type { AgentMapState } from './agent-map-filter' import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' - -const NOW = 2_000_000_000 - -function card(overrides: Partial = {}): DashboardCard { - return { - paneKey: 'pane-1', - ptyId: 'pty-1', - agentType: 'codex', - bucket: 'working', - dotState: 'working', - task: 'Build map', - repoId: 'repo-1', - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - repoName: 'Orca', - worktreeName: 'Agent map', - conversationName: 'Agent alpha', - startedAt: NOW - 10 * 60_000, - finishedAt: null, - stateChangedAt: NOW - 1_000, - unseen: false, - hostKind: 'local', - workspaceKind: 'worktree', - ...overrides - } -} - -function renderMap( - cards: DashboardCard[], - { - onOpenTerminal = vi.fn(), - selectedPaneKey = null, - compact = false, - workspaceContextMenusEnabled = false, - enabledStates, - showOrchestrationLinks, - launchableAgentsByWorktreeId, - onSpawnAgent, - onSleepWorkspace - }: { - onOpenTerminal?: (card: DashboardCard) => void - selectedPaneKey?: string | null - compact?: boolean - workspaceContextMenusEnabled?: boolean - enabledStates?: ReadonlySet - showOrchestrationLinks?: boolean - launchableAgentsByWorktreeId?: Record - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void - } = {} -): ReturnType { - return render( - - ) -} +import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness' describe('AgentMap', () => { - const originalUserAgent = navigator.userAgent - let boundsSpy: ReturnType - - beforeEach(() => { - Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) - vi.stubGlobal( - 'matchMedia', - vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) - ) - boundsSpy = vi - .spyOn(Element.prototype, 'getBoundingClientRect') - .mockImplementation(function getBounds(this: Element) { - if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 400, - bottom: 300, - width: 400, - height: 300, - toJSON: () => ({}) - } - } - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - toJSON: () => ({}) - } - }) - }) - - afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.unstubAllGlobals() - boundsSpy.mockRestore() - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: originalUserAgent - }) - }) + const environment = installAgentMapEnvironment() it('renders the amber marker only for unread agents', () => { const finished = card({ @@ -164,23 +43,6 @@ describe('AgentMap', () => { expect(doneNode).toHaveAccessibleName(/unread/) }) - it.each([ - { bucket: 'working', dotState: 'working', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, - { bucket: 'done', dotState: 'done', unseen: true, glows: false }, - { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } - ] as const)('applies the expected halo for $dotState agents', (state) => { - const { container } = renderMap([card(state)]) - const glow = container.querySelector('[data-agent-map-agent-status-glow]') - - if (state.glows) { - expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) - return - } - expect(glow).not.toBeInTheDocument() - }) - it('lets attention override working on the worktree glow', () => { const done = card({ paneKey: 'done', @@ -631,13 +493,13 @@ describe('AgentMap', () => { labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - const readsBeforeZoom = boundsSpy.mock.calls.length + const readsBeforeZoom = environment.boundsSpy.mock.calls.length fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) const zoomedScale = Number( labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) expect(zoomedScale).toBeGreaterThan(initialScale) }) @@ -656,18 +518,18 @@ describe('AgentMap', () => { releasePointerCapture: vi.fn() }) - const readsBeforeHover = boundsSpy.mock.calls.length + const readsBeforeHover = environment.boundsSpy.mock.calls.length fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) expect(svg.setPointerCapture).toHaveBeenCalledWith(1) - const readsAfterPointerDown = boundsSpy.mock.calls.length + const readsAfterPointerDown = environment.boundsSpy.mock.calls.length const initialViewBox = svg.getAttribute('viewBox') fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) expect(requestFrame).toHaveBeenCalledOnce() expect(svg).toHaveAttribute('viewBox', initialViewBox) @@ -675,7 +537,7 @@ describe('AgentMap', () => { expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) requestFrame.mockClear() - const readsBeforeWheel = boundsSpy.mock.calls.length + const readsBeforeWheel = environment.boundsSpy.mock.calls.length const wheel = (): void => { const event = new Event('wheel', { bubbles: true, cancelable: true }) Object.defineProperties(event, { @@ -688,7 +550,7 @@ describe('AgentMap', () => { } wheel() wheel() - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) expect(requestFrame).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index b3203ebf701..3b71f0f2006 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -28,6 +28,7 @@ import { import type { AgentMapViewport } from './agent-map-viewport-transition' import { useAgentMapContextMenus } from './useAgentMapContextMenus' import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapPointerHold } from './useAgentMapPointerHold' import { useAgentMapMotionLayout } from './useAgentMapMotionLayout' import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' @@ -86,6 +87,7 @@ export const AgentMapCanvas = forwardRef(null) const interactionBoundsRef = useRef(null) const hasShownProjectsRef = useRef(layout.projects.length > 0) + const { held, hold, release: releaseHold, clearDrag } = useAgentMapPointerHold(dragRef) const clearInteractionBounds = useCallback(() => { interactionBoundsRef.current = null }, []) @@ -315,7 +317,7 @@ export const AgentMapCanvas = forwardRef { - if (event.button !== 0) { + if (event.button !== 0 || dragRef.current) { return } if ( @@ -337,11 +339,16 @@ export const AgentMapCanvas = forwardRef { const drag = dragRef.current - if (!drag || drag.pointerId !== event.pointerId) { + if (!drag) { + releaseHold() + return + } + if (drag.pointerId !== event.pointerId) { return } scheduleViewport({ @@ -353,14 +360,19 @@ export const AgentMapCanvas = forwardRef { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + if (clearDrag(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } }} onPointerCancel={(event) => { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + clearDrag(event.pointerId) + }} + onLostPointerCapture={(event) => { + clearDrag(event.pointerId) + }} + onPointerLeave={() => { + if (!dragRef.current) { + releaseHold() } }} > @@ -370,6 +382,8 @@ export const AgentMapCanvas = forwardRef { zoom={1} labelScale={1} mapScale={0.5} + heldProjectId={null} + heldWorktreeId={null} selectedPaneKey={null} allowAggregation showOrchestrationLinks diff --git a/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx new file mode 100644 index 00000000000..ccc3b29e89f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { fireEvent } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +/** A ring has to stay open for as long as the pointer is working inside it — + * across its own contents, and across a pan drag that takes pointer capture. */ +describe('AgentMap ring hover', () => { + installAgentMapEnvironment() + + it('reveals the hovered workspace name and agent count above every other label', () => { + const { container } = renderMap([ + card(), + card({ paneKey: 'pane-2', conversationName: 'Agent beta' }) + ]) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + + fireEvent.pointerOver(container.querySelector('.agent-map-worktree-group')!) + + const hovered = container.querySelector('[data-agent-map-hover-label]')! + expect(hovered.querySelector('.agent-map-worktree-label')).toHaveTextContent('Agent map') + expect(hovered.querySelector('.agent-map-worktree-count')).toHaveTextContent('2 agents') + expect(hovered.querySelector('.agent-map-worktree-label-group')).toHaveClass( + 'is-active', + 'is-count-visible' + ) + // The hovered name is hoisted, not duplicated, and draws after every ring. + const labels = container.querySelectorAll('.agent-map-worktree-label-group') + expect(labels).toHaveLength(1) + const lastRing = [...container.querySelectorAll('[data-agent-map-worktree]')].at(-1)! + expect(lastRing.compareDocumentPosition(labels[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + }) + + it('hides the hovered workspace label again when the pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + + fireEvent.pointerOver(group) + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + + fireEvent.pointerOut(group) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + }) + + it('keeps the pressed rings lit for the whole pan drag', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + const worktreeGroup = container.querySelector('.agent-map-worktree-group')! + // Pointer capture retargets :hover to the mid-gesture, so CSS alone + // cannot hold the ring open — the class has to survive the drag. + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerUp(svg, { pointerId: 1 }) + expect(projectRing).not.toHaveClass('is-held') + expect(worktreeGroup).not.toHaveClass('is-held') + }) + + it('holds the workspace name up while panning from inside that workspace', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const worktreeGroup = container.querySelector('[data-agent-map-worktree-id]')! + // The aggregate bubble sits inside the group but is not the ring, so a press + // there pans rather than opening the workspace popover. + fireEvent.pointerDown(worktreeGroup, { button: 0, pointerId: 1 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + + expect(worktreeGroup).toHaveClass('is-held') + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('drops the held rings when the pan is cancelled', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.pointerCancel(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('drops the held rings and drag when pointer capture is lost', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.lostPointerCapture(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('keeps a focused workspace label visible after its pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + ring.focus() + fireEvent.pointerOver(group) + fireEvent.pointerOut(group) + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('keeps a hovered workspace label visible after its focus leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + fireEvent.pointerOver(group) + ring.focus() + ring.blur() + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index fdd80ff982e..2181e4c0f2c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -22,6 +22,9 @@ type AgentMapSceneProps = { zoom: number labelScale: number mapScale: number + /** Rings the pointer was pressed in; they stay lit for the whole pan drag. */ + heldProjectId: string | null + heldWorktreeId: string | null selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -63,6 +66,8 @@ export const AgentMapScene = memo(function AgentMapScene({ zoom, labelScale, mapScale, + heldProjectId, + heldWorktreeId, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -74,9 +79,16 @@ export const AgentMapScene = memo(function AgentMapScene({ onOpenWorkspaceContextMenu, onAgentKeyDown }: AgentMapSceneProps): React.JSX.Element { - const [activeWorktreeId, setActiveWorktreeId] = useState(null) - const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { - setActiveWorktreeId((current) => + const [hoveredWorktreeId, setHoveredWorktreeId] = useState(null) + const [focusedWorktreeId, setFocusedWorktreeId] = useState(null) + const activeWorktreeId = heldWorktreeId ?? hoveredWorktreeId ?? focusedWorktreeId + const handleLabelHoverChange = useCallback((worktreeId: string, active: boolean): void => { + setHoveredWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const handleLabelFocusChange = useCallback((worktreeId: string, active: boolean): void => { + setFocusedWorktreeId((current) => active ? worktreeId : current === worktreeId ? null : current ) }, []) @@ -84,6 +96,19 @@ export const AgentMapScene = memo(function AgentMapScene({ () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), [labelScale, layout, mapScale] ) + const activeWorktree = useMemo(() => { + if (!activeWorktreeId) { + return null + } + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (worktree.id === activeWorktreeId) { + return worktree + } + } + } + return null + }, [activeWorktreeId, layout]) const visibleAgentsByPaneKey = useMemo(() => { const agents = new Map() for (const project of layout.projects) { @@ -125,7 +150,8 @@ export const AgentMapScene = memo(function AgentMapScene({ return ( ))} - {project.worktrees.map((worktree) => ( - - ))} + {project.worktrees.map((worktree) => + worktree.id === activeWorktreeId ? null : ( + + ) + )} ) })} + {/* Drawn last so a hovered name clears every other project's rings, and + * outside the declutter pass so it reads at any zoom. */} + {activeWorktree ? ( + + + + ) : null} ) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx new file mode 100644 index 00000000000..8803b3336a5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +describe('AgentMap status glow', () => { + installAgentMapEnvironment() + + it.each([ + { bucket: 'working', dotState: 'working', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, + { bucket: 'done', dotState: 'done', unseen: true, glows: false }, + { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } + ] as const)('applies the expected halo for $dotState agents', (state) => { + const { container } = renderMap([card(state)]) + const glow = container.querySelector('[data-agent-map-agent-status-glow]') + + if (state.glows) { + expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) + return + } + expect(glow).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx index 455b20df5bd..27a1c455341 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -17,7 +17,8 @@ export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ labelScale, mapScale }: AgentMapWorktreeLabelProps): React.JSX.Element { - const showCount = visible && (active || worktree.radius * mapScale >= 80) + // Hover is an explicit ask for this workspace's detail, so it outranks declutter. + const showCount = active || (visible && worktree.radius * mapScale >= 80) const agentCountText = translate( 'dashboardPopout.map.agentCount', worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 251fabbb079..ee49a574901 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -22,6 +22,8 @@ type AgentMapWorktreeRingNodeProps = { worktree: AgentMapWorktreeRing zoom: number mapScale: number + /** Pressed at the start of a pan drag; keeps the ring lit through the gesture. */ + held: boolean selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -33,7 +35,8 @@ type AgentMapWorktreeRingNodeProps = { event: React.MouseEvent, worktree: AgentMapWorktreeRing ) => void - onLabelActiveChange: (worktreeId: string, active: boolean) => void + onLabelHoverChange: (worktreeId: string, active: boolean) => void + onLabelFocusChange: (worktreeId: string, active: boolean) => void onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void } @@ -175,6 +178,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ worktree, zoom, mapScale, + held, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -183,7 +187,8 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ onSelectAgent, onSpawnAgent, onOpenWorkspaceContextMenu, - onLabelActiveChange, + onLabelHoverChange, + onLabelFocusChange, onAgentKeyDown }: AgentMapWorktreeRingNodeProps): React.JSX.Element { const [detailsOpen, setDetailsOpen] = useState(false) @@ -196,14 +201,15 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ return ( onLabelActiveChange(worktree.id, true)} - onPointerLeave={() => onLabelActiveChange(worktree.id, false)} - onFocus={() => onLabelActiveChange(worktree.id, true)} + onPointerEnter={() => onLabelHoverChange(worktree.id, true)} + onPointerLeave={() => onLabelHoverChange(worktree.id, false)} + onFocus={() => onLabelFocusChange(worktree.id, true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - onLabelActiveChange(worktree.id, false) + onLabelFocusChange(worktree.id, false) } }} > diff --git a/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts new file mode 100644 index 00000000000..ea5bbab362e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/agent-map.css'), + 'utf8' +) + +const PROJECT_SCALE = + /:where\(\s*\.agent-map-project-node:hover,\s*\.agent-map-project-node:focus-within,\s*\.agent-map-project-node\.is-held\s*\)\s*\.agent-map-project-ring\s*\{([^}]*)\}/ +const WORKTREE_SCALE = + /:where\(\s*\.agent-map-worktree-group:hover,\s*\.agent-map-worktree-group:focus-within,\s*\.agent-map-worktree-group\.is-held\s*\)\s*\.agent-map-worktree-ring\s*\{([^}]*)\}/ + +/** A ring that only reacts to :hover on itself pulses shut whenever the pointer + * crosses onto something drawn inside it, and again when a pan drag takes + * pointer capture. Both triggers have to live on the containing group. */ +describe('Agent Map hover containment', () => { + it('scales each ring from its containing group, never from the ring element', () => { + expect(css).not.toMatch(/\.agent-map-(?:project|worktree)-ring:hover/) + expect(css.match(PROJECT_SCALE)?.[1]).toContain('transform: scale') + expect(css.match(WORKTREE_SCALE)?.[1]).toContain('transform: scale') + }) + + it('keeps the group-scoped hover at ring specificity so state rules still win', () => { + // `:where()` contributes no specificity, so the workspace state rules keep + // overriding hover fill and stroke — but only while they stay below it. + const hoverAt = css.search(WORKTREE_SCALE) + + expect(hoverAt).toBeGreaterThan(css.indexOf('.agent-map-worktree-ring {')) + for (const state of ['.is-open', '.is-selected', '.is-working', '.is-blocked']) { + expect(css.indexOf(`.agent-map-worktree-ring${state}`)).toBeGreaterThan(hoverAt) + } + }) + + it('expands containing rings for keyboard focus as well as pointer hover', () => { + expect(css.match(PROJECT_SCALE)?.[0]).toContain('.agent-map-project-node:focus-within') + expect(css.match(WORKTREE_SCALE)?.[0]).toContain('.agent-map-worktree-group:focus-within') + }) + + it('drops both hover triggers under reduced motion', () => { + const reducedMotion = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)')) + + expect(reducedMotion).toContain('.agent-map-project-node.is-held') + expect(reducedMotion).toContain('.agent-map-worktree-group.is-held') + expect(reducedMotion).toMatch(/\.agent-map-project-node:hover/) + expect(reducedMotion).toMatch(/\.agent-map-worktree-group:hover/) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts index 28c71c881a1..ad02fb56243 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -90,19 +90,6 @@ describe('selectVisibleAgentMapLabels', () => { const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) expect(labels.worktreeIds.size).toBe(0) - expect(labels.worktreeAgentSafeIds.size).toBe(0) - }) - - it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { - const layout = layoutOf([ - worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), - worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) - ]) - - const labels = selectVisibleAgentMapLabels(layout, 1, 1) - - expect([...labels.worktreeIds]).toEqual(['busy']) - expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) }) it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 58a1f53c4d6..841197437e6 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -38,8 +38,6 @@ type LabelGrid = Map> export type AgentMapVisibleLabels = { /** Worktree ring ids whose name can render without colliding. */ worktreeIds: Set - /** Worktree names that hover/focus may reveal without covering an agent. */ - worktreeAgentSafeIds: Set /** Project ids whose agent/workspace count line still has room. The count is * the first thing dropped: it repeats what the filter rail already says. */ projectCountIds: Set @@ -221,7 +219,6 @@ export function selectVisibleAgentMapLabels( }) const worktreeIds = new Set() - const worktreeAgentSafeIds = new Set() for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { const box = baselineBox( worktree.x, @@ -234,7 +231,6 @@ export function selectVisibleAgentMapLabels( if (collides(agentGrid, box)) { continue } - worktreeAgentSafeIds.add(worktree.id) if (collides(grid, box)) { continue } @@ -261,5 +257,5 @@ export function selectVisibleAgentMapLabels( projectCountIds.add(project.id) } - return { worktreeIds, worktreeAgentSafeIds, projectCountIds } + return { worktreeIds, projectCountIds } } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx new file mode 100644 index 00000000000..45c83a33c8e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -0,0 +1,133 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import type { AgentMapState } from './agent-map-filter' + +export const NOW = 2_000_000_000 + +export function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +export type RenderMapOptions = { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + showOrchestrationLinks?: boolean + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + showOrchestrationLinks, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: RenderMapOptions = {} +): ReturnType { + return render( + + ) +} + +export type AgentMapTestEnvironment = { + /** Exposed so tests can assert the canvas does not re-read layout when idle. */ + boundsSpy: ReturnType +} + +const CANVAS_BOUNDS = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) +} +const ZERO_BOUNDS = { ...CANVAS_BOUNDS, right: 0, bottom: 0, width: 0, height: 0 } + +/** Gives the map a measurable canvas and a non-Mac platform, the way every map + * suite needs it. Call once per describe block. */ +export function installAgentMapEnvironment(): AgentMapTestEnvironment { + const environment = {} as AgentMapTestEnvironment + const originalUserAgent = navigator.userAgent + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + environment.boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + return this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement + ? CANVAS_BOUNDS + : ZERO_BOUNDS + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + environment.boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + return environment +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 6107332ba0d..fb1f392f707 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -31,7 +31,13 @@ vector-effect: non-scaling-stroke; } -.agent-map-project-ring:hover { +/* Group hover includes nested contents; held state bridges pointer capture. */ +:where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring { fill: color-mix(in srgb, var(--card) 42%, transparent); stroke: var(--ring); transform: scale(1.018); @@ -154,7 +160,12 @@ stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); } -.agent-map-worktree-ring:hover { +:where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring { fill: color-mix(in srgb, var(--card) 72%, transparent); stroke: var(--ring); transform: scale(1.035); @@ -203,6 +214,11 @@ text-anchor: middle; } +.agent-map-worktree-label-layer, +.agent-map-worktree-hover-label-layer { + pointer-events: none; +} + .agent-map-worktree-label-group { opacity: 0; pointer-events: none; @@ -480,8 +496,18 @@ transition: none; } - .agent-map-project-ring:hover, - .agent-map-worktree-ring:hover, + :where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring, + :where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring, .agent-map-agent-node:hover .agent-map-agent-visual, .agent-map-agent-node:focus-visible .agent-map-agent-visual { transform: none; diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts new file mode 100644 index 00000000000..924c18d6586 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts @@ -0,0 +1,47 @@ +import { useCallback, useState, type RefObject } from 'react' + +export type AgentMapPointerHold = { + projectId: string | null + worktreeId: string | null +} + +type AgentMapPointerDragRef = RefObject<{ pointerId: number } | null> + +function closestId(target: Element, attribute: string): string | null { + return target.closest(`[${attribute}]`)?.getAttribute(attribute) ?? null +} + +/** + * Remembers which rings a pan drag started in. Pointer capture retargets + * `:hover` to the `` for the whole gesture, so the ring under the pointer + * would otherwise collapse until the gesture ends. + */ +export function useAgentMapPointerHold(dragRef: AgentMapPointerDragRef): { + held: AgentMapPointerHold | null + hold: (target: Element) => void + release: () => void + clearDrag: (pointerId: number) => boolean +} { + const [held, setHeld] = useState(null) + const hold = useCallback((target: Element): void => { + const projectId = closestId(target, 'data-agent-map-project-id') + const worktreeId = closestId(target, 'data-agent-map-worktree-id') + // A pan off empty canvas holds nothing, so leave the memoized scene alone. + setHeld(projectId === null && worktreeId === null ? null : { projectId, worktreeId }) + }, []) + const release = useCallback((): void => { + setHeld((current) => (current === null ? current : null)) + }, []) + const clearDrag = useCallback( + (pointerId: number): boolean => { + if (dragRef.current?.pointerId !== pointerId) { + return false + } + dragRef.current = null + release() + return true + }, + [dragRef, release] + ) + return { held, hold, release, clearDrag } +} From c065a2d7e107f619b3459ffcbf8d5ed5837b6d6b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:23:40 -0700 Subject: [PATCH 20/69] Preserve AskUserQuestion waits during parallel tool completions (#13714) * fix(agent-hooks): preserve parallel question waits * fix(agent-hooks): preserve child question waits --- src/shared/agent-hook-listener.test.ts | 106 +++++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 47 ++++++++--- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a03435d0e1f..298e7a55c79 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -391,6 +391,90 @@ describe('shared agent-hook-listener', () => { expect(next?.payload.interactivePrompt).toBeUndefined() }) + it('keeps AskUserQuestion visible through a late parallel sibling completion', () => { + const questions = { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + const question = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: questions + } + }, + 'production' + ) + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PermissionRequest', + tool_name: 'AskUserQuestion', + tool_input: questions + } + }, + 'production' + ) + const siblingCompletion = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_use_id: 'tool-sibling', + tool_input: { command: 'sleep 5' } + } + }, + 'production' + ) + + expect(siblingCompletion?.payload).toMatchObject({ + state: 'waiting', + toolName: 'AskUserQuestion', + interactivePrompt: question?.payload.interactivePrompt + }) + }) + + it('clears AskUserQuestion when its own PostToolUse arrives', () => { + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question' + } + }, + 'production' + ) + + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + it('does not re-assert the AskUserQuestion prompt on PostToolUse', () => { // The question was answered, so PostToolUse must clear the live card instead // of re-deriving the `{questions}` prompt from the carried tool input. @@ -3594,6 +3678,28 @@ describe('shared agent-hook-listener', () => { expect(childTool?.payload.toolName).toBe('AskUserQuestion') }) + it('keeps a child AskUserQuestion visible through its parallel sibling completion', () => { + const question = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'a1', + tool_use_id: 'question-1', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + }) + + const siblingCompletion = claudeEvent({ + hook_event_name: 'PostToolUse', + agent_id: 'a1', + tool_use_id: 'sibling-1', + tool_name: 'Bash', + tool_input: { command: 'sleep 5' } + }) + + expect(siblingCompletion?.payload.state).toBe('waiting') + expect(siblingCompletion?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt) + expect(siblingCompletion?.payload.toolName).toBe('AskUserQuestion') + }) + it('preserves the interrupted flag across a gated working window', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long job' }) claudeEvent({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index c41bcb7354d..44032c03b24 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -150,6 +150,8 @@ export type ClaudeLeadTurnState = { interrupted?: true /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick } @@ -2620,7 +2622,7 @@ function normalizeClaudeSubagentLifecycleEvent( clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ @@ -2712,8 +2714,8 @@ export function clearClaudeAnsweredQuestionWait( return effectiveState === restored.state ? restored : { state: effectiveState } } -/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */ -function buildClaudeChildDrivenStatusPayload( +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +function buildClaudeCachedLeadStatusPayload( state: HookListenerState, eventName: unknown, paneKey: string, @@ -2794,10 +2796,13 @@ function normalizeClaudeEvent( return null } - // Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer. - // Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const isAskUserQuestion = - eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy; // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352). const reportedStateName = @@ -2836,6 +2841,20 @@ function normalizeClaudeEvent( state.claudeActiveSessionCronPaneKeys.delete(paneKey) } + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = reportedStateName === 'waiting' @@ -2857,7 +2876,15 @@ function normalizeClaudeEvent( if (subagentOriginId) { const lead = state.claudeLeadStateByPaneKey.get(paneKey) if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. @@ -2872,7 +2899,7 @@ function normalizeClaudeEvent( // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. if (eventAgentId && !isWaitingInducing) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } if (isTurnBoundary && eventAgentId === undefined) { @@ -2897,10 +2924,12 @@ function normalizeClaudeEvent( ...(previousLead.interrupted ? { interrupted: true as const } : {}) } : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId state.claudeLeadStateByPaneKey.set(paneKey, { state: reportedStateName, ...(interrupted ? { interrupted } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}) }) From c7850eb59c5ad90f85faee8036589efa00dfbcb2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:49:34 -0700 Subject: [PATCH 21/69] fix(terminals): prevent retired workers from cold-resuming (#13708) --- config/reliability-gates.jsonc | 30 +- .../terminal-pane/pty-connection.test.ts | 10 +- .../terminal/terminal-tab-actions.test.ts | 51 ++ .../terminal/terminal-tab-actions.ts | 13 + ...completed-worker-retirement-resume.spec.ts | 521 +++++++++++++++ ...eted-worker-retirement-resume.unit.test.ts | 610 ++++++++++++++++++ .../completed-worker-retirement-fixture.ts | 194 ++++++ 7 files changed, 1424 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/completed-worker-retirement-resume.spec.ts create mode 100644 tests/e2e/completed-worker-retirement-resume.unit.test.ts create mode 100644 tests/e2e/helpers/completed-worker-retirement-fixture.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 16e479b2666..f5e5a39caeb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1969,7 +1969,7 @@ "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" @@ -1989,11 +1989,18 @@ "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery" + ] + }, { "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", "assertions": [ @@ -2711,11 +2718,13 @@ "oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -2727,6 +2736,13 @@ "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "completed and explicitly retired workers retain no provider-resume authority on first workspace activation", + "the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart" + ] + }, { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "assertions": [ @@ -7867,16 +7883,24 @@ "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose" ], "testFiles": [ "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 9391b6d614a..2ff73043cc3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11599,11 +11599,12 @@ describe('connectPanePty', () => { expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => { + it('forwards one sidebar resume spawn without writing the restored banner through xterm', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-1') transportFactoryQueue.push(transport) const pane = createPane(1) + const providerSession = { key: 'session_id', id: 'codex-session-1' } as const connectPanePty( pane as never, @@ -11611,6 +11612,7 @@ describe('connectPanePty', () => { createDeps({ startup: { command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession, showSessionRestoredBanner: true } }) as never @@ -11626,7 +11628,11 @@ describe('connectPanePty', () => { expect.stringContaining('--- session restored ---'), expect.any(Function) ) - expect(createdTransportOptions[0]?.command).toBe("codex 'resume' 'codex-session-1'") + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(createdTransportOptions[0]).toMatchObject({ + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession + }) }) it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index a8747ec81ff..4b7557ba057 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -183,6 +183,57 @@ describe('closeTerminalTab', () => { isWebTerminalSurfaceTabIdMock.mockReturnValue(false) }) + it('retires exact resume authority when explicit cleanup arrives after the tab disappeared', () => { + const closeTab = vi.fn() + const onClosed = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab', { + hostCloseReason: 'cleanup', + localPtyTeardownOwnedExternally: true, + onClosed + }) + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { + reason: 'cleanup', + localPtyTeardownOwnedExternally: true + }) + expect(onClosed).toHaveBeenCalledOnce() + }) + + it('retires exact resume authority when a user close arrives after the tab disappeared', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab') + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { reason: 'user' }) + }) + + it.each([ + ['local PTY exit', { reason: 'pty-exit' as const }], + ['paired host PTY exit', { hostCloseReason: 'pty-exit' as const }] + ])('preserves missing-tab recovery authority for %s', (_label, options) => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('unexpectedly-lost-tab', options) + + expect(closeTab).not.toHaveBeenCalled() + }) + it('delegates host-backed terminal closes to the paired runtime', () => { const closeTab = vi.fn() isWebRuntimeSessionActiveMock.mockReturnValue(true) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 55934e7ae88..8216696d217 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -65,6 +65,19 @@ export function closeTerminalTab( ) const target = resolveTerminalCloseTarget(state, tabId, precomputedCloseState) if (!target) { + const closeReason = options?.reason ?? options?.hostCloseReason ?? 'user' + if (closeReason !== 'pty-exit') { + // Why: late explicit cleanup must still revoke tab-scoped resume authority after PTY exit removed the row. + state.closeTab(tabId, { + reason: closeReason, + ...(options?.localPtyTeardownOwnedExternally + ? { localPtyTeardownOwnedExternally: true } + : {}), + ...(options?.precomputedRetirementPlan + ? { precomputedRetirementPlan: options.precomputedRetirementPlan } + : {}) + }) + } options?.onClosed?.() return } diff --git a/tests/e2e/completed-worker-retirement-resume.spec.ts b/tests/e2e/completed-worker-retirement-resume.spec.ts new file mode 100644 index 00000000000..5540c4d6896 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.spec.ts @@ -0,0 +1,521 @@ +import { test as base, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + cleanupCompletedWorkerFixture, + clearCompletedWorkerLedger, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + readPersistedWorkerRecoveryRecord, + runBuiltOrcaCli, + seedCurrentCodexTranscript, + terminalIdentity +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree-id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' + +const test = base.extend({ + launchEnv: [completedWorkerLaunchEnv, { option: true }] +}) + +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + cleanupCompletedWorkerFixture() +}) + +for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) { + test(`completed background worker ${closeMode} retires resume authority before first activation`, async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + clearCompletedWorkerLedger() + await waitForSessionReady(orcaPage) + const coordinatorWorktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }) + + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const isolatedHome = await electronApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) + const coordinatorResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: coordinatorPane.paneKey } + ) + const coordinatorHandle = coordinatorResolved.result.terminal.handle + const coordinator = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + if (!coordinator) { + throw new Error('Coordinator terminal was not runtime-visible') + } + const coordinatorBefore = terminalIdentity(coordinator) + + let targetWorktreeId: string | null = null + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + const rendererWorktreeIds = await orcaPage.evaluate(() => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .map((worktree) => worktree.id) + ) + targetWorktreeId = + listed.result.worktrees.find( + (worktree) => + worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id) + )?.id ?? null + return targetWorktreeId + }, + { timeout: 60_000, message: 'runtime never registered the secondary worktree' } + ) + .not.toBeNull() + if (!targetWorktreeId) { + throw new Error('The seeded repository did not expose its secondary worktree') + } + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Retire one completed background worker', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion, then exit normally', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${String(targetWorktreeId)}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId || !worker.incarnationId) { + throw new Error('Background worker did not publish exact PTY identity') + } + const workerBefore = terminalIdentity(worker) + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + expect(worker.worktreeId).toBe(targetWorktreeId) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + window.dispatchEvent( + new CustomEvent('orca-background-mount-terminal-worktree', { + detail: { worktreeId, tabIds: [tabId] } + }) + ) + }, + { tabId: worker.tabId, worktreeId: targetWorktreeId } + ) + await expect + .poll(() => + orcaPage.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), workerBefore.tabId) + ) + .toBe(true) + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'spawn')) + .toHaveLength(1) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + + await orcaPage.evaluate( + ({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const providerSession = { + key: 'session_id' as const, + id: providerSessionId, + transcriptPath + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession, + launchConfig: { + agentCommand: 'codex', + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + state.setAgentStatus( + paneKey, + { state: 'done', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + }, + { + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: worker.tabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + + const expectedRecovery = { + origin: 'live', + state: 'working', + providerSessionId: PROVIDER_SESSION_ID + } + await expect + .poll(() => + orcaPage.evaluate((paneKey) => { + const record = window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession.id + } + : null + }, workerPaneKey) + ) + .toEqual(expectedRecovery) + await expect + .poll( + () => { + const record = readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey) + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession?.id + } + : null + }, + { timeout: 30_000 } + ) + .toEqual(expectedRecovery) + + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed. It found no work. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + await expect + .poll( + async () => { + const dispatch = await client.call<{ dispatch: { status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + const tasks = await client.call<{ tasks: { id: string; status: string }[] }>( + 'orchestration.taskList', + { run: run.result.run.id } + ) + return { + dispatch: dispatch.result.dispatch?.status ?? null, + task: + tasks.result.tasks.find((candidate) => candidate.id === task.result.task.id) + ?.status ?? null + } + }, + { timeout: 30_000, message: 'worker completion never settled its task and dispatch' } + ) + .toEqual({ dispatch: 'completed', task: 'completed' }) + + await client.call('terminal.send', { + terminal: workerHandle, + text: 'ORCA_E2E_EXIT_AFTER_DONE', + enter: true + }) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')) + .toHaveLength(1) + expect( + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const state = window.__store?.getState() + return { + tabPresent: Boolean(state?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state?.sleepingAgentSessionsByPaneKey[paneKey]) + } + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + ).toEqual({ tabPresent: true, recoveryPresent: true }) + + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + const e2eWindow = window as typeof window & { + __orcaRetiredWorkerTransitions?: Transition[] + __orcaRetiredWorkerUnsubscribe?: () => void + } + const transitions: Transition[] = [ + { + tabPresent: Boolean( + store.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + recoveryPresent: Boolean(store.getState().sleepingAgentSessionsByPaneKey[paneKey]) + } + ] + e2eWindow.__orcaRetiredWorkerTransitions = transitions + e2eWindow.__orcaRetiredWorkerUnsubscribe = store.subscribe((state) => { + const next = { + tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state.sleepingAgentSessionsByPaneKey[paneKey]) + } + const previous = transitions.at(-1) + if ( + !previous || + previous.tabPresent !== next.tabPresent || + previous.recoveryPresent !== next.recoveryPresent + ) { + transitions.push(next) + } + }) + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + + if (closeMode === 'terminal-close-cli') { + const closed = runBuiltOrcaCli(['terminal', 'close', '--terminal', workerHandle, '--json'], { + userDataDir, + cwd: process.cwd() + }) + expect(closed).toMatchObject({ + ok: true, + result: { + close: { + handle: workerHandle, + tabId: workerBefore.tabId, + ptyKilled: true + } + } + }) + } else { + const release = await client.call<{ + dispatchId: string + state: string + processAction: string + }>('orchestration.workerRelease', { dispatch: started.result.dispatchId }) + expect(release.result).toMatchObject({ + dispatchId: started.result.dispatchId, + state: 'released', + processAction: 'closed_agent_terminal' + }) + } + await expect + .poll(() => + orcaPage.evaluate(() => { + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + return (window as typeof window & { __orcaRetiredWorkerTransitions?: Transition[] }) + .__orcaRetiredWorkerTransitions + }) + ) + .toEqual( + expect.arrayContaining([ + { tabPresent: true, recoveryPresent: true }, + { tabPresent: false, recoveryPresent: false } + ]) + ) + await orcaPage.evaluate(() => { + const e2eWindow = window as typeof window & { __orcaRetiredWorkerUnsubscribe?: () => void } + e2eWindow.__orcaRetiredWorkerUnsubscribe?.() + delete e2eWindow.__orcaRetiredWorkerUnsubscribe + }) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] ?? null, + workerPaneKey + ) + ) + .toBeNull() + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll(() => + orcaPage.evaluate(async (paneKey) => { + const session = await window.api.session.get() + return session.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null + }, workerPaneKey) + ) + .toBeNull() + await orcaPage.evaluate(() => window.api.session.flush()) + expect(readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey)).toBeNull() + + await orcaPage.reload() + await waitForSessionReady(orcaPage) + + const beforeActivation = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + tabCount: state?.tabsByWorktree[worktreeId]?.length ?? 0, + pendingStartupCount: Object.keys(state?.pendingStartupByTabId ?? {}).length + } + }, targetWorktreeId) + expect(beforeActivation).toEqual({ everActivated: false, tabCount: 0, pendingStartupCount: 0 }) + + const targetCard = orcaPage + .locator(`[data-worktree-id="${String(targetWorktreeId)}"]`) + .first() + .locator('[data-worktree-card-surface]') + await targetCard.evaluate((element: HTMLElement) => element.click()) + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId)) + .toBe(targetWorktreeId) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const activatedPane = await waitForActivePaneHookDescriptor(orcaPage) + expect(activatedPane.worktreeId).toBe(targetWorktreeId) + const activatedResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: activatedPane.paneKey } + ) + await expect + .poll( + async () => { + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: activatedResolved.result.terminal.handle, + limit: 50 + }) + return read.result.terminal.tail.join('\n') + }, + { timeout: 30_000, message: 'activated fallback terminal never produced output' } + ) + .not.toBe('') + + const spawnEvents = readCompletedWorkerLedger().filter((event) => event.event === 'spawn') + expect(spawnEvents).toHaveLength(1) + expect( + spawnEvents.filter( + (event) => event.args?.includes('resume') && event.args?.includes(PROVIDER_SESSION_ID) + ) + ).toEqual([]) + await expect(orcaPage.locator('.session-restored-banner')).toHaveCount(0) + + const afterActivation = await orcaPage.evaluate( + ({ originalTabId, worktreeId }) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[worktreeId] ?? [] + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + originalTabPresent: tabs.some((tab) => tab.id === originalTabId), + replacementTabCount: tabs.filter((tab) => tab.id !== originalTabId).length, + pendingResumeSessionIds: Object.values(state?.pendingStartupByTabId ?? {}).flatMap( + (startup) => (startup.resumeProviderSession ? [startup.resumeProviderSession.id] : []) + ), + resumeClaimCount: Object.keys(state?.automaticAgentResumeClaimsByTabId ?? {}).length + } + }, + { originalTabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + expect(afterActivation).toEqual({ + everActivated: true, + originalTabPresent: false, + replacementTabCount: 1, + pendingResumeSessionIds: [], + resumeClaimCount: 0 + }) + + const coordinatorAfter = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + expect(coordinatorAfter ? terminalIdentity(coordinatorAfter) : null).toEqual(coordinatorBefore) + expect(coordinatorAfter?.worktreeId).toBe(coordinatorWorktreeId) + }) +} diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts new file mode 100644 index 00000000000..03292dfb0e6 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -0,0 +1,610 @@ +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../src/shared/agent-session-resume' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { tokenizeStartupCommand } from '../../src/shared/tui-agent-startup-shell' +import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema' +import type { TerminalTab, Worktree } from '../../src/shared/types' +import { OrchestrationDb } from '../../src/main/runtime/orchestration/db' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { RpcContext } from '../../src/main/runtime/rpc/core' +import { ORCHESTRATION_METHODS } from '../../src/main/runtime/rpc/methods/orchestration' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { seedStartupSessionRestoredBanner } from '@/components/terminal-pane/session-restored-banner-pane-state' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '@/hooks/legacy-worker-terminal-recovery-event' +import { useAppStore, type AppState } from '@/store' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' +const ORIGINAL_TAB_ID = '1c897bc8-973b-47b4-9449-ac5fc6b726c3' +const ORIGINAL_LEAF_ID = '0526f763-6729-49af-adf8-85ddbcf2b4e7' +const ORIGINAL_PANE_KEY = makePaneKey(ORIGINAL_TAB_ID, ORIGINAL_LEAF_ID) +const ORIGINAL_PTY_ID = 'pty-background-worker' +const REPO_ID = '32a0226d-9f33-42e8-8b7b-24867dea06d4' +const WORKTREE_PATH = path.join(path.sep, 'workspace', 'factory-pr-4626-git-crypt') +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const CANARY_WORKTREE_PATH = path.join(path.sep, 'workspace', 'canary') +const CANARY_WORKTREE_ID = `${REPO_ID}::${CANARY_WORKTREE_PATH}` +const CANARY_TAB_ID = 'canary-tab' +const CANARY_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const CANARY_PTY_ID = 'pty-unrelated-canary' +const HELPER_TAB_ID = 'worker-child-terminal' +const HELPER_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const HELPER_PANE_KEY = makePaneKey(HELPER_TAB_ID, HELPER_LEAF_ID) +const TERMINAL_HANDLE = 'terminal-background-worker' +const initialAppStoreState = useAppStore.getState() + +function makeWorktree(id: string, workspacePath: string): Worktree { + return { + id, + repoId: REPO_ID, + path: workspacePath, + head: 'abc123', + branch: 'refs/heads/review', + isBare: false, + isMainWorktree: false, + displayName: path.basename(workspacePath), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + hostId: 'local' + } +} + +function makeTab(id: string, worktreeId: string, ptyId: string, title: string): TerminalTab { + return { + id, + ptyId, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(leafId: string, ptyId: string) { + return { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function seedWorkspace(options: { helper?: boolean } = {}): void { + useAppStore.setState(initialAppStoreState, true) + const target = makeWorktree(WORKTREE_ID, WORKTREE_PATH) + const canary = makeWorktree(CANARY_WORKTREE_ID, CANARY_WORKTREE_PATH) + const original = makeTab( + ORIGINAL_TAB_ID, + WORKTREE_ID, + ORIGINAL_PTY_ID, + 'PR 4626 unified correction r3' + ) + const unrelated = makeTab(CANARY_TAB_ID, CANARY_WORKTREE_ID, CANARY_PTY_ID, 'Unrelated') + const helper = makeTab(HELPER_TAB_ID, WORKTREE_ID, 'pty-worker-child', 'Worker child') + useAppStore.setState({ + repos: [ + { + id: REPO_ID, + path: path.join(path.sep, 'workspace'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'local' + } + ], + worktreesByRepo: { [REPO_ID]: [target, canary] }, + activeRepoId: REPO_ID, + activeWorktreeId: CANARY_WORKTREE_ID, + activeTabId: CANARY_TAB_ID, + activeTabType: 'terminal', + activeView: 'terminal', + tabsByWorktree: { + [WORKTREE_ID]: options.helper ? [original, helper] : [original], + [CANARY_WORKTREE_ID]: [unrelated] + }, + ptyIdsByTabId: { + [ORIGINAL_TAB_ID]: [ORIGINAL_PTY_ID], + [CANARY_TAB_ID]: [CANARY_PTY_ID], + ...(options.helper ? { [HELPER_TAB_ID]: ['pty-worker-child'] } : {}) + }, + terminalLayoutsByTabId: { + [ORIGINAL_TAB_ID]: makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID), + [CANARY_TAB_ID]: makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID), + ...(options.helper ? { [HELPER_TAB_ID]: makeLayout(HELPER_LEAF_ID, 'pty-worker-child') } : {}) + }, + activeTabIdByWorktree: { + [WORKTREE_ID]: ORIGINAL_TAB_ID, + [CANARY_WORKTREE_ID]: CANARY_TAB_ID + }, + activeTabTypeByWorktree: { + [WORKTREE_ID]: 'terminal', + [CANARY_WORKTREE_ID]: 'terminal' + }, + tabBarOrderByWorktree: { + [WORKTREE_ID]: options.helper ? [ORIGINAL_TAB_ID, HELPER_TAB_ID] : [ORIGINAL_TAB_ID], + [CANARY_WORKTREE_ID]: [CANARY_TAB_ID] + }, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + everActivatedWorktreeIds: new Set([CANARY_WORKTREE_ID]), + settings: { + ...initialAppStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' }, + setupScriptLaunchMode: 'new-tab' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as Partial) +} + +function recordWorkingWorker() { + const providerSession = { key: 'session_id' as const, id: PROVIDER_SESSION_ID } + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'working', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_478_130, stateStartedAt: 1_786_361_478_130 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + return providerSession +} + +function completeRecordedWorker( + providerSession: ReturnType +): SleepingAgentSessionRecord { + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'done', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_625_666, stateStartedAt: 1_786_361_625_666 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + expect(useAppStore.getState().agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'done', + providerSession + }) + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + expect(record).toMatchObject({ + paneKey: ORIGINAL_PANE_KEY, + tabId: ORIGINAL_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession, + state: 'working', + origin: 'live' + }) + return record! +} + +function recordCompletedWorker(): SleepingAgentSessionRecord { + return completeRecordedWorker(recordWorkingWorker()) +} + +function expectCanaryUnchanged(): void { + const state = useAppStore.getState() + expect(state.tabsByWorktree[CANARY_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID }) + ]) + expect(state.terminalLayoutsByTabId[CANARY_TAB_ID]).toEqual( + makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID) + ) +} + +function orchestrationMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Missing orchestration method: ${name}`) + } + return method +} + +async function releaseCompletedWorker(terminalState: 'running' | 'exited'): Promise { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const coordinatorPaneKey = 'coordinator-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const run = db.createRun({ + objective: 'Completed worker retirement reproduction', + coordinatorHandle: 'terminal-coordinator', + coordinatorPaneKey + }) + const ctx: RpcContext = { runtime } + const call = async (name: string, params: Record) => { + const method = orchestrationMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'terminal-coordinator' ? coordinatorPaneKey : ORIGINAL_PANE_KEY + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === TERMINAL_HANDLE ? 'runtime:test:worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === TERMINAL_HANDLE + ? ({ + terminalHandle: TERMINAL_HANDLE, + paneKey: ORIGINAL_PANE_KEY, + processIncarnation: 'runtime:test:worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ id: WORKTREE_ID } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + title: 'PR 4626 unified correction r3' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: TERMINAL_HANDLE, + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + ...(terminalState === 'exited' ? { connected: false } : { status: 'running' }) + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + status: terminalState, + tail: terminalState === 'exited' ? [] : ['completed worker output'], + truncated: false, + nextCursor: terminalState === 'exited' ? null : '1' + }) + const closeTerminal = vi.spyOn(runtime, 'closeTerminal').mockImplementation(async () => { + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + return { handle: TERMINAL_HANDLE, tabId: ORIGINAL_TAB_ID, ptyKilled: true } + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + try { + const task = db.createTask({ spec: 'release completed worker', runId: run.id }) + const started = (await call('orchestration.workerStart', { + task: task.id, + from: 'terminal-coordinator', + agent: 'codex' + })) as { dispatchId: string; state: string } + expect(started.state).toBe('ready') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('ready') + expect( + db.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatchId, + outcome: 'succeeded', + result: 'worker completed' + }).action + ).toBe('settled') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + + await expect( + call('orchestration.workerRelease', { dispatch: started.dispatchId }) + ).resolves.toMatchObject({ + dispatchId: started.dispatchId, + state: 'released', + processAction: terminalState === 'exited' ? 'closed_exited_terminal' : 'closed_agent_terminal' + }) + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + expect(db.getWorkerTerminalResourceByOwner(started.dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released', + pane_key: ORIGINAL_PANE_KEY, + terminal_handle: TERMINAL_HANDLE + }) + expect(closeTerminal).toHaveBeenCalledOnce() + } finally { + db.close() + } +} + +function persistAndParseCurrentSession() { + const payload = buildWorkspaceSessionPayload(useAppStore.getState()) + const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(payload))) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.value +} + +async function hydrateSession( + session: ReturnType +): Promise { + seedWorkspace() + useAppStore.getState().hydrateWorkspaceSession(session) + useAppStore.getState().hydrateTabsSession(session) + await useAppStore.getState().reconnectPersistedTerminals() +} + +beforeEach(() => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtime: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } + } + }) + seedWorkspace() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +describe('completed background-worker retirement resume matrix', () => { + it('does not cold-resume an explicitly completed and retired worker on first activation', async () => { + // Case 1: task completion alone keeps the still-owned provider session recoverable in place. + const ownedRecord = recordCompletedWorker() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBe( + ownedRecord + ) + + // Case 2: the renderer boundary used by an explicit Orca close retires the exact authority. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 3: both running and PTY-exit-first release retire exact resume authority. + seedWorkspace() + recordCompletedWorker() + await releaseCompletedWorker('running') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + seedWorkspace() + recordCompletedWorker() + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + origin: 'live', + state: 'working', + providerSession: { key: 'session_id', id: PROVIDER_SESSION_ID } + }) + await releaseCompletedWorker('exited') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + + const retiredRestart = persistAndParseCurrentSession() + expect(retiredRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(retiredRestart.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeUndefined() + + // Case 4: legacy rollback preserves a fenced record; exited resolution clears it. + seedWorkspace() + const legacyRecord = recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [ORIGINAL_PANE_KEY]: { + ...legacyRecord, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + } + }) + const legacyAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'rolled_back', + ptyId: ORIGINAL_PTY_ID + }) + expect(legacyAction.kind).toBe('rollback-surface') + if (legacyAction.kind === 'rollback-surface') { + expect( + rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), legacyAction.detail) + ).toBe('removed') + } + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect( + useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + ?.automaticResumeBlockedBy + ).toBe('legacy-orchestration-worker') + const exitedAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'exited' + }) + expect(exitedAction).toEqual({ kind: 'clear-sleeping', paneKey: ORIGINAL_PANE_KEY }) + useAppStore.getState().clearSleepingAgentSession(ORIGINAL_PANE_KEY) + + // Case 5: coordinator manual close is the same safe exact-tab retirement boundary. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 6: normal Codex exit leaves the shell pane; helper close retires only the helper. + seedWorkspace({ helper: true }) + recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + ...useAppStore.getState().sleepingAgentSessionsByPaneKey, + [HELPER_PANE_KEY]: { + paneKey: HELPER_PANE_KEY, + tabId: HELPER_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'unrelated-helper-session' }, + prompt: 'helper', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toEqual( + makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID) + ) + closeTerminalTab(HELPER_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[HELPER_PANE_KEY]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeDefined() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expectCanaryUnchanged() + + // Case 7: restart preserves owned panes, while post-retirement restart keeps authority absent. + seedWorkspace() + const workingProviderSession = recordWorkingWorker() + const beforeCompletion = persistAndParseCurrentSession() + expect(beforeCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(beforeCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'working', + origin: 'live', + providerSession: workingProviderSession + }) + await hydrateSession(beforeCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe(ORIGINAL_PTY_ID) + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toEqual([ORIGINAL_PTY_ID]) + completeRecordedWorker(recordWorkingWorker()) + const afterCompletion = persistAndParseCurrentSession() + expect(afterCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(afterCompletion.terminalLayoutsByTabId[ORIGINAL_TAB_ID]?.ptyIdsByLeafId).toEqual({ + [ORIGINAL_LEAF_ID]: ORIGINAL_PTY_ID + }) + expect(afterCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeDefined() + await hydrateSession(afterCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[CANARY_WORKTREE_ID]?.[0]?.id).toBe(CANARY_TAB_ID) + + activateAndRevealWorktree(CANARY_WORKTREE_ID, { notifyHostRuntime: false }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + await releaseCompletedWorker('exited') + const restartAfterRetirement = persistAndParseCurrentSession() + await hydrateSession(restartAfterRetirement) + + // Case 8: first activation of the never-visited target cannot resurrect retired authority. + const beforeActivation = useAppStore.getState() + expect(beforeActivation.everActivatedWorktreeIds.has(WORKTREE_ID)).toBe(false) + expect(beforeActivation.agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(beforeActivation.sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(Object.keys(beforeActivation.pendingStartupByTabId)).toEqual([]) + const tabCountBeforeActivation = beforeActivation.tabsByWorktree[WORKTREE_ID]?.length ?? 0 + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + const activated = useAppStore.getState() + const replacementTabs = (activated.tabsByWorktree[WORKTREE_ID] ?? []).filter( + (tab) => tab.id !== ORIGINAL_TAB_ID + ) + // The provider-ownership gate separately proves this request becomes one transport spawn. + const coldSpawnRequests = replacementTabs.flatMap((tab) => { + const startup = activated.pendingStartupByTabId[tab.id] + if (!startup?.resumeProviderSession) { + return [] + } + const tokens = tokenizeStartupCommand(startup.command, 'posix') + expect(tokens.ok).toBe(true) + const showSessionRestoredBanner = vi.fn() + seedStartupSessionRestoredBanner(startup, 1, showSessionRestoredBanner) + return [ + { + providerSession: startup.resumeProviderSession, + command: startup.command, + argv: tokens.ok ? tokens.tokens : [], + restoredBannerCount: showSessionRestoredBanner.mock.calls.length + } + ] + }) + + expect(tabCountBeforeActivation).toBe(0) + expect(replacementTabs).toHaveLength(1) + expect(activated.tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === ORIGINAL_TAB_ID)).toBe( + false + ) + expect(activated.terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(activated.ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(coldSpawnRequests).toEqual([]) + expectCanaryUnchanged() + expect(Object.keys(activated.pendingStartupByTabId)).toHaveLength(0) + expect(Object.keys(activated.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + + // Required invariant: explicit completion plus retirement must revoke provider-resume authority. + expect(coldSpawnRequests).toEqual([]) + }) +}) diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts new file mode 100644 index 00000000000..d1211234258 --- /dev/null +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -0,0 +1,194 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { RuntimeClient } from '../../../src/cli/runtime-client' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import type { + RuntimeTerminalListResult, + RuntimeTerminalSummary +} from '../../../src/shared/runtime-types' + +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-retired-worker-')) +const lifecycleLedgerPath = path.join(fakeCliDir, 'codex-lifecycle.jsonl') +const fakeCodexSource = ` +const { appendFileSync } = require('node:fs') +const ledger = process.env.ORCA_E2E_CODEX_LIFECYCLE_LEDGER +const append = (event) => appendFileSync(ledger, JSON.stringify({ pid: process.pid, ...event }) + '\\n') +const args = process.argv.slice(2) +if (args.includes('app-server')) { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +append({ event: 'spawn', args }) +process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n') +process.stdin.on('data', (chunk) => { + const input = chunk.toString() + append({ event: 'input', input }) + if (input.includes('ORCA_E2E_EXIT_AFTER_DONE')) { + append({ event: 'normal-exit' }) + process.exit(0) + } + if (input.includes('\\r')) process.stdout.write('ACK\\n') +}) +process.stdin.resume() +setInterval(() => {}, 60_000) +` + +if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) +} else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) +} + +export const completedWorkerLaunchEnv = { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath +} + +export type LifecycleEvent = { + pid: number + event: 'spawn' | 'input' | 'normal-exit' + args?: string[] + input?: string +} + +export type TerminalIdentity = Pick< + RuntimeTerminalSummary, + 'handle' | 'incarnationId' | 'leafId' | 'ptyId' | 'tabId' | 'worktreeId' +> + +export function clearCompletedWorkerLedger(): void { + rmSync(lifecycleLedgerPath, { force: true }) +} + +export function cleanupCompletedWorkerFixture(): void { + rmSync(fakeCliDir, { recursive: true, force: true }) +} + +export function readCompletedWorkerLedger(): LifecycleEvent[] { + if (!existsSync(lifecycleLedgerPath)) { + return [] + } + const contents = readFileSync(lifecycleLedgerPath, 'utf8') + const lastCompleteLine = contents.lastIndexOf('\n') + if (lastCompleteLine < 0) { + return [] + } + return contents + .slice(0, lastCompleteLine) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as LifecycleEvent) +} + +export function readCompletedWorkerDispatchCapability(): string | null { + const input = readCompletedWorkerLedger() + .filter((event) => event.event === 'input') + .map((event) => event.input ?? '') + .join('') + return input.match(/--dispatch-capability\s+(\S+)/)?.[1] ?? null +} + +export function runBuiltOrcaCli( + args: string[], + options: { userDataDir: string; cwd: string } +): unknown { + const { + ORCA_ENVIRONMENT: _environment, + ORCA_PAIRING_CODE: _pairingCode, + ORCA_USER_DATA_PATH: _userDataPath, + ...cleanEnv + } = process.env + void _environment + void _pairingCode + void _userDataPath + const output = execFileSync( + process.execPath, + [path.join(process.cwd(), 'out', 'cli', 'index.js'), ...args], + { + cwd: options.cwd, + env: { ...cleanEnv, ORCA_USER_DATA_PATH: options.userDataDir }, + encoding: 'utf8', + timeout: 30_000 + } + ) + return JSON.parse(output) as unknown +} + +export function seedCurrentCodexTranscript( + isolatedHome: string, + providerSessionId: string, + cwd: string +): string { + const now = new Date() + const transcriptDir = path.join( + isolatedHome, + '.codex', + 'sessions', + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0') + ) + mkdirSync(transcriptDir, { recursive: true }) + const transcriptPath = path.join(transcriptDir, `rollout-${providerSessionId}.jsonl`) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: now.toISOString(), + type: 'session_meta', + payload: { id: providerSessionId, cwd } + })}\n` + ) + return transcriptPath +} + +export function terminalIdentity(terminal: RuntimeTerminalSummary): TerminalIdentity { + const { handle, incarnationId, leafId, ptyId, tabId, worktreeId } = terminal + return { handle, incarnationId, leafId, ptyId, tabId, worktreeId } +} + +export async function listRuntimeTerminals( + client: RuntimeClient +): Promise { + return (await client.call('terminal.list')).result.terminals +} + +export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + if (!existsSync(dataPath)) { + return null + } + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + origin?: unknown + state?: unknown + providerSession?: { id?: unknown } + } + > + } + } + return data.workspaceSession?.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null +} From 8c975b9d15fda7990c65e044267ec157a48d3c2d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 10 Aug 2026 19:55:29 -0700 Subject: [PATCH 22/69] fix(terminal): avoid Windows PATH registry subprocesses (#13485) --- src/main/cli/cli-installer.test.ts | 2 +- .../cli/windows-user-path-registry.test.ts | 2 +- src/main/cli/windows-user-path-registry.ts | 41 ++----- ...eflight-windows-path-refresh.repro.test.ts | 65 ++--------- ...windows-environment-path-main-loop.test.ts | 80 +++++++++++++ src/main/pty/windows-environment-path.test.ts | 110 ++++++++---------- src/main/pty/windows-environment-path.ts | 86 +++++++++----- .../pty/windows-path-registry-reader.test.ts | 70 +++++++++++ src/main/pty/windows-path-registry-reader.ts | 67 +++++++++++ src/main/windows-native-registry.ts | 25 ++++ 10 files changed, 371 insertions(+), 177 deletions(-) create mode 100644 src/main/pty/windows-environment-path-main-loop.test.ts create mode 100644 src/main/pty/windows-path-registry-reader.test.ts create mode 100644 src/main/pty/windows-path-registry-reader.ts create mode 100644 src/main/windows-native-registry.ts diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..180611c1590 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -489,7 +489,7 @@ describe('CliInstaller', () => { const registryReader = new WindowsUserPathRegistryReader({ platform: 'win32', registryLoader: async () => ({ - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, getRegistryKey: () => ({ Path: { name: 'Path', type: 2, value: registryPath } }) diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts index 12beca13852..7f919694626 100644 --- a/src/main/cli/windows-user-path-registry.test.ts +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -7,7 +7,7 @@ import { function registryModule(pathValue?: string, type = 2) { return { - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, REG: { SZ: 1, EXPAND_SZ: 2 }, getRegistryKey: vi.fn(() => pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts index d3e493fd87d..90e8ba7863e 100644 --- a/src/main/cli/windows-user-path-registry.ts +++ b/src/main/cli/windows-user-path-registry.ts @@ -1,26 +1,17 @@ -import { createRequire } from 'node:module' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' export type WindowsUserPathReadResult = | { state: 'success'; value: string | null; expandable: boolean } | { state: 'unknown'; detail: string } -type RegistryValue = { - name?: unknown - type?: unknown - value?: unknown -} - -type WindowsRegistryModule = { - HK: { CU: number } - getRegistryKey: ( - root: number, - path: string - ) => Record | null | undefined -} - type WindowsUserPathRegistryReaderOptions = { platform?: NodeJS.Platform - registryLoader?: () => Promise + registryLoader?: () => Promise now?: () => number cacheTtlMs?: number } @@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = { const DEFAULT_CACHE_TTL_MS = 1_000 const USER_ENVIRONMENT_KEY = 'Environment' const USER_PATH_VALUE = 'Path' -const REG_SZ = 1 -const REG_EXPAND_SZ = 2 -const requireFromMain = createRequire(__filename) - -async function loadWindowsRegistryModule(): Promise { - // Why: the optional dependency is not present in non-Windows installs, so - // TypeScript and the main bundle must not resolve it eagerly. - return requireFromMain('windows-native-registry') as WindowsRegistryModule -} - export class WindowsUserPathRegistryReader { private readonly platform: NodeJS.Platform - private readonly registryLoader: () => Promise + private readonly registryLoader: () => Promise private readonly now: () => number private readonly cacheTtlMs: number private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null @@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader { constructor(options: WindowsUserPathRegistryReaderOptions = {}) { this.platform = options.platform ?? process.platform - this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry()) this.now = options.now ?? Date.now this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS } @@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: null, expandable: false } } if ( - (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + (pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) || typeof pathEntry.value !== 'string' ) { return { @@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: pathEntry.value || null, - expandable: pathEntry.type === REG_EXPAND_SZ + expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ } } catch { return { diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts index 38e135d4558..916ab35ab63 100644 --- a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -3,45 +3,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ - registryQueryAsyncMock: vi.fn(), - registryQuerySyncMock: vi.fn() -})) - -vi.mock('node:child_process', async (importOriginal) => { - const original = await importOriginal>() - const originalExecFile = original.execFile as ( - command: string, - commandArgs: string[], - commandOptions: unknown, - commandCallback: (error: Error | null, stdout: string, stderr: string) => void - ) => unknown - const registryAwareExecFile = ( - file: string, - args: string[], - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): unknown => { - if (file.toLowerCase().endsWith('\\reg.exe')) { - return registryQueryAsyncMock(file, args, options, callback) - } - return originalExecFile(file, args, options, callback) - } - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - Object.defineProperty(registryAwareExecFile, customPromisify, { - value: (originalExecFile as unknown as Record)[customPromisify] - }) - return { - ...original, - execFile: registryAwareExecFile, - execFileSync: registryQuerySyncMock - } -}) - import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { execLocalPreflightCommand } from './preflight-command-exec' describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { @@ -50,8 +16,7 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep afterEach(() => { process.env.Path = originalPath - registryQueryAsyncMock.mockReset() - registryQuerySyncMock.mockReset() + __setWindowsPathRegistryLoaderForTests() __resetPersistedWindowsPathCacheForTests() for (const directory of fixtureDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -68,22 +33,13 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep ) let persistedUserPath = '' - registryQuerySyncMock.mockImplementation((_file, args: string[]) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - return ` Path REG_SZ ${value}\r\n` - }) - registryQueryAsyncMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - callback(null, ` Path REG_SZ ${value}\r\n`, '') - return {} as never - } - ) + const getRegistryKey = vi.fn((root: number) => ({ + Path: { type: 1, value: root === 2 ? persistedUserPath : '' } + })) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) __resetPersistedWindowsPathCacheForTests() await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ @@ -97,7 +53,6 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ stdout: expect.any(String) }) - expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) - expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + expect(getRegistryKey).toHaveBeenCalledTimes(4) }) }) diff --git a/src/main/pty/windows-environment-path-main-loop.test.ts b/src/main/pty/windows-environment-path-main-loop.test.ts new file mode 100644 index 00000000000..fbe185479cc --- /dev/null +++ b/src/main/pty/windows-environment-path-main-loop.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegments +} from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' + +const CREATE_PROCESS_DELAY_MS = 160 +const delayedExecFileSync = vi.hoisted(() => + vi.fn(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CREATE_PROCESS_DELAY_MS) + return ' Path REG_EXPAND_SZ C:\\Delayed' + }) +) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: delayedExecFileSync +})) + +describe('persisted Windows PATH process creation', () => { + afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + delayedExecFileSync.mockClear() + }) + + async function measureMainLoopGap(run: () => T): Promise<{ + callMs: number + maxGapMs: number + result: T + }> { + let lastTick = performance.now() + let maxGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + maxGapMs = Math.max(maxGapMs, now - lastTick - 5) + lastTick = now + }, 5) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const startedAt = performance.now() + const result = run() + const callMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 20)) + clearInterval(timer) + return { callMs, maxGapMs, result } + } + + it('proves delayed process creation blocks while native registry reads create no process', async () => { + const legacy = await measureMainLoopGap(() => + readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + execFileSync: delayedExecFileSync as never + }) + ) + + expect(legacy.result).toEqual(['C:\\Delayed', 'C:\\Delayed']) + expect(delayedExecFileSync).toHaveBeenCalledTimes(2) + expect(legacy.callMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS * 2) + expect(legacy.maxGapMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS) + + __resetPersistedWindowsPathCacheForTests() + delayedExecFileSync.mockClear() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { type: 1, value: root === 1 ? 'C:\\Machine' : 'C:\\User' } + }) + })) + const native = readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(native).toEqual(['C:\\Machine', 'C:\\User']) + expect(delayedExecFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index d639ee604ce..e1ace3350ca 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ defaultExecFileMock: vi.fn(), @@ -18,8 +18,26 @@ import { readPersistedWindowsPathSegmentsAsync, resolvePathEnvKey } from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +const registryGetKeyMock = vi.fn() + +function registryPath(value: string): Record { + return { Path: { type: 1, value } } +} + +beforeEach(() => { + registryGetKeyMock.mockReset() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: registryGetKeyMock + })) +}) + +afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() +}) describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { @@ -67,18 +85,17 @@ describe('readPersistedWindowsPathSegments', () => { it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -86,27 +103,26 @@ describe('readPersistedWindowsPathSegments', () => { it('force-refreshes the production registry cache', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ 'C:\\Machine', 'C:\\User', 'C:\\Program Files\\GitHub CLI' ]) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -114,9 +130,9 @@ describe('readPersistedWindowsPathSegments', () => { it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) .mockImplementation(() => { throw new Error('ERROR: Access is denied.') }) @@ -131,7 +147,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -139,10 +154,10 @@ describe('readPersistedWindowsPathSegments', () => { it('still clears the cache when the registry reports an empty Path', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValue(' Path REG_SZ \r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValue(registryPath('')) __resetPersistedWindowsPathCacheForTests() try { @@ -154,7 +169,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -162,13 +176,9 @@ describe('readPersistedWindowsPathSegments', () => { it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const callbacks: ExecCallback[] = [] - defaultExecFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callbacks.push(callback) - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { @@ -177,41 +187,25 @@ describe('readPersistedWindowsPathSegments', () => { const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) - callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') - callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') await Promise.all([first, second]) expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) - it('keeps the last good cache when bounded asynchronous reads time out', async () => { + it('keeps the last good cache when native registry reads fail', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileMock - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\Machine\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\User\r\n', '') - return {} as never - } - ) - .mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) __resetPersistedWindowsPathCacheForTests() try { @@ -223,7 +217,7 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ 'C:\\Machine', 'C:\\User' @@ -232,11 +226,9 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(4) - expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 36032da9aec..8fad453cab5 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,9 +1,10 @@ -import { execFile, execFileSync } from 'node:child_process' +import type { execFile, execFileSync } from 'node:child_process' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { readWindowsPathRegistry } from './windows-path-registry-reader' type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -16,10 +17,7 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { - failed: boolean - segments: string[] -} +type RegistryPathRead = { failed: boolean; segments: string[] } const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], @@ -71,6 +69,19 @@ function registryOutputSegments( : [] } +function readNativeRegistryPaths( + env: NodeJS.ProcessEnv, + pathDelimiter: string +): RegistryPathRead[] { + return readWindowsPathRegistry().map((read) => ({ + failed: read.failed, + segments: + read.value === null + ? [] + : splitPathSegments(expandWindowsEnvironmentVariables(read.value, env), pathDelimiter) + })) +} + function keepLastGoodSegments(segments: string[], failedReads: number): string[] { if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { return [...persistedWindowsPathCache.segments] @@ -145,34 +156,39 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const segments: string[] = [] - let failedReads = 0 - - for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { - try { - const output = run(getRegExePath(env), ['query', key, '/v', valueName], { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true + const reads = options.execFileSync + ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { + try { + const output = options.execFileSync!( + getRegExePath(env), + ['query', key, '/v', valueName], + { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + } + ) + return { + failed: false, + segments: registryOutputSegments(output, valueName, env, pathDelimiter) + } + } catch { + return { failed: true, segments: [] } + } }) - segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) - } catch { - // Registry access can fail in stripped test containers or remote-like - // Windows contexts. Existing PATH remains the fallback in those cases. - failedReads += 1 - } - } + : readNativeRegistryPaths(env, pathDelimiter) + const segments = reads.flatMap((read) => read.segments) + const failedReads = reads.filter((read) => read.failed).length if (!useProductionCache) { return segments } - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. + // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two + // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still + // picking up newly installed CLIs soon. return cachePersistedWindowsPathSegments(segments, failedReads) } @@ -202,14 +218,22 @@ export async function readPersistedWindowsPathSegmentsAsync( return [...(await pendingPersistedWindowsPathRefresh)] } - const run = options.execFile ?? execFile const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const executable = getRegExePath(env) - const refresh = Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) - ) + const refresh = ( + options.execFile + ? Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync( + options.execFile!, + getRegExePath(env), + registryValue, + env, + pathDelimiter + ) + ) + ) + : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts new file mode 100644 index 00000000000..634cd30e72a --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __setWindowsPathRegistryLoaderForTests, + readWindowsPathRegistry +} from './windows-path-registry-reader' + +describe('readWindowsPathRegistry', () => { + afterEach(() => __setWindowsPathRegistryLoaderForTests()) + + it('reads machine and user PATH values without creating a process', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ PATH: { type: 2, value: '%SystemRoot%\\System32' } }) + .mockReturnValueOnce({ Path: { type: 1, value: 'C:\\Users\\me\\bin' } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '%SystemRoot%\\System32' }, + { failed: false, value: 'C:\\Users\\me\\bin' } + ]) + expect(getRegistryKey).toHaveBeenNthCalledWith( + 1, + 1, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + expect(getRegistryKey).toHaveBeenNthCalledWith(2, 2, 'Environment') + }) + + it('distinguishes an empty PATH from a failed registry read', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ Path: { type: 1, value: '' } }) + .mockReturnValueOnce({ Path: { type: 4, value: 1 } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: true, value: null } + ]) + }) + + it('treats a missing PATH value as a failed query', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({})) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) + + it('fails closed when the optional native module is unavailable', () => { + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('native module unavailable') + }) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) +}) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts new file mode 100644 index 00000000000..6f98c45e390 --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.ts @@ -0,0 +1,67 @@ +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' + +export type WindowsPathRegistryRead = { + failed: boolean + value: string | null +} + +const MACHINE_ENVIRONMENT_KEY = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' +const USER_ENVIRONMENT_KEY = 'Environment' +const PATH_VALUE = 'Path' + +let windowsRegistryLoader = loadWindowsNativeRegistry + +function readRegistryPath( + registry: WindowsNativeRegistryModule, + root: number, + key: string +): WindowsPathRegistryRead { + try { + const values = registry.getRegistryKey(root, key) + if (!values || typeof values !== 'object') { + return { failed: true, value: null } + } + const entry = Object.entries(values).find( + ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() + )?.[1] + if (!entry) { + return { failed: true, value: null } + } + if ( + (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || + typeof entry.value !== 'string' + ) { + return { failed: true, value: null } + } + return { failed: false, value: entry.value } + } catch { + return { failed: true, value: null } + } +} + +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { + let registry: WindowsNativeRegistryModule + try { + registry = windowsRegistryLoader() + } catch { + return [ + { failed: true, value: null }, + { failed: true, value: null } + ] + } + return [ + readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), + readRegistryPath(registry, registry.HK.CU, USER_ENVIRONMENT_KEY) + ] +} + +export function __setWindowsPathRegistryLoaderForTests( + loader?: () => WindowsNativeRegistryModule +): void { + windowsRegistryLoader = loader ?? loadWindowsNativeRegistry +} diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts new file mode 100644 index 00000000000..e3a2d77889d --- /dev/null +++ b/src/main/windows-native-registry.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeRegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +export type WindowsNativeRegistryModule = { + HK: { CU: number; LM: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +export const WINDOWS_REG_SZ = 1 +export const WINDOWS_REG_EXPAND_SZ = 2 + +const requireFromMain = createRequire(__filename) + +export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { + // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. + return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule +} From 7d1a17465e50d0bedf17d3fffa4de1cd85c6cbbe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:56:08 -0700 Subject: [PATCH 23/69] perf(computer-use): skip unused accessibility summaries (#13559) --- config/scripts/verify-computer-native.mjs | 19 ++ native/computer-use-linux/runtime.py | 7 +- .../computer-use-linux/runtime_render_test.py | 198 ++++++++++++++++ .../runtime-render.test.ps1 | 224 ++++++++++++++++++ native/computer-use-windows/runtime.ps1 | 8 +- 5 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 native/computer-use-linux/runtime_render_test.py create mode 100644 native/computer-use-windows/runtime-render.test.ps1 diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index 91c27e03a5b..ba23a49cdd0 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -27,6 +27,12 @@ const checks = [ ], enabled: true }, + { + name: 'Linux snapshot renderer tests', + command: 'python3', + args: ['native/computer-use-linux/runtime_render_test.py'], + enabled: true + }, { name: 'native provider argument guardrails', run: verifyNativeArgumentGuardrails, @@ -69,6 +75,19 @@ const checks = [ run: verifyWindowsProviderHandshake, enabled: process.platform === 'win32' }, + { + name: 'Windows snapshot renderer tests', + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime-render.test.ps1' + ], + enabled: process.platform === 'win32' + }, { name: 'macOS helper app bundle and signature', run: verifyMacOSHelperApp, diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index baeed544bd6..ac53f6cf65f 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs item = record(node, len(records), path, window_rect) child_items = list(children(node)) role_key = (item["controlType"] or "").lower() - summary_values = text_snippets(node, limit=8, max_depth=4) - generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + generic_summary = None + if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]: + summary_values = text_snippets(node, limit=8, max_depth=4) + if len(summary_values) >= 2 and is_plain_text_subtree(node): + generic_summary = " ".join(summary_values) if should_elide(item, len(child_items), generic_summary): for child_index, child in child_items: walk(child, depth, path + [child_index]) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py new file mode 100644 index 00000000000..83a600c7d2d --- /dev/null +++ b/native/computer-use-linux/runtime_render_test.py @@ -0,0 +1,198 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class FakeStateType: + PROTECTED = 1 + SELECTED = 2 + + +class FakeText: + @staticmethod + def get_character_count(node): + return len(node.value) + + @staticmethod + def get_text(node, start, end): + return node.value[start:end] + + +def load_runtime(): + gi = types.ModuleType("gi") + repository = types.ModuleType("gi.repository") + gi.require_version = lambda *_: None + repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText) + repository.Gdk = types.SimpleNamespace() + repository.GdkPixbuf = types.SimpleNamespace() + gi.repository = repository + sys.modules["gi"] = gi + sys.modules["gi.repository"] = repository + + path = Path(__file__).with_name("runtime.py") + spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runtime = load_runtime() + + +class FakeAccessible: + def __init__(self, role, name="", value="", children=(), counter=None): + self.role = role + self.name = name + self.value = value + self.children = list(children) + self.counter = counter or {"child_reads": 0} + for child in self.children: + child.use_counter(self.counter) + + def use_counter(self, counter): + self.counter = counter + for child in self.children: + child.use_counter(counter) + + def get_child_count(self): + self.counter["child_reads"] += 1 + return len(self.children) + + def get_child_at_index(self, index): + self.counter["child_reads"] += 1 + return self.children[index] + + def get_role_name(self): + return self.role + + def get_name(self): + return self.name + + def get_accessible_id(self): + return "" + + def get_toolkit_name(self): + return "fake" + + def get_component_iface(self): + return None + + def get_state_set(self): + return None + + def get_n_actions(self): + return 0 + + def is_text(self): + return bool(self.value) + + def get_text_iface(self): + return self if self.value else None + + def get_value_iface(self): + return None + + +class FailingChildrenAccessible(FakeAccessible): + def get_child_count(self): + raise RuntimeError("defunct node") + + +def render(root): + return runtime.render_accessibility_tree(root, None, [0]) + + +class RuntimeRenderTest(unittest.TestCase): + def test_named_non_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")]) + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Save"]) + self.assertEqual(lines, ["0 button Save"]) + self.assertEqual(root.counter["child_reads"], 2) + self.assertFalse(truncation["truncated"]) + + def test_named_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")]) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Details", "body"]) + self.assertEqual(lines, ["0 section Details", "\t1 text body"]) + self.assertEqual(root.counter["child_reads"], 3) + + def test_unnamed_generic_node_keeps_plain_text_summary(self): + root = FakeAccessible( + "section", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual(len(records), 1) + self.assertEqual(lines, ["0 section, Text: Alpha Beta"]) + self.assertEqual(root.counter["child_reads"], 13) + + def test_row_keeps_its_specific_summary_walk(self): + root = FakeAccessible( + "row", + "Invoice", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"]) + self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"]) + self.assertEqual(root.counter["child_reads"], 10) + + def test_empty_generic_wrapper_keeps_elision_path_and_depth(self): + root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")]) + + records, lines, _ = render(root) + + self.assertEqual([record["runtimeId"] for record in records], [[0, 0]]) + self.assertEqual(lines, ["0 button Continue"]) + + def test_child_failure_keeps_fail_soft_row_output(self): + root = FailingChildrenAccessible("row", "Invoice") + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice"]) + self.assertEqual(lines, ["0 row Invoice"]) + self.assertFalse(truncation["truncated"]) + + def test_node_limit_keeps_exact_prefix_and_truncation(self): + root = FakeAccessible( + "document", + "Results", + children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)], + ) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_NODES) + self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}") + self.assertTrue(truncation["truncated"]) + self.assertFalse(truncation["maxDepthReached"]) + + def test_depth_limit_keeps_exact_prefix_and_flag(self): + root = FakeAccessible("document", "Depth 65") + for depth in range(runtime.MAX_DEPTH, -1, -1): + root = FakeAccessible("document", f"Depth {depth}", children=[root]) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_DEPTH + 1) + self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}") + self.assertTrue(truncation["truncated"]) + self.assertTrue(truncation["maxDepthReached"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/native/computer-use-windows/runtime-render.test.ps1 b/native/computer-use-windows/runtime-render.test.ps1 new file mode 100644 index 00000000000..3e52b1a203e --- /dev/null +++ b/native/computer-use-windows/runtime-render.test.ps1 @@ -0,0 +1,224 @@ +$ErrorActionPreference = "Stop" + +function Assert-TestEqual($Actual, $Expected, [string]$Label) { + if ($Actual -ne $Expected) { + throw "$Label expected '$Expected', received '$Actual'" + } +} + +Add-Type -TypeDefinition @" +using System.Collections; +using System.Collections.Generic; + +public sealed class OrcaRenderTestCounter { + public int FindAll; +} + +public sealed class OrcaRenderTestBounds { + public bool IsEmpty = true; + public double X; + public double Y; + public double Width; + public double Height; +} + +public sealed class OrcaRenderTestControlType { + public string ProgrammaticName = ""; +} + +public sealed class OrcaRenderTestCurrent { + public string AutomationId = ""; + public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds(); + public string ClassName = "fake"; + public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType(); + public bool IsPassword; + public string LocalizedControlType = ""; + public string Name = ""; + public long NativeWindowHandle; +} + +public sealed class OrcaRenderTestPatternCurrent { + public bool IsSelected; + public string Value = ""; +} + +public sealed class OrcaRenderTestPattern { + public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent(); +} + +public sealed class OrcaRenderTestCollection : IEnumerable { + private readonly OrcaRenderTestElement[] values; + + public OrcaRenderTestCollection(OrcaRenderTestElement[] values) { + this.values = values; + } + + public int Count { + get { return values.Length; } + } + + public OrcaRenderTestElement Item(int index) { + return values[index]; + } + + public IEnumerator GetEnumerator() { + return values.GetEnumerator(); + } +} + +public sealed class OrcaRenderTestElement { + public readonly List Children = + new List(); + public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter(); + public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent(); + public bool FailFindAll; + public int RuntimeIdValue; + public string ValueText = ""; + + public OrcaRenderTestCollection FindAll(object scope, object condition) { + Counter.FindAll++; + if (FailFindAll) { + throw new System.InvalidOperationException("defunct node"); + } + return new OrcaRenderTestCollection(Children.ToArray()); + } + + public OrcaRenderTestPattern GetCurrentPattern(object pattern) { + OrcaRenderTestPattern result = new OrcaRenderTestPattern(); + result.Current.Value = ValueText; + return result; + } + + public int[] GetRuntimeId() { + return new int[] { RuntimeIdValue }; + } + + public object[] GetSupportedPatterns() { + return new object[0]; + } +} +"@ + +function New-TestCounter { + New-Object -TypeName OrcaRenderTestCounter +} + +function New-TestElement { + param( + [string]$Role, + [string]$Name = "", + [string]$Value = "", + [object[]]$Children = @(), + $Counter = $(New-TestCounter), + [switch]$FailFindAll, + [int]$RuntimeId = 1 + ) + $element = New-Object -TypeName OrcaRenderTestElement + $element.Counter = $Counter + $element.FailFindAll = [bool]$FailFindAll + $element.RuntimeIdValue = $RuntimeId + $element.ValueText = $Value + $element.Current.ControlType.ProgrammaticName = $Role + $element.Current.LocalizedControlType = $Role + $element.Current.Name = $Name + foreach ($child in @($Children)) { + [void]$element.Children.Add($child) + } + $element +} + +$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json") +try { + Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}' + $runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath + $handshake = $runtimeOutput | ConvertFrom-Json + Assert-TestEqual $handshake.ok $true "runtime handshake" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "named control record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line" + Assert-TestEqual $counter.findAll 1 "named control child enumeration" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records" + Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines" + Assert-TestEqual $counter.findAll 2 "named generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary" + Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary" + Assert-TestEqual $counter.findAll 6 "row child enumeration" + + $counter = New-TestCounter + $button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Children @($button) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line" + Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration" + + $counter = New-TestCounter + $root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "failed child read record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line" + Assert-TestEqual $counter.findAll 2 "failed child read retries" + + $originalMaxNodes = $MaxNodes + try { + $MaxNodes = 3 + $counter = New-TestCounter + $children = @() + for ($index = 0; $index -lt 3; $index++) { + $children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2) + } + $root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "node limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "node limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag" + } finally { + $MaxNodes = $originalMaxNodes + } + + $originalMaxDepth = $MaxDepth + try { + $MaxDepth = 2 + $counter = New-TestCounter + $root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4 + for ($depth = 2; $depth -ge 0; $depth--) { + $root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1) + } + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "depth limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag" + } finally { + $MaxDepth = $originalMaxDepth + } + + Write-Output "windows-snapshot-render-tests-ok" +} finally { + Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 39ab77e1704..2aa581901e6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } $roleKey = $role.ToLowerInvariant() - $snippets = @(Get-OrcaTextSnippets $Node 8 4) $genericSummary = $null - if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { - $genericSummary = ($snippets -join " ") + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) { + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } } if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { for ($i = 0; $i -lt $children.Count; $i++) { From 3e3f48fe895413ffaa4d90ae2ce5670e6c795012 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:03:48 -0700 Subject: [PATCH 24/69] fix(native-chat): restore Codex model picker dispatch (#13669) * fix(native-chat): restore Codex model picker dispatch * fix(native-chat): type Codex effort picker command --- .../session/mobile-native-chat-send.test.ts | 38 +++++++++++++++- mobile/src/session/mobile-native-chat-send.ts | 22 ++++++++++ ...se-mobile-native-chat-message-send.test.ts | 17 ++++++++ .../use-mobile-native-chat-message-send.ts | 40 +++++++++++++++-- ...mobile-native-chat-session-options.test.ts | 21 ++++++--- .../use-mobile-native-chat-session-options.ts | 10 ++++- .../native-chat/NativeChatComposer.test.tsx | 15 ++++--- .../native-chat-pty-session-options.test.ts | 2 +- .../native-chat-runtime-send.test.ts | 30 +++++++++++++ .../native-chat/native-chat-runtime-send.ts | 19 ++++++++ .../native-chat-session-option-apply.ts | 4 +- ...ve-chat-session-option-command-dispatch.ts | 6 ++- .../use-native-chat-session-option-command.ts | 22 +++++++--- ...ent-session-option-catalog-claude-codex.ts | 11 ++--- .../agent-session-option-catalog-types.ts | 3 +- src/shared/agent-session-option-catalog.ts | 1 + src/shared/agent-tui-command-typing.ts | 43 +++++++++++++++++++ ...ative-chat-session-option-commands.test.ts | 9 +++- ...ative-chat-session-option-snapshot.test.ts | 4 +- 19 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 src/shared/agent-tui-command-typing.ts diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..1c7a661cda5 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,37 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..e7cb0e45beb 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,27 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + return typeAgentTuiCommand({ + command: args.command, + write: (key) => + sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 06b30f5cdb4..6e92169b819 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -260,6 +264,19 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + it('binds classification to the agent that started the send', async () => { let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void sendWithOutcome.mockReturnValue( diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 338c800cecd..e3bc6c0cabd 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,10 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +34,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -269,12 +274,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (options?.delivery === 'type') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +316,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index d7cab9a4c37..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..66a929aeb56 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 665f95449e9..90fa4380c77 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), draftScopeKeys: [] as string[], @@ -65,6 +66,7 @@ vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), + typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) @@ -181,6 +183,7 @@ describe('NativeChatComposer', () => { }) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.typeNativeChatCommand.mockResolvedValue(true) mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, @@ -550,7 +553,7 @@ describe('NativeChatComposer', () => { expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) - it('applies a Codex model change without switching to the terminal', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mocks.sendHandle.settleAfterMs = 0 const onSwitchToTerminal = vi.fn() render( @@ -563,17 +566,17 @@ describe('NativeChatComposer', () => { /> ) - // Codex model changes are value-bearing commands; only effort still uses the TUI picker. await act(async () => { - await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'gpt-5.5') + await mocks.fieldProps?.sessionOptionsSurface?.invokeAction('model') }) - expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith( {}, 'pty-1', - '/model gpt-5.5', + '/model', expect.any(AbortSignal) ) - expect(onSwitchToTerminal).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageVerified).not.toHaveBeenCalled() + expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 0dd5e19025d..37850bccaec 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -565,7 +565,7 @@ describe('native chat PTY session options', () => { ) const result = await surface.invokeAction('effort') - expect(dispatch).toHaveBeenCalledWith('/model') + expect(dispatch).toHaveBeenCalledWith('/model', { delivery: 'type' }) expect(onAgentPicker).toHaveBeenCalledOnce() expect(result.snapshot).toHaveLength(1) expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 93a63765a46..d5e54f205fa 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -12,6 +12,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { sendNativeChatMessage, sendNativeChatMessageVerified, + typeNativeChatCommand, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -252,6 +253,35 @@ describe('sendNativeChatMessageVerified', () => { }) }) +describe('typeNativeChatCommand', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + resetNativeChatPtySendQueuesForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() + }) + + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + const result = typeNativeChatCommand(SETTINGS, PTY, '/model') + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe(true) + expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '/', + 'm', + 'o', + 'd', + 'e', + 'l', + NATIVE_CHAT_SUBMIT + ]) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a2974474b2b..02bb53fbcdd 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -19,6 +19,7 @@ import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { typeAgentTuiCommand } from '../../../../shared/agent-tui-command-typing' import { cancelNativeChatPtySends, enqueueNativeChatPtySend, @@ -213,6 +214,24 @@ export async function sendNativeChatMessageVerified( return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) } +/** Types a slash command as individual keys so Codex opens its command palette. */ +export async function typeNativeChatCommand( + settings: RuntimeSettings, + ptyId: string, + command: string, + signal?: AbortSignal +): Promise { + cancelNativeChatPtySends(ptyId) + await waitForNativeChatPtyIdle(ptyId) + const outcome = await typeAgentTuiCommand({ + command, + signal, + write: async (key) => + (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + }) + return outcome === 'accepted' +} + export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts index 98481e1035c..5585512633a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts @@ -109,7 +109,9 @@ async function handleAgentPicker( ctx: SessionOptionApplyContext, midSession: Extract ): Promise { - await ctx.dispatchCommand(midSession.command) + await (midSession.delivery + ? ctx.dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : ctx.dispatchCommand(midSession.command)) ctx.clearModelTruth() const snapshot = ctx.publish() ctx.onAgentPicker?.() diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts index c510990dc5e..d7b071c55e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -1,4 +1,7 @@ -import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { + CatalogAgentInteractionDetection, + CatalogCommandDelivery +} from '../../../../shared/agent-session-option-catalog' import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' export type NativeChatSessionOptionDispatchResult = { @@ -10,6 +13,7 @@ export type NativeChatSessionOptionDispatchCommand = ( options?: { detectAgentInteraction?: CatalogAgentInteractionDetection expectedChoiceLabel?: string + delivery?: CatalogCommandDelivery } ) => | Promise diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 96d3cbdf479..5070b0230fe 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -6,7 +6,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import { pushHistory, type HistoryState } from './native-chat-composer-state' -import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { sendNativeChatMessageVerified, typeNativeChatCommand } from './native-chat-runtime-send' import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue' import { createClaudeModelSwitchConfirmationObserver, @@ -86,12 +86,20 @@ export function useNativeChatSessionOptionCommand(args: { // submit immediately so historical output cannot satisfy the match. observer.arm() } - const accepted = await sendNativeChatMessageVerified( - target.settings, - target.ptyId, - command, - sendController.signal - ) + const accepted = + options?.delivery === 'type' + ? await typeNativeChatCommand( + target.settings, + target.ptyId, + command, + sendController.signal + ) + : await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) if (!accepted) { throw new Error('The terminal did not accept the command.') } diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 3e2f0f57921..4e9396632c7 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -204,7 +204,7 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, removeAgentArgs: removeCodexEffortOverride, - midSession: { kind: 'agent-picker', command: '/model' } + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } } @@ -228,12 +228,9 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), - // Codex accepts a model argument in its live /model command. - midSession: { - kind: 'command', - build: (value) => `/model ${String(value)}`, - pickerCommand: '/model' - } + // Codex classifies multi-character writes as pasted prose; type the bare + // command and let its own picker apply the account-supported model. + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } }, unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index bb08339e07e..ae2af3b450f 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -6,6 +6,7 @@ import type { } from './native-chat-session-options' export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' +export type CatalogCommandDelivery = 'type' export type CatalogMidSessionApply = | { @@ -15,7 +16,7 @@ export type CatalogMidSessionApply = detectAgentInteraction?: CatalogAgentInteractionDetection } | { kind: 'toggle-command'; command: string } - | { kind: 'agent-picker'; command: string } + | { kind: 'agent-picker'; command: string; delivery?: CatalogCommandDelivery } | { kind: 'unsupported' } export type CatalogOptionApply = { diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index e5e254c45de..b242b6b27c9 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -20,6 +20,7 @@ import type { SessionOptionValue } from './native-chat-session-options' export type { AgentSessionOptionCatalog, CatalogAgentInteractionDetection, + CatalogCommandDelivery, CatalogMidSessionApply, CatalogModel, CatalogOption, diff --git a/src/shared/agent-tui-command-typing.ts b/src/shared/agent-tui-command-typing.ts new file mode 100644 index 00000000000..389d7e03141 --- /dev/null +++ b/src/shared/agent-tui-command-typing.ts @@ -0,0 +1,43 @@ +import { AGENT_TUI_CLEAR_INPUT_LINE } from './agent-tui-input-clear' + +export type AgentTuiCommandWriteOutcome = 'accepted' | 'rejected' | 'unknown' + +export const AGENT_TUI_COMMAND_KEY_INTERVAL_MS = 16 + +function waitForNextKey(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const timer = setTimeout(() => finish(true), AGENT_TUI_COMMAND_KEY_INTERVAL_MS) + const finish = (completed: boolean): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** Sends one PTY write per key so slash-command TUIs do not classify it as pasted prose. */ +export async function typeAgentTuiCommand(args: { + command: string + write: (key: string) => Promise + signal?: AbortSignal +}): Promise { + const keys = [AGENT_TUI_CLEAR_INPUT_LINE, ...args.command, '\r'] + for (let index = 0; index < keys.length; index += 1) { + if (args.signal?.aborted) { + return 'rejected' + } + const outcome = await args.write(keys[index]!) + if (outcome !== 'accepted') { + return outcome + } + if (index < keys.length - 1 && !(await waitForNextKey(args.signal))) { + return 'rejected' + } + } + return 'accepted' +} diff --git a/src/shared/native-chat-session-option-commands.test.ts b/src/shared/native-chat-session-option-commands.test.ts index 2583192d34b..77205f6ba5c 100644 --- a/src/shared/native-chat-session-option-commands.test.ts +++ b/src/shared/native-chat-session-option-commands.test.ts @@ -68,7 +68,7 @@ describe('buildNativeChatSessionOptionCommand', () => { ).toBe('/fast') }) - it('builds an absolute command for live Codex model changes', () => { + it('does not turn a Codex model pick into pasted slash-command prose', () => { expect( buildNativeChatSessionOptionCommand({ optionId: 'model', @@ -79,7 +79,12 @@ describe('buildNativeChatSessionOptionCommand', () => { models: CODEX_SESSION_OPTION_CATALOG.models, record: createNativeChatSessionOptionRecord('codex') }) - ).toBe('/model gpt-5.5') + ).toBeNull() + expect(CODEX_SESSION_OPTION_CATALOG.modelApply.midSession).toEqual({ + kind: 'agent-picker', + command: '/model', + delivery: 'type' + }) }) }) diff --git a/src/shared/native-chat-session-option-snapshot.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts index c6436f9aad4..961701b7415 100644 --- a/src/shared/native-chat-session-option-snapshot.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -207,7 +207,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { }) }) - it('exposes Codex model changes as native selectable values', () => { + it('routes Codex model changes through its typed TUI picker', () => { const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: CODEX_SESSION_OPTION_CATALOG, models: CODEX_SESSION_OPTION_CATALOG.models, @@ -216,7 +216,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { modelLabel: 'Model' }) expect(snapshot[0]).toMatchObject({ settable: true }) - expect(snapshot[0]?.action).toBeUndefined() + expect(snapshot[0]?.action).toEqual({ type: 'agent-picker' }) expect(snapshot[0]?.kind).toMatchObject({ type: 'select', choices: expect.arrayContaining([{ value: 'gpt-5.5', label: 'GPT-5.5' }]) From fb39308dfe005c92622cf56e7f2caab6e11892e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:24:56 -0700 Subject: [PATCH 25/69] refactor(daemon): remove abandoned binary framing (#13530) --- src/main/daemon/binary-frame.test.ts | 167 --------------------------- src/main/daemon/binary-frame.ts | 56 --------- src/main/daemon/types.ts | 17 --- 3 files changed, 240 deletions(-) delete mode 100644 src/main/daemon/binary-frame.test.ts delete mode 100644 src/main/daemon/binary-frame.ts diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts deleted file mode 100644 index 81148f18a59..00000000000 --- a/src/main/daemon/binary-frame.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' - -describe('encodeFrame', () => { - it('encodes a data frame with correct header', () => { - const payload = Buffer.from('hello') - const frame = encodeFrame(FrameType.Data, payload) - - expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) - expect(frame[0]).toBe(FrameType.Data) - expect(frame.readUInt32BE(1)).toBe(payload.length) - expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') - }) - - it('encodes a resize frame', () => { - const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) - const frame = encodeFrame(FrameType.Resize, payload) - - expect(frame[0]).toBe(FrameType.Resize) - expect(frame.readUInt32BE(1)).toBe(payload.length) - }) - - it('encodes an exit frame with exit code', () => { - const payload = Buffer.from(JSON.stringify({ code: 0 })) - const frame = encodeFrame(FrameType.Exit, payload) - - expect(frame[0]).toBe(FrameType.Exit) - const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) - expect(decoded.code).toBe(0) - }) - - it('encodes empty payload', () => { - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - - expect(frame.length).toBe(FRAME_HEADER_SIZE) - expect(frame[0]).toBe(FrameType.Kill) - expect(frame.readUInt32BE(1)).toBe(0) - }) - - it('throws on payload exceeding max size', () => { - const oversized = Buffer.alloc(1024 * 1024 + 1) - expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() - }) -}) - -describe('createFrameParser', () => { - it('parses a single complete frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('test data') - const frame = encodeFrame(FrameType.Data, payload) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[0][1].toString()).toBe('test data') - }) - - it('parses multiple frames in one chunk', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) - const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) - parser.feed(Buffer.concat([frame1, frame2])) - - expect(onFrame).toHaveBeenCalledTimes(2) - expect(onFrame.mock.calls[0][1].toString()).toBe('one') - expect(onFrame.mock.calls[1][1].toString()).toBe('two') - }) - - it('handles frame split across header boundary', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('split test') - const frame = encodeFrame(FrameType.Data, payload) - - // Split in the middle of the header (3 bytes, then the rest) - parser.feed(frame.subarray(0, 3)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(3)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('split test') - }) - - it('handles frame split in the middle of payload', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('payload split') - const frame = encodeFrame(FrameType.Data, payload) - - // Split after header + 3 bytes of payload - const splitPoint = FRAME_HEADER_SIZE + 3 - parser.feed(frame.subarray(0, splitPoint)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(splitPoint)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') - }) - - it('handles byte-by-byte feeding', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) - - for (let i = 0; i < frame.length; i++) { - parser.feed(frame.subarray(i, i + 1)) - } - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) - expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) - }) - - it('handles zero-length payload frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) - expect(onFrame.mock.calls[0][1].length).toBe(0) - }) - - it('parses different frame types correctly', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frames = [ - encodeFrame(FrameType.Data, Buffer.from('data')), - encodeFrame(FrameType.Resize, Buffer.from('resize')), - encodeFrame(FrameType.Signal, Buffer.from('signal')) - ] - - parser.feed(Buffer.concat(frames)) - - expect(onFrame).toHaveBeenCalledTimes(3) - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) - expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) - }) - - it('resets buffer state', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - // Feed partial frame then reset - const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) - parser.feed(frame.subarray(0, 3)) - parser.reset() - - // Feed a new complete frame - const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) - parser.feed(frame2) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') - }) -}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts deleted file mode 100644 index e8c4fd6ae90..00000000000 --- a/src/main/daemon/binary-frame.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { FrameType } from './types' -import type { FrameType } from './types' -import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' - -export { FRAME_HEADER_SIZE } - -export function encodeFrame(type: FrameType, payload: Buffer): Buffer { - if (payload.length > FRAME_MAX_PAYLOAD) { - throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) - } - - const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) - frame[0] = type - frame.writeUInt32BE(payload.length, 1) - payload.copy(frame, FRAME_HEADER_SIZE) - return frame -} - -export type FrameParser = { - feed(chunk: Buffer): void - reset(): void -} - -export function createFrameParser( - onFrame: (type: FrameType, payload: Buffer) => void -): FrameParser { - let buffer: Buffer = Buffer.alloc(0) - - function parse(): void { - while (buffer.length >= FRAME_HEADER_SIZE) { - const payloadLength = buffer.readUInt32BE(1) - const totalLength = FRAME_HEADER_SIZE + payloadLength - - if (buffer.length < totalLength) { - break - } - - const type = buffer[0] as FrameType - const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) - buffer = buffer.subarray(totalLength) - - onFrame(type, payload) - } - } - - return { - feed(chunk: Buffer): void { - buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) - parse() - }, - - reset(): void { - buffer = Buffer.alloc(0) - } - } -} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..1f067885467 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & { // existing importers keep one types entry point. export * from './daemon-stream-events' -// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── -// -// 5-byte header: [type:1][length:4 big-endian] -// Followed by `length` bytes of payload. - -export const enum FrameType { - Data = 0x01, - Resize = 0x02, - Exit = 0x03, - Error = 0x04, - Kill = 0x05, - Signal = 0x06 -} - -export const FRAME_HEADER_SIZE = 5 -export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB - // ─── Notify prefix ────────────────────────────────────────────────── // Requests with IDs starting with this prefix are fire-and-forget: // the daemon processes them but does not send a response. From a45adcfff0ac99a1e5aa8b3eb3711128313afe4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:00 -0700 Subject: [PATCH 26/69] refactor(emulator): remove unwired scrcpy control encoders (#13528) --- .../android/scrcpy-control-protocol.test.ts | 205 ------------------ .../android/scrcpy-control-protocol.ts | 91 -------- .../emulator/android/scrcpy-stream-session.ts | 9 +- 3 files changed, 1 insertion(+), 304 deletions(-) delete mode 100644 src/main/emulator/android/scrcpy-control-protocol.test.ts delete mode 100644 src/main/emulator/android/scrcpy-control-protocol.ts diff --git a/src/main/emulator/android/scrcpy-control-protocol.test.ts b/src/main/emulator/android/scrcpy-control-protocol.test.ts deleted file mode 100644 index 10e0e7a842a..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SCRCPY_CONTROL_MSG_TYPE, - encodeBackOrScreenOn, - encodeInjectKeycode, - encodeInjectText, - encodeInjectTouchEvent -} from './scrcpy-control-protocol' - -describe('SCRCPY_CONTROL_MSG_TYPE', () => { - it('pins the v2.4 control message type codes', () => { - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3) - expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4) - }) -}) - -describe('encodeInjectTouchEvent', () => { - it('encodes a touch-down event with the full 32-byte field layout', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 0x1122334455667788n, - x: 100, - y: 200, - screenWidth: 1080, - screenHeight: 1920 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT) - expect(buf.readUInt8(0)).toBe(2) - expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN - expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n) - expect(buf.readInt32BE(10)).toBe(100) - expect(buf.readInt32BE(14)).toBe(200) - expect(buf.readUInt16BE(18)).toBe(1080) - expect(buf.readUInt16BE(20)).toBe(1920) - expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down - expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY - expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed - }) - - it('encodes a move event with action code 2 and full default pressure', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE - expect(buf.readUInt16BE(22)).toBe(0xffff) - expect(buf.readUInt32BE(28)).toBe(1) - }) - - it('encodes an up event with zero default pressure and cleared buttons', () => { - const buf = encodeInjectTouchEvent({ - action: 'up', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP - expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up - expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary - expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up - }) - - it('writes negative coordinates as signed int32', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: -3, - y: -7, - screenWidth: 1, - screenHeight: 1 - }) - - expect(buf.readInt32BE(10)).toBe(-3) - expect(buf.readInt32BE(14)).toBe(-7) - }) - - it('encodes explicit fractional pressure as fixed-point u16', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 0.5 - }) - - expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff)) - }) - - it('clamps pressure above 1 and below 0', () => { - const high = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 2 - }) - expect(high.readUInt16BE(22)).toBe(0xffff) - - const low = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: -1 - }) - expect(low.readUInt16BE(22)).toBe(0) - }) -}) - -describe('encodeInjectKeycode', () => { - it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => { - const buf = encodeInjectKeycode({ - action: 'down', - keycode: 66, // AKEYCODE_ENTER - repeat: 3, - metaState: 0x1000 - }) - - expect(buf.length).toBe(14) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE) - expect(buf.readUInt8(0)).toBe(0) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - expect(buf.readInt32BE(2)).toBe(66) - expect(buf.readInt32BE(6)).toBe(3) - expect(buf.readInt32BE(10)).toBe(0x1000) - }) - - it('encodes a keycode-up event and applies repeat/metaState defaults', () => { - const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK - - expect(buf.length).toBe(14) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - expect(buf.readInt32BE(2)).toBe(4) - expect(buf.readInt32BE(6)).toBe(0) // default repeat - expect(buf.readInt32BE(10)).toBe(0) // default metaState - }) -}) - -describe('encodeInjectText', () => { - it('encodes the type byte, UTF-8 byte length header, and payload', () => { - const buf = encodeInjectText('hello') - - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT) - expect(buf.readUInt8(0)).toBe(1) - expect(buf.readUInt32BE(1)).toBe(5) - expect(buf.subarray(5).toString('utf8')).toBe('hello') - expect(buf.length).toBe(5 + 5) - }) - - it('uses UTF-8 byte length (not string length) for multi-byte characters', () => { - const text = 'é' // one JS char, two UTF-8 bytes - const buf = encodeInjectText(text) - - expect(text.length).toBe(1) - expect(buf.readUInt32BE(1)).toBe(2) - expect(buf.length).toBe(5 + 2) - expect(buf.subarray(5).toString('utf8')).toBe('é') - }) - - it('encodes an empty string as a 5-byte header with zero length', () => { - const buf = encodeInjectText('') - - expect(buf.length).toBe(5) - expect(buf.readUInt32BE(1)).toBe(0) - }) -}) - -describe('encodeBackOrScreenOn', () => { - it('encodes a 2-byte down message', () => { - const buf = encodeBackOrScreenOn('down') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON) - expect(buf.readUInt8(0)).toBe(4) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - }) - - it('encodes a 2-byte up message', () => { - const buf = encodeBackOrScreenOn('up') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - }) -}) diff --git a/src/main/emulator/android/scrcpy-control-protocol.ts b/src/main/emulator/android/scrcpy-control-protocol.ts deleted file mode 100644 index 4e35148948c..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.ts +++ /dev/null @@ -1,91 +0,0 @@ -// scrcpy server control protocol v2.4 -// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is -// big-endian to match the server's DataInputStream reads. - -export const SCRCPY_CONTROL_MSG_TYPE = { - INJECT_KEYCODE: 0, - INJECT_TEXT: 1, - INJECT_TOUCH_EVENT: 2, - INJECT_SCROLL_EVENT: 3, - BACK_OR_SCREEN_ON: 4 -} as const - -export type ScrcpyKeyAction = 'down' | 'up' -export type ScrcpyTouchAction = 'down' | 'up' | 'move' - -// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android. -const KEY_ACTION_CODE: Record = { down: 0, up: 1 } -const TOUCH_ACTION_CODE: Record = { down: 0, up: 1, move: 2 } - -// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches. -const BUTTON_PRIMARY = 1 -// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0. -const PRESSURE_MAX = 0xffff - -function encodePressure(pressure: number): number { - const fixed = Math.round(pressure * PRESSURE_MAX) - if (fixed < 0) { - return 0 - } - if (fixed > PRESSURE_MAX) { - return PRESSURE_MAX - } - return fixed -} - -export function encodeInjectTouchEvent(p: { - action: ScrcpyTouchAction - pointerId: bigint - x: number - y: number - screenWidth: number - screenHeight: number - pressure?: number -}): Buffer { - // A finger that lifts has no pressure; otherwise full pressure unless overridden. - const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1) - const buf = Buffer.alloc(32) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0) - buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1) - buf.writeBigUInt64BE(p.pointerId, 2) - buf.writeInt32BE(p.x, 10) - buf.writeInt32BE(p.y, 14) - buf.writeUInt16BE(p.screenWidth, 18) - buf.writeUInt16BE(p.screenHeight, 20) - buf.writeUInt16BE(encodePressure(pressure), 22) - buf.writeUInt32BE(BUTTON_PRIMARY, 24) - buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28) - return buf -} - -export function encodeInjectKeycode(p: { - action: ScrcpyKeyAction - keycode: number - repeat?: number - metaState?: number -}): Buffer { - const buf = Buffer.alloc(14) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0) - buf.writeUInt8(KEY_ACTION_CODE[p.action], 1) - buf.writeInt32BE(p.keycode, 2) - buf.writeInt32BE(p.repeat ?? 0, 6) - buf.writeInt32BE(p.metaState ?? 0, 10) - return buf -} - -export function encodeInjectText(text: string): Buffer { - // The length prefix is the UTF-8 byte count, which can exceed text.length. - const payload = Buffer.from(text, 'utf8') - const buf = Buffer.alloc(5 + payload.length) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0) - buf.writeUInt32BE(payload.length, 1) - payload.copy(buf, 5) - return buf -} - -export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer { - const buf = Buffer.alloc(2) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0) - buf.writeUInt8(KEY_ACTION_CODE[action], 1) - return buf -} diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..0333e9ee5db 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' // A live scrcpy session validated against a real emulator. The connection // handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; the pure framing -// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are +// set are coupled to the pinned scrcpy server version; pure video framing is // unit-tested, while this orchestration is exercised live via probes. const DEVICE_NAME_BYTES = 64 @@ -198,7 +197,6 @@ export class ScrcpyStreamSession { socket.on('error', (error) => emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial }) ) - // Drop the ref on reset so sendControl() can't write to a dead socket (half-open session). socket.on('close', () => { if (this.controlSocket === socket) { this.controlSocket = null @@ -253,11 +251,6 @@ export class ScrcpyStreamSession { } } - // Sends an encoded scrcpy control message (see scrcpy-control-protocol). - sendControl(message: Buffer): void { - this.controlSocket?.write(message) - } - private fail(message: string): void { if (this.closed) { return From a276e54e624bfbe418b7491c0221ed005460db8b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:03 -0700 Subject: [PATCH 27/69] refactor(relay): remove unwired PTY replay index (#13518) --- src/relay/pty-source-replay-index.test.ts | 96 ----------- src/relay/pty-source-replay-index.ts | 201 ---------------------- 2 files changed, 297 deletions(-) delete mode 100644 src/relay/pty-source-replay-index.test.ts delete mode 100644 src/relay/pty-source-replay-index.ts diff --git a/src/relay/pty-source-replay-index.test.ts b/src/relay/pty-source-replay-index.test.ts deleted file mode 100644 index efbb17451b0..00000000000 --- a/src/relay/pty-source-replay-index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PtySourceSpan } from '../shared/pty-source-credit-contract' -import { PtySourceReplayIndex } from './pty-source-replay-index' - -function span(id: string, data: string, sourceStartSu: number): PtySourceSpan { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - spanId: id, - sourceStartSu, - sourceEndSu: sourceStartSu + data.length, - displayStart: sourceStartSu, - displayEnd: sourceStartSu + data.length, - data, - splittable: true, - transform: { transformed: false, rawLengthSu: data.length, scalarSafe: true } - } -} - -describe('PtySourceReplayIndex', () => { - it('wraps the existing chunked buffer and preserves exact retained source ranges', () => { - const replay = new PtySourceReplayIndex(6) - replay.append(span('one', 'abcd', 0)) - replay.append(span('two', 'efgh', 4)) - - expect(replay.readLegacy()).toBe('cdefgh') - expect(replay.recoveryFrom(3)).toEqual({ - ok: true, - records: [ - expect.objectContaining({ sourceStartSu: 3, data: 'd' }), - expect.objectContaining({ sourceStartSu: 4, data: 'efgh' }) - ], - liveEndSourceSu: 8 - }) - expect(replay.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range' - }) - }) - - it('does not wall-clock dedupe identical replay payloads', () => { - const first = new PtySourceReplayIndex(16) - const second = new PtySourceReplayIndex(16) - first.append(span('first', 'same', 0)) - second.append({ ...span('second', 'same', 0), deliveryToken: 'token-2' }) - - expect(first.recoveryFrom(0)).toMatchObject({ ok: true }) - expect(second.recoveryFrom(0)).toMatchObject({ ok: true }) - }) - - it('preserves indivisible-form split metadata during recovery', () => { - const replay = new PtySourceReplayIndex(16) - replay.append({ - ...span('range', 'abcd', 0), - splittable: undefined, - indivisible: false - }) - - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: true, - records: [expect.objectContaining({ sourceStartSu: 2, data: 'cd' })] - }) - }) - - it('rejects replay checkpoints and retained heads inside a scalar', () => { - const replay = new PtySourceReplayIndex(4) - replay.append(span('range', 'a😀b', 0)) - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: false, - reason: 'checkpoint-inside-scalar' - }) - - const trimmed = new PtySourceReplayIndex(1) - trimmed.append(span('emoji', '😀', 0)) - expect(trimmed.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: 2 - }) - }) - - it('rejects a recovery checkpoint beyond the live source range', () => { - const replay = new PtySourceReplayIndex(16) - replay.append(span('range', 'abcd', 0)) - - expect(replay.recoveryFrom(5)).toMatchObject({ - ok: false, - reason: 'checkpoint-after-live-range', - liveEndSourceSu: 4 - }) - }) -}) diff --git a/src/relay/pty-source-replay-index.ts b/src/relay/pty-source-replay-index.ts deleted file mode 100644 index 1dfe5596eb1..00000000000 --- a/src/relay/pty-source-replay-index.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { ptySourceSpanIsSplittable, type PtySourceSpan } from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' - -export type PtySourceReplayRecord = Readonly< - Pick< - PtySourceSpan, - | 'spanId' - | 'sourceStartSu' - | 'sourceEndSu' - | 'displayStart' - | 'displayEnd' - | 'data' - | 'splittable' - | 'indivisible' - | 'transform' - > -> - -export type PtySourceReplayResult = - | Readonly<{ ok: true; records: readonly PtySourceReplayRecord[]; liveEndSourceSu: number }> - | Readonly<{ - ok: false - reason: - | 'checkpoint-before-retained-range' - | 'checkpoint-after-live-range' - | 'checkpoint-inside-indivisible-transform' - | 'checkpoint-inside-scalar' - retainedStartSourceSu: number - liveEndSourceSu: number - }> - -export class PtySourceReplayIndex { - private readonly output: RecentPtyOutputBuffer - private readonly records: PtySourceReplayRecord[] = [] - private displayEnd = 0 - private retainedStartSourceSu = 0 - private liveEndSourceSu = 0 - - constructor(limit: number) { - this.output = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) - } - - append(span: PtySourceSpan): void { - assertPtySourceSpan(span) - if ( - span.sourceStartSu !== this.liveEndSourceSu || - span.displayStart !== this.displayEnd || - span.displayEnd - span.displayStart !== span.data.length - ) { - throw new Error('PTY replay source/display ranges must be contiguous') - } - this.output.append(span.data) - this.records.push( - Object.freeze({ - spanId: span.spanId, - sourceStartSu: span.sourceStartSu, - sourceEndSu: span.sourceEndSu, - displayStart: span.displayStart, - displayEnd: span.displayEnd, - data: span.data, - splittable: span.splittable, - indivisible: span.indivisible, - transform: span.transform - }) - ) - this.displayEnd = span.displayEnd - this.liveEndSourceSu = span.sourceEndSu - this.evictUnmappedPrefix() - } - - readLegacy(): string { - return this.output.read() - } - - recoveryFrom(checkpointSourceEndSu: number): PtySourceReplayResult { - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - if (checkpointSourceEndSu < this.retainedStartSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if (checkpointSourceEndSu > this.liveEndSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-after-live-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const containing = this.records.find( - (record) => - record.sourceStartSu < checkpointSourceEndSu && checkpointSourceEndSu < record.sourceEndSu - ) - if ( - containing?.transform.transformed || - (containing && !ptySourceSpanIsSplittable(containing)) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-indivisible-transform', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if ( - containing && - splitsSurrogatePair(containing.data, checkpointSourceEndSu - containing.sourceStartSu) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-scalar', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const records = this.records - .filter((record) => record.sourceEndSu > checkpointSourceEndSu) - .map((record) => this.sliceRecord(record, checkpointSourceEndSu)) - return Object.freeze({ - ok: true, - records: Object.freeze(records), - liveEndSourceSu: this.liveEndSourceSu - }) - } - - private evictUnmappedPrefix(): void { - const retainedDisplayStart = this.displayEnd - this.output.read().length - while (this.records[0]?.displayEnd <= retainedDisplayStart) { - const removed = this.records.shift()! - this.retainedStartSourceSu = removed.sourceEndSu - } - const head = this.records[0] - if (!head || head.displayStart >= retainedDisplayStart) { - return - } - if (head.transform.transformed || !ptySourceSpanIsSplittable(head)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const displayOffset = retainedDisplayStart - head.displayStart - if (splitsSurrogatePair(head.data, displayOffset)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const sourceStartSu = head.sourceStartSu + displayOffset - this.records[0] = Object.freeze({ - ...head, - spanId: `${head.spanId}:retained:${sourceStartSu}`, - sourceStartSu, - displayStart: retainedDisplayStart, - data: head.data.slice(displayOffset), - transform: Object.freeze({ - ...head.transform, - rawLengthSu: head.sourceEndSu - sourceStartSu - }) - }) - this.retainedStartSourceSu = sourceStartSu - } - - private sliceRecord( - record: PtySourceReplayRecord, - checkpointSourceEndSu: number - ): PtySourceReplayRecord { - if (checkpointSourceEndSu <= record.sourceStartSu) { - return record - } - const offset = checkpointSourceEndSu - record.sourceStartSu - return Object.freeze({ - ...record, - spanId: `${record.spanId}:recovery:${checkpointSourceEndSu}`, - sourceStartSu: checkpointSourceEndSu, - displayStart: record.displayStart + offset, - data: record.data.slice(offset), - transform: Object.freeze({ - ...record.transform, - rawLengthSu: record.sourceEndSu - checkpointSourceEndSu - }) - }) - } -} - -function splitsSurrogatePair(data: string, offset: number): boolean { - const preceding = data.charCodeAt(offset - 1) - const following = data.charCodeAt(offset) - return ( - offset > 0 && - preceding >= 0xd800 && - preceding <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) -} From d5c5322442c391146908ad5de41bd3473086077b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:06 -0700 Subject: [PATCH 28/69] refactor(computer): remove orphaned helpers (#13511) --- src/main/computer/key-mapping.test.ts | 59 ------------ src/main/computer/key-mapping.ts | 128 -------------------------- src/main/computer/permissions.test.ts | 120 ------------------------ src/main/computer/permissions.ts | 58 ------------ 4 files changed, 365 deletions(-) delete mode 100644 src/main/computer/key-mapping.test.ts delete mode 100644 src/main/computer/key-mapping.ts delete mode 100644 src/main/computer/permissions.test.ts delete mode 100644 src/main/computer/permissions.ts diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts deleted file mode 100644 index 6b599cd483c..00000000000 --- a/src/main/computer/key-mapping.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseKey } from './key-mapping' - -describe('parseKey', () => { - it.each([ - ['a', { key: 'a', modifiers: [] }], - ['7', { key: '7', modifiers: [] }], - [';', { key: ';', modifiers: [] }], - ['+', { key: '+', modifiers: [] }], - ['Return', { key: 'Enter', modifiers: [] }], - ['Escape', { key: 'Escape', modifiers: [] }], - ['BackSpace', { key: 'Backspace', modifiers: [] }], - ['Tab', { key: 'Tab', modifiers: [] }], - ['space', { key: 'Space', modifiers: [] }], - ['Page_Up', { key: 'PageUp', modifiers: [] }], - ['Page_Down', { key: 'PageDown', modifiers: [] }], - ['Home', { key: 'Home', modifiers: [] }], - ['End', { key: 'End', modifiers: [] }], - ['Up', { key: 'ArrowUp', modifiers: [] }], - ['Down', { key: 'ArrowDown', modifiers: [] }], - ['Left', { key: 'ArrowLeft', modifiers: [] }], - ['Right', { key: 'ArrowRight', modifiers: [] }], - ['Delete', { key: 'Delete', modifiers: [] }], - ['Insert', { key: 'Insert', modifiers: [] }], - ['F1', { key: 'F1', modifiers: [] }], - ['F24', { key: 'F24', modifiers: [] }], - ['KP_0', { key: 'Numpad0', modifiers: [] }], - ['KP_9', { key: 'Numpad9', modifiers: [] }], - ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], - ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], - ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], - ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], - ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] - ])('maps %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it.each([ - ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], - ['control+a', { key: 'a', modifiers: ['Ctrl'] }], - ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], - ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], - ['cmd+a', { key: 'a', modifiers: ['Meta'] }], - ['command+a', { key: 'a', modifiers: ['Meta'] }], - ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], - ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], - ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], - ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], - ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] - ])('maps chord %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it('rejects unknown modifiers and key names', () => { - expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - }) -}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts deleted file mode 100644 index 4634f241268..00000000000 --- a/src/main/computer/key-mapping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RuntimeClientError } from './runtime-client-error' - -export type KeyChord = { - key: string - modifiers: string[] -} - -const MODIFIER_NAMES: Record = { - ctrl: 'Ctrl', - control: 'Ctrl', - shift: 'Shift', - alt: 'Alt', - meta: 'Meta', - super: 'Meta', - cmd: 'Meta', - command: 'Meta', - win: 'Meta' -} - -const PLATFORM_MODIFIER_NAMES: Record string> = { - cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), - commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') -} - -const KEY_NAMES: Record = { - Return: 'Enter', - Enter: 'Enter', - Escape: 'Escape', - Esc: 'Escape', - BackSpace: 'Backspace', - Backspace: 'Backspace', - Tab: 'Tab', - space: 'Space', - Space: 'Space', - Page_Up: 'PageUp', - Page_Down: 'PageDown', - Home: 'Home', - End: 'End', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Delete: 'Delete', - Insert: 'Insert', - KP_Add: 'NumpadAdd', - KP_Subtract: 'NumpadSubtract', - KP_Multiply: 'NumpadMultiply', - KP_Divide: 'NumpadDivide', - KP_Enter: 'NumpadEnter' -} - -export function parseKey(input: string): KeyChord { - const parts = splitKeyChord(input) - if (parts.length === 0) { - throw new RuntimeClientError('invalid_argument', 'key must not be empty') - } - - const keyPart = parts.at(-1)! - const modifiers = parts.slice(0, -1).map(parseModifier) - return { - key: parseBaseKey(keyPart), - modifiers: dedupeModifiers(modifiers) - } -} - -function splitKeyChord(input: string): string[] { - const trimmed = input.trim() - // Why: `+` is both the chord separator and a printable key users can press. - if (trimmed === '+') { - return ['+'] - } - if (trimmed.endsWith('+')) { - return [ - ...trimmed - .slice(0, -1) - .split('+') - .map((part) => part.trim()) - .filter(Boolean), - '+' - ] - } - return input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) -} - -function parseModifier(input: string): string { - const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] - if (platformModifier) { - return platformModifier() - } - const modifier = MODIFIER_NAMES[input.toLowerCase()] - if (!modifier) { - throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) - } - return modifier -} - -function parseBaseKey(input: string): string { - const mapped = KEY_NAMES[input] - if (mapped) { - return mapped - } - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { - return input - } - const keypadDigit = input.match(/^KP_([0-9])$/) - if (keypadDigit) { - return `Numpad${keypadDigit[1]}` - } - if (isPrintableAscii(input)) { - return input - } - throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) -} - -function isPrintableAscii(input: string): boolean { - if (input.length !== 1) { - return false - } - const code = input.charCodeAt(0) - return code >= 0x20 && code <= 0x7e -} - -function dedupeModifiers(modifiers: string[]): string[] { - return [...new Set(modifiers)] -} diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts deleted file mode 100644 index fd94f389b34..00000000000 --- a/src/main/computer/permissions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - notificationCtorMock, - notificationIsSupportedMock, - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock, - isTrustedAccessibilityClientMock -} = vi.hoisted(() => { - const notificationOnMock = vi.fn() - const notificationRemoveListenerMock = vi.fn() - const notificationShowMock = vi.fn() - const notificationCtorMock = vi.fn(function () { - return { - on: notificationOnMock, - removeListener: notificationRemoveListenerMock, - show: notificationShowMock - } - }) - return { - notificationCtorMock, - notificationIsSupportedMock: vi.fn(() => true), - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock: vi.fn(), - isTrustedAccessibilityClientMock: vi.fn(() => false) - } -}) - -vi.mock('electron', () => ({ - Notification: Object.assign(notificationCtorMock, { - isSupported: notificationIsSupportedMock - }), - shell: { - openExternal: shellOpenExternalMock - }, - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock - } -})) - -import { notifyPermissionRequired } from './permissions' - -describe('notifyPermissionRequired', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllTimers() - notificationCtorMock.mockClear() - notificationIsSupportedMock.mockReset() - notificationIsSupportedMock.mockReturnValue(true) - notificationOnMock.mockClear() - notificationRemoveListenerMock.mockClear() - notificationShowMock.mockClear() - shellOpenExternalMock.mockClear() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { - const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) - if (!call) { - throw new Error(`Notification ${eventName} handler not registered`) - } - return call[1] as (...args: unknown[]) => void - } - - it('clears the retained notification fallback timer when the notification closes', () => { - notifyPermissionRequired('Enable accessibility') - - expect(notificationShowMock).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(1) - - const closeHandler = getNotificationEventHandler('close') - closeHandler() - - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) - }) - - it('opens macOS Accessibility settings on click after releasing notification state', () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - notifyPermissionRequired('Enable accessibility') - - getNotificationEventHandler('click')() - - expect(shellOpenExternalMock).toHaveBeenCalledWith( - expect.stringContaining('Privacy_Accessibility') - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears retained notification state when native delivery fails', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - notifyPermissionRequired('Enable accessibility') - expect(vi.getTimerCount()).toBe(1) - - const failedHandler = getNotificationEventHandler('failed') - failedHandler({}, 'Application is not code signed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Accessibility permission notification failed') - ) - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts deleted file mode 100644 index a98f01c3dbc..00000000000 --- a/src/main/computer/permissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notification, shell } from 'electron' - -const ACCESSIBILITY_SETTINGS_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' - -const activePermissionNotifications = new Set() - -/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ -export function notifyPermissionRequired(instructions: string): void { - if (!Notification.isSupported()) { - return - } - - const notification = new Notification({ - title: 'Accessibility permission required', - body: instructions - }) - activePermissionNotifications.add(notification) - - let releaseTimer: ReturnType | null = null - let released = false - function release(): void { - if (released) { - return - } - released = true - activePermissionNotifications.delete(notification) - notification.removeListener('close', release) - notification.removeListener('click', onClick) - notification.removeListener('failed', onFailed) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - } - function onClick(): void { - release() - if (process.platform === 'darwin') { - void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) - } - } - function onFailed(_event: unknown, error?: string): void { - // Why: Electron 42 reports macOS UNNotification delivery failures here, - // most commonly when a local validation build is not code-signed. - console.warn( - `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` - ) - release() - } - notification.on('close', release) - notification.on('click', onClick) - notification.on('failed', onFailed) - releaseTimer = setTimeout(release, 5 * 60 * 1000) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - notification.show() -} From 837ea3feecd2bf3fa4bd7151a7ddaf8153adf6e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:09 -0700 Subject: [PATCH 29/69] refactor(onboarding): remove retired capability checklist (#13509) --- config/scripts/locale-ko-key-overrides.json | 9 - .../onboarding/AgentFeatureSetupStep.test.tsx | 30 ---- .../onboarding/AgentFeatureSetupStep.tsx | 64 ------- .../onboarding/FeatureSetupChecklist.tsx | 159 ------------------ src/renderer/src/i18n/locales/en.json | 14 -- src/renderer/src/i18n/locales/es.json | 14 -- src/renderer/src/i18n/locales/ja.json | 14 -- src/renderer/src/i18n/locales/ko.json | 14 -- src/renderer/src/i18n/locales/zh.json | 14 -- 9 files changed, 332 deletions(-) delete mode 100644 src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx delete mode 100644 src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx delete mode 100644 src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..7c0bf011de4 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1520,9 +1520,6 @@ "auto.components.notification.sound.options.86af8d938c": { "ko": "봉 소리" }, - "auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": { - "ko": "기능 활성화" - }, "auto.components.onboarding.AgentStep.1eee1c7bd8": { "ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요." }, @@ -1538,12 +1535,6 @@ "auto.components.onboarding.AgentStep.yoloPermissionsTooltip": { "ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "auto.components.onboarding.FeatureSetupChecklist.01426f3a23": { - "ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다." - }, - "auto.components.onboarding.FeatureSetupChecklist.c5292c409d": { - "ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다." - }, "auto.components.onboarding.IntegrationsStep.0b4a7d23ab": { "ko": "로그인 중" }, diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx deleted file mode 100644 index 832af22219c..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { AgentFeatureSetupStep } from './AgentFeatureSetupStep' - -describe('AgentFeatureSetupStep', () => { - it('renders the agent feature setup checklist', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Agent Browser Use') - expect(html).toContain('Computer Use') - expect(html).toContain('Agent Orchestration') - expect(html).toContain('Linear agent skill') - expect(html).toContain('Enable capabilities') - expect(html).toContain('role="checkbox"') - }) -}) diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx deleted file mode 100644 index 647600e02bb..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Loader2, Terminal } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { FeatureSetupChecklist } from './FeatureSetupChecklist' -import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal' -import { - hasSelectedOnboardingFeatureSetup, - type OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type AgentFeatureSetupStepProps = { - featureSetup: OnboardingFeatureSetupSelection - onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void - featureSetupCommand: string | null - featureSetupCommandSelection: OnboardingFeatureSetupSelection | null - setupBusyLabel: string | null - onStartFeatureSetup: () => void -} - -export function AgentFeatureSetupStep({ - featureSetup, - onFeatureSetupChange, - featureSetupCommand, - featureSetupCommandSelection, - setupBusyLabel, - onStartFeatureSetup -}: AgentFeatureSetupStepProps): React.JSX.Element { - const hasSelectedFeatures = hasSelectedOnboardingFeatureSetup(featureSetup) - const showSetupAction = !featureSetupCommand - - return ( - <> - - {showSetupAction ? ( -
- -
- ) : null} - {featureSetupCommand ? ( - - ) : null} - - ) -} diff --git a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx deleted file mode 100644 index 36fb6443ce6..00000000000 --- a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { ReactNode } from 'react' -import { Check, Globe2, MonitorCog, TicketCheck, Workflow } from 'lucide-react' -import { cn } from '@/lib/utils' -import type { - OnboardingFeatureSetupId, - OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type FeatureSetupChecklistProps = { - value: OnboardingFeatureSetupSelection - onChange: (value: OnboardingFeatureSetupSelection) => void -} - -type FeatureSetupRow = { - id: OnboardingFeatureSetupId - title: string - description: string - setupSummary: string - icon: ReactNode -} - -const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [ - { - id: 'browserUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628', - 'Agent Browser Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.01426f3a23', - 'Agents can navigate sites, inspect pages, and work through browser tasks.' - ) - }, - setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.', - icon: - }, - { - id: 'computerUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac', - 'Computer Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.c5292c409d', - 'Agents can inspect app windows and operate local apps when you ask.' - ) - }, - setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.', - icon: - }, - { - id: 'orchestration', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.399cf885c0', - 'Agent Orchestration' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.77f74946f5', - 'Agents can message each other, take tasks, and coordinate handoffs.' - ) - }, - setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.', - icon: - }, - { - id: 'linearTickets', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsTitle', - 'Linear agent skill' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsDescription', - 'Agents can use linked Linear tasks for richer ticket-aware handoffs.' - ) - }, - get setupSummary() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsSetupSummary', - 'Recommended for Linear workspaces; does not affect Linear connection setup.' - ) - }, - icon: - } -] - -export function FeatureSetupChecklist({ - value, - onChange -}: FeatureSetupChecklistProps): React.JSX.Element { - return ( -
-
- {FEATURE_SETUP_ROWS.map((row) => { - const selected = value[row.id] - return ( - - ) - })} -
-
- ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 41486e6220f..10c9c94fd6f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12005,17 +12005,6 @@ "yoloPermissionsInfo": "Agent permission info", "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents can message each other, take tasks, and coordinate handoffs.", - "399cf885c0": "Agent Orchestration", - "c5292c409d": "Agents can inspect app windows and operate local apps when you ask.", - "1ecfb490ac": "Computer Use", - "01426f3a23": "Agents can navigate sites, inspect pages, and work through browser tasks.", - "ea85d9e628": "Agent Browser Use", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Agents can use linked Linear tasks for richer ticket-aware handoffs.", - "linearTicketsSetupSummary": "Recommended for Linear workspaces; does not affect Linear connection setup." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Press Enter to run the command and confirm npx if asked. You can also set this up later in Settings.", "47fc6cc6dc": "Skill setup command", @@ -12165,9 +12154,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Enable capabilities" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Uses PowerShell 7+ when available, with Windows PowerShell as fallback.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9a80ab4e5f5..e572aebea50 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11493,17 +11493,6 @@ "yoloPermissionsInfo": "Información de permisos del agente", "yoloPermissionsTooltip": "Omite las comprobaciones de permisos de los agentes para reducir interrupciones" }, - "FeatureSetupChecklist": { - "77f74946f5": "Los agentes pueden enviarse mensajes entre sí, tomar tareas y coordinar handoffs.", - "399cf885c0": "Orquestación de agentes", - "c5292c409d": "Los agentes pueden inspeccionar ventanas de apps y operar apps locales cuando se lo pidas.", - "1ecfb490ac": "Uso de la computadora", - "01426f3a23": "Los agentes pueden navegar por sitios, inspeccionar páginas y trabajar en tareas del navegador.", - "ea85d9e628": "Uso del navegador por agentes", - "linearTicketsTitle": "Skill de agente de Linear", - "linearTicketsDescription": "Los agentes pueden usar tareas de Linear enlazadas para handoffs con más contexto de ticket.", - "linearTicketsSetupSummary": "Recomendado para espacios de trabajo de Linear; no afecta la configuración de conexión de Linear." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Pulsa Enter para ejecutar el comando y confirma npx si se te solicita. También puedes configurarlo más tarde en Configuración.", "47fc6cc6dc": "Comando de configuración de skills", @@ -11653,9 +11642,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Activar capacidades" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Usa PowerShell 7+ cuando está disponible, con Windows PowerShell como alternativa.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index dd37babf73d..94acf2379a5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11493,17 +11493,6 @@ "yoloPermissionsInfo": "Agent 権限情報", "yoloPermissionsTooltip": "Agent の権限チェックをスキップして割り込みを減らす" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agent は相互にメッセージを送信し、タスクを実行し、引き継ぎを調整できます。", - "399cf885c0": "Agent オーケストレーション", - "c5292c409d": "Agent は、要求に応じてアプリウィンドウを検査し、ローカルアプリを操作できます。", - "1ecfb490ac": "コンピュータ操作", - "01426f3a23": "Agent はサイトを移動し、ページを検査し、ブラウザのタスクを実行できます。", - "ea85d9e628": "Agent ブラウザの使用", - "linearTicketsTitle": "Linear Agent スキル", - "linearTicketsDescription": "Agent はリンクされた Linear タスクを使って、チケットを踏まえたより詳しい引き継ぎができます。", - "linearTicketsSetupSummary": "Linear ワークスペースに推奨。Linear 接続設定には影響しません。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter キーを押してコマンドを実行し、要求されたら npx を確認します。これは、後で [設定] で設定することもできます。", "47fc6cc6dc": "スキルセットアップコマンド", @@ -11653,9 +11642,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "CLI とスキルをインストール" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "利用可能な場合は PowerShell 7+ を使い、代わりに Windows PowerShell を使用します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b0b476820c5..a3ee8dd668d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11493,17 +11493,6 @@ "yoloPermissionsInfo": "Agent 권한 정보", "yoloPermissionsTooltip": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents는 서로 메시지를 보내고, 작업을 수행하고, 핸드오프를 조정할 수 있습니다.", - "399cf885c0": "Agent 오케스트레이션", - "c5292c409d": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다.", - "1ecfb490ac": "컴퓨터 사용", - "01426f3a23": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다.", - "ea85d9e628": "Agent 브라우저 사용", - "linearTicketsTitle": "Linear agent 스킬", - "linearTicketsDescription": "Agents는 연결된 Linear 작업을 사용해 티켓 맥락을 반영한 더 풍부한 인계를 할 수 있습니다.", - "linearTicketsSetupSummary": "Linear 워크스페이스에 권장됩니다. Linear 연결 설정에는 영향을 주지 않습니다." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter를 눌러 명령을 실행하고 요청 시 npx를 확인합니다. 나중에 설정에서 설정할 수도 있습니다.", "47fc6cc6dc": "스킬 설정 명령어", @@ -11653,9 +11642,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "기능 활성화" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "사용할 수 있으면 PowerShell 7+를 사용하고, 없으면 Windows PowerShell을 사용합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0498519afb9..30aba037f97 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11513,17 +11513,6 @@ "yoloPermissionsInfo": "智能体权限信息", "yoloPermissionsTooltip": "跳过智能体权限检查,减少打断" }, - "FeatureSetupChecklist": { - "77f74946f5": "智能体可互相通信、领取任务并协调交接。", - "399cf885c0": "智能体编排", - "c5292c409d": "按你的要求,智能体可查看应用窗口并操作本地应用。", - "1ecfb490ac": "计算机控制", - "01426f3a23": "智能体可浏览网站、检查页面并完成浏览器任务。", - "ea85d9e628": "智能体浏览器操控", - "linearTicketsTitle": "Linear 智能体技能", - "linearTicketsDescription": "智能体可使用已链接的 Linear 任务,完成更了解 ticket 背景的交接。", - "linearTicketsSetupSummary": "推荐用于 Linear 工作区;不会影响 Linear 连接设置。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "按 Enter 运行命令并在询问时确认 npx。您也可以稍后在“设置”中进行设置。", "47fc6cc6dc": "技能设置命令", @@ -11673,9 +11662,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "安装 CLI 和技能" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "在可用时使用 PowerShell 7+,否则回退到 Windows PowerShell。", From 9359edc2b01c7c38ce59766bf911da79600d5f08 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:13 -0700 Subject: [PATCH 30/69] refactor(workspaces): remove replaced host combobox (#13507) --- config/scripts/locale-ko-key-overrides.json | 6 - .../ProjectHostSetupCombobox.test.tsx | 157 ------------------ .../ProjectHostSetupCombobox.tsx | 105 ------------ src/renderer/src/i18n/locales/en.json | 4 - src/renderer/src/i18n/locales/es.json | 4 - src/renderer/src/i18n/locales/ja.json | 4 - src/renderer/src/i18n/locales/ko.json | 4 - src/renderer/src/i18n/locales/zh.json | 4 - 8 files changed, 288 deletions(-) delete mode 100644 src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx delete mode 100644 src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 7c0bf011de4..d3c2375bc45 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1502,12 +1502,6 @@ "auto.components.new.workspace.ProjectCombobox.search": { "ko": "프로젝트 검색..." }, - "auto.components.new.workspace.ProjectHostSetupCombobox.empty": { - "ko": "이 프로젝트에 준비된 호스트가 없습니다." - }, - "auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": { - "ko": "호스트 선택" - }, "auto.components.notification.sound.options.0acd3d384e": { "ko": "클랙" }, diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx deleted file mode 100644 index 06b1267f80b..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption -} from '@/lib/project-host-setup-options' -import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' - -vi.mock('@/components/ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/command', () => ({ - Command: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandItem: ({ - children, - disabled, - onSelect, - value - }: { - children: React.ReactNode - disabled?: boolean - onSelect?: (value: string) => void - value: string - }) => ( - - ) -})) - -let container: HTMLDivElement -let root: Root - -const readyOption: ProjectHostSetupOption = { - id: 'local-setup', - kind: 'ready', - projectId: 'project-1', - hostId: 'local', - repoId: 'local-repo', - label: 'Local Mac', - detail: 'Orca', - path: '/Users/alice/orca' -} - -const needsSetupOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:ssh:builder', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'ssh:builder', - label: 'Builder', - detail: 'Project location not set', - isAvailable: true, - attention: false -} - -const unavailableOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:runtime:old', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'runtime:old', - label: 'Old server', - detail: 'Update Orca on this host to set up projects', - isAvailable: false, - attention: false -} - -beforeEach(() => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() -}) - -function renderCombobox({ - onValueChange = vi.fn() -}: { - onValueChange?: (setupId: string) => void -} = {}): void { - act(() => { - root.render( - - ) - }) -} - -describe('ProjectHostSetupCombobox', () => { - it('routes ready setup rows through onValueChange', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - act(() => { - container - .querySelector('[data-command-value="local-setup"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(onValueChange).toHaveBeenCalledWith('local-setup') - }) - - it('hides hosts that need setup from the run target list', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - expect( - container.querySelector('[data-command-value="needs-setup:ssh:builder"]') - ).toBeNull() - expect(container.textContent).not.toContain('Project location not set') - expect(onValueChange).not.toHaveBeenCalled() - }) - - it('hides unavailable setup rows from the run target list', () => { - const onValueChange = vi.fn() - - act(() => { - root.render( - - ) - }) - - const unavailableButton = container.querySelector( - '[data-command-value="needs-setup:runtime:old"]' - ) - expect(unavailableButton).toBeNull() - expect(container.textContent).not.toContain('Update Orca on this host') - - expect(onValueChange).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx deleted file mode 100644 index 22df2d5f192..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react' -import { Check, ChevronsUpDown, Server } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' -import { translate } from '@/i18n/i18n' - -type ProjectHostSetupComboboxProps = { - options: readonly ProjectHostSetupOption[] - value: string | null - onValueChange: (setupId: string) => void -} - -export default function ProjectHostSetupCombobox({ - options, - value, - onValueChange -}: ProjectHostSetupComboboxProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) - const readyOptions = options.filter((option) => option.kind === 'ready') - const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null - - const handleSelect = React.useCallback( - (setupId: string): void => { - const option = options.find((candidate) => candidate.id === setupId) - if (!option) { - return - } - if (!readyOptions.some((candidate) => candidate.id === setupId)) { - return - } - onValueChange(setupId) - setOpen(false) - }, - [onValueChange, options, readyOptions] - ) - - return ( - - - - - - - - - {translate( - 'auto.components.new.workspace.ProjectHostSetupCombobox.empty', - 'No hosts are ready for this project.' - )} - - {readyOptions.map((option) => ( - handleSelect(option.id)} - className="items-center gap-2 px-3 py-2" - > - - -
-
{option.label}
-
- {option.path} -
-
-
- ))} -
-
-
-
- ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 10c9c94fd6f..f782865d3be 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12254,10 +12254,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hosts are ready for this project.", - "placeholder": "Choose host" - }, "ProjectCombobox": { "empty": "No projects match your search.", "addProject": "Add a new project", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e572aebea50..4447e45e0f7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11742,10 +11742,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hay hosts listos para este proyecto.", - "placeholder": "Elegir host" - }, "ProjectCombobox": { "empty": "Ningún proyecto coincide con tu búsqueda.", "addProject": "Add a new project", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 94acf2379a5..883ec789b60 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11742,10 +11742,6 @@ "jiraSelectBindFailed": "この Jira Issue をリンクできませんでした。該当するサイトを選択するか Jira に再接続してから、もう一度お試しください。", "emoji": "絵文字" }, - "ProjectHostSetupCombobox": { - "empty": "このプロジェクトで準備が整ったホストはありません。", - "placeholder": "ホストを選択" - }, "ProjectCombobox": { "empty": "検索条件に一致するプロジェクトはありません。", "addProject": "新規プロジェクトを追加", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a3ee8dd668d..4641da71258 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11742,10 +11742,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "이 프로젝트에 준비된 호스트가 없습니다.", - "placeholder": "호스트 선택" - }, "ProjectCombobox": { "empty": "검색과 일치하는 프로젝트가 없습니다.", "addProject": "새 프로젝트 추가", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 30aba037f97..4f1f86c0a46 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11762,10 +11762,6 @@ "jiraSelectBindFailed": "无法关联此 Jira 议题。请选择匹配的站点或重新连接 Jira,然后重试。", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "此项目没有已就绪的主机。", - "placeholder": "选择主机" - }, "ProjectCombobox": { "empty": "没有匹配搜索的项目。", "addProject": "添加新项目", From 1979f5a3e36d6bd1ed08b6d8cbd39d2a5f9aa2e3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:16 -0700 Subject: [PATCH 31/69] refactor(linear): remove replaced team combobox (#13506) --- config/scripts/locale-ko-key-overrides.json | 3 - docs/STYLEGUIDE.md | 4 +- .../components/ui/team-multi-combobox.test.ts | 61 ----- .../src/components/ui/team-multi-combobox.tsx | 220 ------------------ src/renderer/src/i18n/locales/en.json | 9 - src/renderer/src/i18n/locales/es.json | 9 - src/renderer/src/i18n/locales/ja.json | 9 - src/renderer/src/i18n/locales/ko.json | 9 - src/renderer/src/i18n/locales/zh.json | 9 - 9 files changed, 2 insertions(+), 331 deletions(-) delete mode 100644 src/renderer/src/components/ui/team-multi-combobox.test.ts delete mode 100644 src/renderer/src/components/ui/team-multi-combobox.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index d3c2375bc45..5afce08514a 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -4961,9 +4961,6 @@ "auto.components.ui.color.picker.faa855a582": { "ko": "Hex" }, - "auto.components.ui.team.multi.combobox.18ec58881e": { - "ko": "팀 검색..." - }, "auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": { "ko": "리뷰 필요" }, diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d795f74039a..c21496b40bc 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you' Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: -- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) - Use `cn()` for class merging. Pass user `className` last so callers can override. - Use `class-variance-authority` (CVA) for variants when there are multiple. @@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork: | Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | | Single choice from a known list | `Select` | Custom listbox | | Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one | | Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | | Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | diff --git a/src/renderer/src/components/ui/team-multi-combobox.test.ts b/src/renderer/src/components/ui/team-multi-combobox.test.ts deleted file mode 100644 index 9f231371c29..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { LinearTeam } from '../../../../shared/types' -import { - TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES, - filterTeamMultiComboboxTeams, - isTeamMultiComboboxQueryTooLarge -} from './team-multi-combobox' - -function team(overrides: Partial): LinearTeam { - return { - id: 'team-1', - key: 'ENG', - name: 'Engineering', - workspaceId: 'workspace-1', - workspaceName: 'Workspace', - ...overrides - } -} - -describe('filterTeamMultiComboboxTeams', () => { - it('returns all teams for empty queries', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, '')).toBe(teams) - expect(filterTeamMultiComboboxTeams(teams, ' ')).toBe(teams) - }) - - it('matches team names and keys case-insensitively', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, 'ops')).toEqual([teams[1]]) - expect(filterTeamMultiComboboxTeams(teams, 'ENGINEER')).toEqual([teams[0]]) - }) - - it('rejects oversized pasted queries before reading teams', () => { - const oversizedQuery = 'secret-team-combobox'.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES) - const teams = [ - { - id: 'secret', - workspaceId: 'workspace-1', - get name(): string { - throw new Error('oversized team combobox queries must not scan names') - }, - get key(): string { - throw new Error('oversized team combobox queries must not scan keys') - } - } - ] as LinearTeam[] - - expect(isTeamMultiComboboxQueryTooLarge(oversizedQuery)).toBe(true) - expect(filterTeamMultiComboboxTeams(teams, oversizedQuery)).toEqual([]) - }) - - it('rejects oversized whitespace before trimming', () => { - const teams = [team({ id: 'eng' })] - - expect( - filterTeamMultiComboboxTeams(teams, ' '.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES + 1)) - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/ui/team-multi-combobox.tsx b/src/renderer/src/components/ui/team-multi-combobox.tsx deleted file mode 100644 index e1795c4801a..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { LinearTeam } from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import { translate } from '@/i18n/i18n' - -type TeamMultiComboboxProps = { - teams: LinearTeam[] - selected: ReadonlySet - onChange: (next: ReadonlySet) => void - onSelectAll: () => void - triggerClassName?: string -} - -export const TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES = 2 * 1024 - -export function isTeamMultiComboboxQueryTooLarge( - query: string, - maxBytes = TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function filterTeamMultiComboboxTeams( - teams: readonly LinearTeam[], - query: string -): readonly LinearTeam[] { - if (isTeamMultiComboboxQueryTooLarge(query)) { - return [] - } - const trimmedQuery = query.trim() - if (!trimmedQuery) { - return teams - } - const lower = trimmedQuery.toLowerCase() - return teams.filter( - (team) => team.name.toLowerCase().includes(lower) || team.key.toLowerCase().includes(lower) - ) -} - -function renderTriggerLabel(teams: LinearTeam[], selected: ReadonlySet): React.JSX.Element { - if (teams.length === 0) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - if (selected.size === teams.length) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - const selectedTeams = teams.filter((t) => selected.has(t.id)) - const [first, second, ...rest] = selectedTeams - return ( - - {first ? {first.key} : null} - {second ? , {second.key} : null} - {rest.length > 0 ? +{rest.length} : null} - - ) -} - -export default function TeamMultiCombobox({ - teams, - selected, - onChange, - onSelectAll, - triggerClassName -}: TeamMultiComboboxProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [commandValue, setCommandValue] = useState('') - - const filteredTeams = useMemo(() => filterTeamMultiComboboxTeams(teams, query), [teams, query]) - - const allSelected = selected.size === teams.length && teams.length > 0 - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - if (!nextOpen) { - setQuery('') - } - }, []) - - const toggle = useCallback( - (teamId: string) => { - const next = new Set(selected) - if (next.has(teamId)) { - if (next.size <= 1) { - return - } - next.delete(teamId) - } else { - next.add(teamId) - } - onChange(next) - }, - [onChange, selected] - ) - - const handleSelectAll = useCallback(() => { - if (allSelected) { - const first = teams[0] - if (!first) { - return - } - onChange(new Set([first.id])) - return - } - onSelectAll() - }, [allSelected, onChange, onSelectAll, teams]) - - return ( - - - - - - - -
- -
- - - {translate( - 'auto.components.ui.team.multi.combobox.de83523bf9', - 'No teams match your search.' - )} - - {filteredTeams.map((team) => { - const isSelected = selected.has(team.id) - const isLastSelected = isSelected && selected.size <= 1 - return ( - toggle(team.id)} - disabled={isLastSelected} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - -
- - {team.name} - - {team.key} - - -
-
- ) - })} -
-
-
-
- ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f782865d3be..1c6022f232d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2709,15 +2709,6 @@ }, "sheet": { "1189e9fe0a": "Close" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "No teams match your search.", - "301f2a796e": "All teams", - "18ec58881e": "Search teams..." - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4447e45e0f7..4ed9cf2624b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "Cerrar" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "Ningún equipo coincide con tu búsqueda.", - "301f2a796e": "Todos los equipos", - "18ec58881e": "Buscar equipos..." - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 883ec789b60..1441a29bc12 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "閉じる" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "検索に一致するチームはありません。", - "301f2a796e": "全チーム", - "18ec58881e": "チームを検索…" - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4641da71258..2bcf63d3433 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "닫기" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "검색어와 일치하는 팀이 없습니다.", - "301f2a796e": "모든 팀", - "18ec58881e": "팀 검색..." - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4f1f86c0a46..76a8f99e696 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2616,15 +2616,6 @@ }, "sheet": { "1189e9fe0a": "关闭" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "没有团队符合您的搜索。", - "301f2a796e": "所有团队", - "18ec58881e": "搜索团队..." - } - } } }, "terminal": { From aa5e275026584d577ef97a9a50ac4917197018e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:20 -0700 Subject: [PATCH 32/69] refactor(sidebar): remove retired activity helpers (#13505) --- .../WorktreeActivityStatusIndicator.test.tsx | 41 ---- .../WorktreeActivityStatusIndicator.tsx | 21 -- .../WorktreeList.lineage-child-card.test.ts | 4 - .../visible-worktree-activity-inputs.test.ts | 37 +--- .../visible-worktree-activity-inputs.ts | 25 --- .../sidebar/worktree-section-activity.test.ts | 200 ------------------ .../sidebar/worktree-section-activity.ts | 116 ---------- 7 files changed, 1 insertion(+), 443 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx delete mode 100644 src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx delete mode 100644 src/renderer/src/components/sidebar/worktree-section-activity.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-section-activity.ts diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx deleted file mode 100644 index 633dce8a46e..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator' - -const mocks = vi.hoisted(() => ({ - status: 'inactive' as WorktreeStatus -})) - -vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: vi.fn(() => mocks.status) -})) - -function renderMarkup(status: WorktreeStatus): string { - mocks.status = status - return renderToStaticMarkup( - React.createElement(WorktreeActivityStatusIndicator, { worktreeId: 'wt-child' }) - ) -} - -describe('WorktreeActivityStatusIndicator', () => { - beforeEach(() => { - mocks.status = 'inactive' - }) - - it('renders the shared inactive status for slept worktrees', () => { - const markup = renderMarkup('inactive') - - expect(markup).toContain('Inactive') - expect(markup).toContain('bg-neutral-500/40') - expect(markup).not.toContain('bg-emerald-500') - }) - - it('renders the shared active status when the worktree is live', () => { - const markup = renderMarkup('active') - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - }) -}) diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx deleted file mode 100644 index be187336da8..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' -import { getWorktreeStatusLabel } from '@/lib/worktree-status' -import StatusIndicator from './StatusIndicator' -import { useWorktreeActivityStatus } from './use-worktree-activity-status' - -export function WorktreeActivityStatusIndicator({ - worktreeId, - className -}: { - worktreeId: string - className?: string -}): React.JSX.Element { - const status = useWorktreeActivityStatus(worktreeId) - - return ( - <> -