From 93bf26296fccf46aae911082399d97963a011dba Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:41:45 -0700 Subject: [PATCH 1/8] Extract lifecycle message reconciliation to standalone module (#6226) - Decouple heartbeat and worker_done logic from the main coordinator to allow re-use in the orchestration.check RPC method. - Reconcile unread lifecycle messages during orchestration.check so that manual coordinators can authoritatively process completions and heartbeats even if they bypass the main coordinator loop. - Add unit tests validating task completion, heartbeat recording, and safety checks against stale or unauthorized worker_done payloads. --- src/main/runtime/orchestration/coordinator.ts | 110 +---------- .../orchestration/lifecycle-reconciliation.ts | 144 ++++++++++++++ .../runtime/rpc/methods/orchestration.test.ts | 179 ++++++++++++++++++ src/main/runtime/rpc/methods/orchestration.ts | 7 + 4 files changed, 337 insertions(+), 103 deletions(-) create mode 100644 src/main/runtime/orchestration/lifecycle-reconciliation.ts diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index db17fc8d087..587dc4db7a8 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -2,6 +2,7 @@ import type { OrchestrationDb } from './db' import type { MessageRow, TaskRow, CoordinatorStatus } from './types' import { buildDispatchPreamble } from './preamble' +import { reconcileLifecycleMessage } from './lifecycle-reconciliation' export type CoordinatorRuntime = { sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise @@ -249,7 +250,7 @@ export class Coordinator { for (const msg of messages) { switch (msg.type) { case 'worker_done': - this.handleWorkerDone(msg) + this.handleLifecycleMessage(msg) break case 'escalation': this.handleEscalation(msg) @@ -258,7 +259,7 @@ export class Coordinator { this.handleDecisionGateMessage(msg) break case 'heartbeat': - this.handleHeartbeat(msg) + this.handleLifecycleMessage(msg) break case 'status': this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`) @@ -273,108 +274,11 @@ export class Coordinator { this.db.markAsRead(messages.map((m) => m.id)) } - // Why: attribute heartbeats to the specific dispatchId, not a - // (taskId, from_handle) lookup. A task that gets retried after a failed - // dispatch has multiple rows in dispatch_contexts — a late heartbeat from - // the previous (failed) assignee arriving while the new dispatch is active - // would falsely bump the new row's last_heartbeat_at if we resolved by - // "latest dispatch for this task" (§5.3.4). If the worker drops dispatchId - // from the payload, log-and-skip is the preferred failure mode: the stale - // detector will correctly flag the dispatch as hung because nothing - // refreshed last_heartbeat_at. - private handleHeartbeat(msg: MessageRow): void { - if (!msg.payload) { - this.opts.onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`) - return + private handleLifecycleMessage(msg: MessageRow): void { + const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog) + if (result.action === 'completed') { + this.state.completedTasks.push(result.taskId) } - let payload: { dispatchId?: unknown } = {} - try { - payload = JSON.parse(msg.payload) - } catch { - this.opts.onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) - return - } - const dispatchId = payload.dispatchId - if (typeof dispatchId !== 'string' || dispatchId.length === 0) { - this.opts.onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) - return - } - this.db.recordHeartbeat(dispatchId, msg.created_at) - } - - private handleWorkerDone(msg: MessageRow): void { - this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) - - let payload: { taskId?: unknown; dispatchId?: unknown; filesModified?: unknown } = {} - if (msg.payload) { - try { - payload = JSON.parse(msg.payload) - } catch { - this.opts.onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) - } - } - - const taskId = payload.taskId - if (typeof taskId !== 'string' || taskId.length === 0) { - this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`) - return - } - - const dispatchId = payload.dispatchId - if (typeof dispatchId !== 'string' || dispatchId.length === 0) { - this.opts.onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`) - return - } - - const task = this.db.getTask(taskId) - if (!task) { - this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`) - return - } - - // Why: taskId alone is not a completion authority; retried tasks can have - // stale worker_done messages racing the current active dispatch. - const dispatch = this.db.getDispatchContextById(dispatchId) - if (!dispatch) { - this.opts.onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`) - return - } - if (dispatch.task_id !== taskId) { - this.opts.onLog( - `Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}` - ) - return - } - if (dispatch.assignee_handle !== msg.from_handle) { - this.opts.onLog( - `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}` - ) - return - } - if (dispatch.status !== 'dispatched') { - this.opts.onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`) - return - } - if (this.db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') { - this.opts.onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`) - return - } - - const filesModified = - Array.isArray(payload.filesModified) && - payload.filesModified.every((file) => typeof file === 'string') - ? payload.filesModified - : [] - - const result = JSON.stringify({ - completedBy: msg.from_handle, - filesModified, - completedAt: new Date().toISOString() - }) - this.db.updateTaskStatus(taskId, 'completed', result) - this.state.completedTasks.push(taskId) - - this.opts.onLog(`Task ${taskId} completed`) } private handleEscalation(msg: MessageRow): void { diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts new file mode 100644 index 00000000000..9036c145534 --- /dev/null +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -0,0 +1,144 @@ +import type { OrchestrationDb } from './db' +import type { MessageRow } from './types' + +export type LifecycleReconciliationResult = + | { action: 'ignored' } + | { action: 'completed'; taskId: string; dispatchId: string } + | { action: 'heartbeat_recorded'; dispatchId: string } + +type LogFn = (msg: string) => void + +const noopLog: LogFn = () => {} + +function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record { + if (!msg.payload) { + return {} + } + + try { + const parsed: unknown = JSON.parse(msg.payload) + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } catch { + onInvalidJson() + return {} + } +} + +export function reconcileLifecycleMessage( + db: OrchestrationDb, + msg: MessageRow, + onLog: LogFn = noopLog +): LifecycleReconciliationResult { + switch (msg.type) { + case 'worker_done': + return reconcileWorkerDoneMessage(db, msg, onLog) + case 'heartbeat': + return reconcileHeartbeatMessage(db, msg, onLog) + case 'status': + case 'dispatch': + case 'merge_ready': + case 'escalation': + case 'handoff': + case 'decision_gate': + return { action: 'ignored' } + } +} + +function reconcileHeartbeatMessage( + db: OrchestrationDb, + msg: MessageRow, + onLog: LogFn +): LifecycleReconciliationResult { + if (!msg.payload) { + onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`) + return { action: 'ignored' } + } + + const payload = parseObjectPayload(msg, () => { + onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) + }) + const dispatchId = payload.dispatchId + if (typeof dispatchId !== 'string' || dispatchId.length === 0) { + onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) + return { action: 'ignored' } + } + + // Why: dispatchId-specific writes let the DB ignore late heartbeats for + // completed/failed retries without masking a newer hung dispatch. + db.recordHeartbeat(dispatchId, msg.created_at) + return { action: 'heartbeat_recorded', dispatchId } +} + +function reconcileWorkerDoneMessage( + db: OrchestrationDb, + msg: MessageRow, + onLog: LogFn +): LifecycleReconciliationResult { + onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) + + const payload = parseObjectPayload(msg, () => { + onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) + }) + + const taskId = payload.taskId + if (typeof taskId !== 'string' || taskId.length === 0) { + onLog(`Warning: worker_done without taskId from ${msg.from_handle}`) + return { action: 'ignored' } + } + + const dispatchId = payload.dispatchId + if (typeof dispatchId !== 'string' || dispatchId.length === 0) { + onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`) + return { action: 'ignored' } + } + + const task = db.getTask(taskId) + if (!task) { + onLog(`Warning: worker_done for unknown task ${taskId}`) + return { action: 'ignored' } + } + + // Why: taskId alone is not a completion authority; retried tasks can have + // stale worker_done messages racing the current active dispatch. + const dispatch = db.getDispatchContextById(dispatchId) + if (!dispatch) { + onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`) + return { action: 'ignored' } + } + if (dispatch.task_id !== taskId) { + onLog( + `Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}` + ) + return { action: 'ignored' } + } + if (dispatch.assignee_handle !== msg.from_handle) { + onLog( + `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}` + ) + return { action: 'ignored' } + } + if (dispatch.status !== 'dispatched') { + onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`) + return { action: 'ignored' } + } + if (db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') { + onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`) + return { action: 'ignored' } + } + + const filesModified = + Array.isArray(payload.filesModified) && + payload.filesModified.every((file) => typeof file === 'string') + ? payload.filesModified + : [] + + const result = JSON.stringify({ + completedBy: msg.from_handle, + filesModified, + completedAt: new Date().toISOString() + }) + db.updateTaskStatus(taskId, 'completed', result) + + onLog(`Task ${taskId} completed`) + return { action: 'completed', taskId, dispatchId } +} diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 190a3ec2b90..368b77b2072 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -377,6 +377,39 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.check', () => { + function createDispatchedTask(assigneeHandle = 'term_worker') { + const task = db.createTask({ spec: 'manual check work' }) + const dispatch = db.createDispatchContext(task.id, assigneeHandle) + return { task, dispatch } + } + + function insertWorkerDone(params: { + from?: string + to?: string + taskId?: string + dispatchId?: string + filesModified?: string[] + }): void { + const payload: Record = {} + if (params.taskId !== undefined) { + payload.taskId = params.taskId + } + if (params.dispatchId !== undefined) { + payload.dispatchId = params.dispatchId + } + if (params.filesModified !== undefined) { + payload.filesModified = params.filesModified + } + + db.insertMessage({ + from: params.from ?? 'term_worker', + to: params.to ?? 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify(payload) + }) + } + it('returns unread messages for a terminal', async () => { setup() db.insertMessage({ from: 'a', to: 'b', subject: 'one' }) @@ -416,6 +449,152 @@ describe('orchestration RPC methods', () => { expect(result.count).toBe(1) }) + it('reconciles worker_done returned by a waiting manual check', async () => { + setup() + const { task, dispatch } = createDispatchedTask() + vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { + insertWorkerDone({ + taskId: task.id, + dispatchId: dispatch.id, + filesModified: ['src/file.ts'] + }) + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + wait: true, + timeoutMs: 100, + types: 'worker_done,escalation,decision_gate' + })) as { count: number; messages: { type: string }[] } + + expect(result.count).toBe(1) + expect(result.messages[0].type).toBe('worker_done') + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(db.getUnreadMessages('term_coord')).toHaveLength(0) + const taskList = (await call('orchestration.taskList', {})) as { + tasks: { + id: string + status: string + assignee_handle?: string | null + dispatch_id?: string | null + }[] + } + const listedTask = taskList.tasks.find((t) => t.id === task.id) + expect(listedTask?.status).toBe('completed') + expect(listedTask).not.toHaveProperty('assignee_handle') + expect(listedTask).not.toHaveProperty('dispatch_id') + const shownDispatch = (await call('orchestration.dispatchShow', { + task: task.id + })) as { dispatch: { status: string } | null } + expect(shownDispatch.dispatch?.status).toBe('completed') + + const completedAt = db.getTask(task.id)?.completed_at + const taskResult = db.getTask(task.id)?.result + const repeated = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number } + expect(repeated.count).toBe(0) + expect(db.getTask(task.id)?.completed_at).toBe(completedAt) + expect(db.getTask(task.id)?.result).toBe(taskResult) + }) + + it('keeps check --all read-only for lifecycle messages', async () => { + setup() + const { task, dispatch } = createDispatchedTask() + insertWorkerDone({ taskId: task.id, dispatchId: dispatch.id }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + all: true, + types: 'worker_done' + })) as { count: number } + + expect(result.count).toBe(1) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(db.getUnreadMessages('term_coord', ['worker_done'])).toHaveLength(1) + }) + + it('does not complete worker_done missing taskId or dispatchId', async () => { + setup() + const { task, dispatch } = createDispatchedTask() + insertWorkerDone({ dispatchId: dispatch.id }) + insertWorkerDone({ taskId: task.id }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number } + + expect(result.count).toBe(2) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + }) + + it('does not complete worker_done from a terminal that does not own the dispatch', async () => { + setup() + const { task, dispatch } = createDispatchedTask('term_owner') + insertWorkerDone({ + from: 'term_intruder', + taskId: task.id, + dispatchId: dispatch.id + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number } + + expect(result.count).toBe(1) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + }) + + it('does not complete worker_done for a stale inactive dispatch', async () => { + setup() + const task = db.createTask({ spec: 'retry-sensitive work' }) + const staleDispatch = db.createDispatchContext(task.id, 'term_old') + db.failDispatch(staleDispatch.id, 'retry elsewhere') + const activeDispatch = db.createDispatchContext(task.id, 'term_current') + insertWorkerDone({ + from: 'term_old', + taskId: task.id, + dispatchId: staleDispatch.id + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number } + + expect(result.count).toBe(1) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(staleDispatch.id)?.status).toBe('failed') + expect(db.getDispatchContextById(activeDispatch.id)?.status).toBe('dispatched') + }) + + it('records heartbeat returned by unread manual check', async () => { + setup() + const { task, dispatch } = createDispatchedTask() + const msg = db.insertMessage({ + from: 'term_worker', + to: 'term_coord', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'heartbeat' + })) as { count: number } + + expect(result.count).toBe(1) + expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBe(msg.created_at) + }) + it('rejects invalid type filters', async () => { setup() await expect( diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index bd5f950815b..64a0b9969a7 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -6,6 +6,7 @@ import type { MessageType, MessagePriority, TaskStatus } from '../../orchestrati import { buildDispatchPreamble } from '../../orchestration/preamble' import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' const MESSAGE_TYPES: MessageType[] = [ @@ -265,6 +266,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ : db.getAllMessagesForHandle(handle, undefined, typeFilter) if (showUnread && messages.length > 0) { + // Why: manual coordinators can consume lifecycle messages before + // the coordinator loop sees them, but unread `check` is still an + // authoritative read path for worker_done/heartbeat. + for (const message of messages) { + reconcileLifecycleMessage(db, message) + } db.markAsRead(messages.map((m) => m.id)) } From 5fc1fb3c25531fa3cf5a3ca182234e86427a92a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:59:06 -0700 Subject: [PATCH 2/8] Coalesce renderer process-gone crash bursts (#6202) Co-authored-by: Neil --- .../process-gone-classification.test.ts | 50 +++++++++++++++++++ .../process-gone-classification.ts | 5 +- .../process-gone-dedupe.test.ts | 36 ++++++++++++- .../crash-reporting/process-gone-dedupe.ts | 8 ++- src/main/index.ts | 2 +- 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index e143729fb29..50740f22add 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -201,6 +201,16 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'video_capture.mojom.VideoCaptureService', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none' + }) + ).toBe(false) }) it('still records unknown child process crashes', () => { @@ -236,6 +246,16 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(true) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'video_capture.mojom.VideoCaptureService', + reason: 'launch-failed', + exitCode: -1, + expectedTeardown: 'none' + }) + ).toBe(true) }) it('still records renderer kills from the recent Linux crash-report cluster', () => { @@ -274,6 +294,36 @@ describe('shouldRecordProcessGoneCrash', () => { ).toBe(true) }) + it('records Windows renderer killed exit 1 outside expected lifecycle teardown only', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none' + }) + ).toBe(true) + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'renderer-reload' + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'app-shutdown' + }) + ).toBe(false) + }) + it('records non-SIGTERM child-process killed events during renderer-only reloads', () => { expect( shouldRecordProcessGoneCrash({ diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index 577f40e2ab9..c9042ede715 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -5,7 +5,10 @@ const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004]) const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu']) const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([ 'audio.mojom.AudioService', - 'network.mojom.NetworkService' + 'network.mojom.NetworkService', + // Why: Windows media/screen capture can churn this Chromium utility without + // taking down Orca; prompting users for those child exits is noise. + 'video_capture.mojom.VideoCaptureService' ]) const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed']) const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure', 'launch-failed']) diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index 521e543d0fa..6beebfd4926 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -4,7 +4,7 @@ import { getProcessGoneDedupeKey, ProcessGoneDedupe } from './process-gone-dedup describe('ProcessGoneDedupe', () => { it('suppresses duplicate keys inside the dedupe window', () => { const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) - const key = getProcessGoneDedupeKey('GPU', 'crashed', 5) + const key = getProcessGoneDedupeKey('child', 'GPU', 'crashed', 5) expect(dedupe.shouldRecord(key, 1_000)).toBe(true) expect(dedupe.shouldRecord(key, 2_999)).toBe(false) @@ -32,4 +32,38 @@ describe('ProcessGoneDedupe', () => { expect(dedupe.size).toBe(3) expect(dedupe.shouldRecord('a', 1_004)).toBe(true) }) + + it('coalesces renderer crash bursts across crash reasons and exit codes', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', -36861), 1_000) + ).toBe(true) + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904), 1_284) + ).toBe(false) + expect( + dedupe.shouldRecord( + getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18), + 1_898 + ) + ).toBe(false) + expect( + dedupe.shouldRecord( + getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18), + 3_000 + ) + ).toBe(true) + }) + + it('keeps child process crash tuples distinct inside renderer burst windows', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'crashed', 1), 1_000) + ).toBe(true) + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'killed', 1), 1_100) + ).toBe(true) + }) }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index f2caf0f3d1f..eb746a18e57 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -54,11 +54,17 @@ export class ProcessGoneDedupe { } export function getProcessGoneDedupeKey( + source: 'renderer' | 'child', processType: string, reason: string, exitCode: number | null ): string { - return `${processType}:${reason}:${exitCode ?? 'null'}` + // Why: one renderer death can surface as crashed/oom/launch-failed in a + // burst. Coalesce that prompt noise while keeping child identities precise. + if (source === 'renderer') { + return `${source}:${processType}` + } + return `${source}:${processType}:${reason}:${exitCode ?? 'null'}` } export const processGoneDedupe = new ProcessGoneDedupe() diff --git a/src/main/index.ts b/src/main/index.ts index d684845923a..f879520bed3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -920,7 +920,7 @@ function recordProcessGoneCrash( ) return } - const key = getProcessGoneDedupeKey(processType, reason, exitCode) + const key = getProcessGoneDedupeKey(source, processType, reason, exitCode) if (!processGoneDedupe.shouldRecord(key)) { return } From b21f8cd230d1608085605e56a421ea0cc24fd6b7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:00:42 -0700 Subject: [PATCH 3/8] Scope terminal handle links by runtime and focus mounted tasks directly (#6225) * Match terminal handle links using their owning runtime environment ID to prevent false-positive activation when terminal handles collide across local and remote runtimes. * Focus task-associated terminals directly in the renderer when they are already mounted to avoid redundant runtime RPC focus calls. --- .../terminal-handle-links.test.ts | 239 +++++++++++++++++- .../terminal-pane/terminal-handle-links.ts | 44 +++- .../terminal-orchestration-task-links.ts | 6 +- 3 files changed, 277 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index ddb842b66f1..b4d12bfb12f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -294,16 +294,80 @@ describe('createTerminalHandleLinkProvider', () => { }) }) + it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => { + markRuntimeEnvironmentCompatible('env-1') + mocks.storeState.tabsByWorktree = { + 'wt-local': [ + { + id: 'tab-local', + worktreeId: 'wt-local', + ptyId: 'term_worker', + title: 'Local worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + 'wt-env-2': [ + { + id: 'tab-env-2', + worktreeId: 'wt-env-2', + ptyId: 'remote:env-2@@term_worker', + title: 'Other remote worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { + 'tab-local': ['term_worker'], + 'tab-env-2': ['remote:env-2@@term_worker'] + } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-env-2': { root: null, activeLeafId: null, expandedLeafId: null } + } + window.api.runtimeEnvironments.call = vi.fn().mockResolvedValue({ + ok: true, + result: { focus: { handle: 'term_worker', tabId: 'tab-env-1', worktreeId: 'wt-env-1' } } + }) + const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-local') + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-env-2') + expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.focus', + params: { terminal: 'term_worker' }, + timeoutMs: undefined + }) + }) + it('provides wrapped task links and focuses their dispatched terminal through runtime', async () => { const rows = [makeBufferLine('Task: task_work'), makeBufferLine('er', { isWrapped: true })] const links = await collectLinks(rows, 1) + const preventDefault = vi.fn() expect(links.map((link) => link.text)).toEqual(['task_worker']) links[0].activate( { metaKey: true, ctrlKey: false, - preventDefault: vi.fn() + preventDefault } as unknown as MouseEvent, links[0].text ) @@ -318,6 +382,179 @@ describe('createTerminalHandleLinkProvider', () => { method: 'terminal.focus', params: { terminal: 'term_worker' } }) + expect(preventDefault).toHaveBeenCalled() + }) + + it('focuses resolved task terminals directly when they are already mounted', async () => { + mocks.storeState.tabsByWorktree = { + 'wt-1': [ + { + id: 'tab-1', + worktreeId: 'wt-1', + ptyId: 'term_worker', + title: 'Worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { 'tab-1': ['term_worker'] } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } + } + const links = await collectLinks([makeBufferLine('Task: task_worker')]) + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.runtime.call).toHaveBeenCalledTimes(1) + expect(window.api.runtime.call).toHaveBeenCalledWith({ + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' } + }) + expect(mocks.storeState.setActiveWorktree).toHaveBeenCalledWith('wt-1') + expect(mocks.storeState.markWorktreeVisited).toHaveBeenCalledWith('wt-1') + expect(mocks.storeState.setActiveView).toHaveBeenCalledWith('terminal') + expect(mocks.storeState.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(mocks.storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1') + expect(mocks.storeState.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + }) + + it('focuses mounted task terminals only when they belong to the owning runtime', async () => { + markRuntimeEnvironmentCompatible('env-1') + mocks.storeState.tabsByWorktree = { + 'wt-remote': [ + { + id: 'tab-remote', + worktreeId: 'wt-remote', + ptyId: 'remote:env-1@@term_remote', + title: 'Remote worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { 'tab-remote': ['remote:env-1@@term_remote'] } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-remote': { root: null, activeLeafId: null, expandedLeafId: null } + } + window.api.runtimeEnvironments.call = vi.fn().mockResolvedValue({ + ok: true, + result: { dispatch: { assignee_handle: 'term_remote' } } + }) + const links = await collectLinks([makeBufferLine('Task: task_remote')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(window.api.runtimeEnvironments.call).toHaveBeenCalledTimes(1) + expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'orchestration.dispatchShow', + params: { task: 'task_remote' }, + timeoutMs: undefined + }) + expect(mocks.storeState.setActiveWorktree).toHaveBeenCalledWith('wt-remote') + expect(mocks.storeState.setActiveTab).toHaveBeenCalledWith('tab-remote') + }) + + it('falls back to the owning runtime when task handles collide across runtimes', async () => { + markRuntimeEnvironmentCompatible('env-1') + mocks.storeState.tabsByWorktree = { + 'wt-local': [ + { + id: 'tab-local', + worktreeId: 'wt-local', + ptyId: 'term_worker', + title: 'Local worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + 'wt-env-2': [ + { + id: 'tab-env-2', + worktreeId: 'wt-env-2', + ptyId: 'remote:env-2@@term_worker', + title: 'Other remote worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { + 'tab-local': ['term_worker'], + 'tab-env-2': ['remote:env-2@@term_worker'] + } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-env-2': { root: null, activeLeafId: null, expandedLeafId: null } + } + window.api.runtimeEnvironments.call = vi.fn().mockImplementation(({ method }) => { + if (method === 'orchestration.dispatchShow') { + return Promise.resolve({ + ok: true, + result: { dispatch: { assignee_handle: 'term_worker' } } + }) + } + return Promise.resolve({ + ok: true, + result: { focus: { handle: 'term_worker', tabId: 'tab-env-1', worktreeId: 'wt-env-1' } } + }) + }) + const links = await collectLinks([makeBufferLine('Task: task_worker')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-local') + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-env-2') + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' }, + timeoutMs: undefined + }) + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'terminal.focus', + params: { terminal: 'term_worker' }, + timeoutMs: undefined + }) }) it('returns task and terminal links in line order', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index d692e68b897..c11d595f611 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -4,7 +4,7 @@ import { useAppStore } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' import { extractOrchestrationTaskLinks, @@ -96,13 +96,14 @@ function findPrefixedTokenEnd(lineText: string, startIndex: number): number { export function findTerminalHandleTarget( handle: string, - state: TerminalHandleFocusState + state: TerminalHandleFocusState, + runtimeEnvironmentId?: string | null ): TerminalHandleTarget | null { for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { for (const tab of tabs) { const layout = state.terminalLayoutsByTabId[tab.id] for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { - if (ptyIdMatchesTerminalHandle(ptyId, handle)) { + if (ptyIdMatchesTerminalHandle(ptyId, handle, runtimeEnvironmentId)) { return { worktreeId, tabId: tab.id, leafId } } } @@ -110,7 +111,9 @@ export function findTerminalHandleTarget( const tabPtyIds = [tab.ptyId, ...(state.ptyIdsByTabId[tab.id] ?? [])].filter( (ptyId): ptyId is string => Boolean(ptyId) ) - if (tabPtyIds.some((ptyId) => ptyIdMatchesTerminalHandle(ptyId, handle))) { + if ( + tabPtyIds.some((ptyId) => ptyIdMatchesTerminalHandle(ptyId, handle, runtimeEnvironmentId)) + ) { return { worktreeId, tabId: tab.id, leafId: layout?.activeLeafId ?? null } } } @@ -118,9 +121,12 @@ export function findTerminalHandleTarget( return null } -export function focusRendererTerminalHandle(handle: string): boolean { +export function focusRendererTerminalHandle( + handle: string, + runtimeEnvironmentId?: string | null +): boolean { const store = useAppStore.getState() - const target = findTerminalHandleTarget(handle, store) + const target = findTerminalHandleTarget(handle, store, runtimeEnvironmentId) if (!target) { return false } @@ -211,21 +217,39 @@ async function activateParsedLink( ): Promise { try { if (parsed.kind === 'terminal') { - if (!focusRendererTerminalHandle(parsed.text)) { + if (!focusRendererTerminalHandle(parsed.text, runtimeEnvironmentId)) { await focusRuntimeTerminalHandle(parsed.text, runtimeEnvironmentId) } return } // Why: a task can be retried onto a new dispatch; runtime DB is the // authority for the latest terminal assigned to a stable task ID. - await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId) + await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId, (handle) => + focusRendererTerminalHandle(handle, runtimeEnvironmentId) + ) } catch (error: unknown) { console.warn('[terminal-handle-link] focus failed:', error) } } -function ptyIdMatchesTerminalHandle(ptyId: string, handle: string): boolean { - return ptyId === handle || getRemoteRuntimeTerminalHandle(ptyId) === handle +function ptyIdMatchesTerminalHandle( + ptyId: string, + handle: string, + runtimeEnvironmentId?: string | null +): boolean { + const targetEnvironmentId = runtimeEnvironmentId?.trim() || null + if (ptyId === handle) { + return targetEnvironmentId === null + } + const remotePty = parseRemoteRuntimePtyId(ptyId) + if (!remotePty || remotePty.handle !== handle) { + return false + } + const ptyEnvironmentId = remotePty.environmentId?.trim() || null + if (runtimeEnvironmentId === undefined) { + return true + } + return ptyEnvironmentId === targetEnvironmentId } function getTerminalHandleFocusHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts index 99acf41f12c..fdd90fb4019 100644 --- a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts @@ -46,7 +46,8 @@ export function extractOrchestrationTaskLinks(lineText: string): ParsedOrchestra export async function focusRuntimeOrchestrationTask( taskId: string, - runtimeEnvironmentId: string | null + runtimeEnvironmentId: string | null, + focusRendererTerminal?: (handle: string) => boolean ): Promise { const environmentId = runtimeEnvironmentId?.trim() const target = environmentId @@ -59,6 +60,9 @@ export async function focusRuntimeOrchestrationTask( if (!terminal) { throw new Error(`No dispatched terminal for orchestration task ${taskId}`) } + if (focusRendererTerminal?.(terminal)) { + return + } // Why: task IDs are stable orchestration DB records, but terminal.focus owns // the app-side navigation contract for local and SSH runtime terminals. await callRuntimeRpc(target, 'terminal.focus', { terminal }) From 23d14aaa18885e20064d6199d6ebe0df238ab729 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Tue, 23 Jun 2026 21:03:13 -0700 Subject: [PATCH 4/8] fix: reword folder grouping prompt away from "monorepo" Reword the nested repository import dialog so it asks users whether to group discovered repositories rather than asserting they are a monorepo. Also keeps locale catalogs aligned with live nested-import strings and updates Electron folder-import coverage for the new affirmative action label. --- .../sidebar/AddRepoDialogStepContent.test.tsx | 16 ++++++------- .../sidebar/AddRepoNestedImportStep.test.tsx | 24 +++++++++---------- .../sidebar/AddRepoNestedImportStep.tsx | 6 ++--- src/renderer/src/i18n/locales/en.json | 13 ++++------ src/renderer/src/i18n/locales/es.json | 13 ++++------ src/renderer/src/i18n/locales/ja.json | 13 ++++------ src/renderer/src/i18n/locales/ko.json | 13 ++++------ src/renderer/src/i18n/locales/zh.json | 13 ++++------ .../e2e/folder-setup-shallow-priority.spec.ts | 14 +++++------ tests/e2e/folder-setup.spec.ts | 12 +++++----- 10 files changed, 55 insertions(+), 82 deletions(-) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 63dc178eab7..26ec195d440 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -100,22 +100,22 @@ function renderNestedStep(repoCount: number): string { } describe('AddRepoDialogStepContent nested imports', () => { - it('asks the monorepo question when no repos exist yet', () => { + it('asks the grouping question when no repos exist yet', () => { const html = renderNestedStep(0) - expect(html).toContain('Is this a monorepo?') - expect(html).toContain('aria-label="Monorepo name"') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Group these repositories?') + expect(html).toContain('aria-label="Group name"') + expect(html).toContain('Yes, import as group') expect(html).toContain('No, import separately') expect(html).not.toContain('>Import') }) - it('shows the same monorepo import controls after a repo already exists', () => { + it('shows the same grouping import controls after a repo already exists', () => { const html = renderNestedStep(1) - expect(html).toContain('Is this a monorepo?') - expect(html).toContain('aria-label="Monorepo name"') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Group these repositories?') + expect(html).toContain('aria-label="Group name"') + expect(html).toContain('Yes, import as group') expect(html).toContain('No, import separately') expect(html).not.toContain('>Import') }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index 13be2021c7d..ba9022ca742 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -72,19 +72,19 @@ describe('AddRepoNestedImportStep', () => { container = null }) - it('asks whether the selected folder is a monorepo', () => { + it('asks whether the selected folder should be grouped', () => { const html = renderStepMarkup() expect(html).toContain('Import repositories from folder') expect(html).toContain('Found 3 repositories in') expect(html).toContain('/workspace/platform') - expect(html).toContain('aria-label="Monorepo name"') + expect(html).toContain('aria-label="Group name"') expect(html).not.toContain('What is a') - expect(html).toContain('Is this a monorepo?') + expect(html).toContain('Group these repositories?') expect(html).toContain('Choose this if these projects belong together') expect(html).toContain('Orca will group them and let you work from the parent folder') expect(html).toContain('No, import separately') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Yes, import as group') expect(html).toContain('payments/api') expect(html).toContain('billing/api') expect(html).not.toContain('disabled=""') @@ -95,14 +95,14 @@ describe('AddRepoNestedImportStep', () => { it('disables both import actions while scanning', () => { const html = renderStepMarkup({ scanInProgress: true }) - expect(html).toContain('Is this a monorepo?') + expect(html).toContain('Group these repositories?') expect(html).toContain('No, import separately') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Yes, import as group') expect(html).toMatch(/]*disabled=""[^>]*>No, import separately<\/button>/) - expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as monorepo<\/button>/) + expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as group<\/button>/) }) - it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => { + it('maps the group choice to grouped import and the separate choice to separate import', () => { const onImport = vi.fn() const host = document.createElement('div') container = host @@ -130,7 +130,7 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Yes, import as monorepo').click() + findButton(host, 'Yes, import as group').click() findButton(host, 'No, import separately').click() }) @@ -174,13 +174,11 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Yes, import as monorepo').click() + findButton(host, 'Yes, import as group').click() }) expect(onImport).toHaveBeenCalledWith('group') - expect( - findButton(host, 'Yes, import as monorepo').querySelector('.animate-spin') - ).not.toBeNull() + expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index 4de4a9b469f..ac7db3b2958 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -105,13 +105,13 @@ export function AddRepoNestedImportStep({

{translate( 'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69', - 'Is this a monorepo?' + 'Group these repositories?' )}

{translate( 'auto.components.sidebar.AddRepoNestedImportStep.d75170194e', - "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder." + 'Choose this if these projects belong together — a monorepo, or just a set of related repos. Orca will group them and let you work from the parent folder.' )}

@@ -156,7 +156,7 @@ export function AddRepoNestedImportStep({ {showGroupSpinner ? : null} {translate( 'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e', - 'Import as group' + 'Yes, import as group' )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e6ed96882bf..c05c5058070 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3384,22 +3384,17 @@ "496f68cf8c": "Scanning repositories. Click to stop.", "a32bef9516": "Stop scanning", "2f8298f3c3": "Stop scan", - "c157f31a95": "Import as group", - "40199ef7b3": "Group name", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "787412361a": "What is a group name?", "5f857ba8e6": "in", "4df0d08cc5": "Found", "8db50afe1a": "Import repositories from folder", "5b2e6fe3c8": "Import separately", "cf9d382ca1": "Import", "220dd32d83": "Scanning...", - "fb33359f69": "Is this a monorepo?", - "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.", - "39d51212cc": "Monorepo name", - "e907ec8935": "What is a monorepo name?", + "fb33359f69": "Group these repositories?", + "d75170194e": "Choose this if these projects belong together — a monorepo, or just a set of related repos. Orca will group them and let you work from the parent folder.", + "39d51212cc": "Group name", "aa0247680d": "No, import separately", - "a0bc4d1f8e": "Yes, import as monorepo", + "a0bc4d1f8e": "Yes, import as group", "8401a7a0d0": "1 repository", "d4f1df62ef": "{{value0}} repositories", "b4263a2ac4": "Found {{value0}} in {{value1}}.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4ec8e276dd3..1aad6ed2381 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3378,26 +3378,21 @@ "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", "a32bef9516": "Dejar de escanear", "2f8298f3c3": "Detener escaneo", - "c157f31a95": "Importar como grupo", - "40199ef7b3": "Nombre del grupo", - "787412361a": "¿Qué es el nombre de un grupo?", "5f857ba8e6": "en", "4df0d08cc5": "Encontró", "8db50afe1a": "Importar repositorios desde la carpeta", "5b2e6fe3c8": "Importar por separado", "cf9d382ca1": "Importar", "220dd32d83": "Exploración...", - "fb33359f69": "¿Es esto un monorepo?", - "d75170194e": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", + "fb33359f69": "¿Agrupar estos repositorios?", + "d75170194e": "Elige esto si estos proyectos van juntos: un monorepo o simplemente un conjunto de repositorios relacionados. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", "39d51212cc": "Nombre del grupo", "aa0247680d": "No, importar por separado", - "a0bc4d1f8e": "Importar como grupo", + "a0bc4d1f8e": "Sí, importar como grupo", "8401a7a0d0": "1 repositorio", "d4f1df62ef": "{{value0}} repositorios", "b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.", - "24eda6c8b2": "Explorando... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "Explorando... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "Detener escaneo", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8fa3a8a09b4..3e2ac5ce512 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3359,26 +3359,21 @@ "496f68cf8c": "repos をスキャンしています。クリックして停止します。", "a32bef9516": "スキャンを停止する", "2f8298f3c3": "スキャンの停止", - "c157f31a95": "グループとしてインポート", - "40199ef7b3": "グループ名", - "787412361a": "グループ名とは何ですか?", "5f857ba8e6": "で", "4df0d08cc5": "見つかった", "8db50afe1a": "フォルダーから repos をインポートする", "5b2e6fe3c8": "個別にインポート", "cf9d382ca1": "インポート", "220dd32d83": "スキャン中...", - "fb33359f69": "これはモノレポですか?", - "d75170194e": "モノレポ、または一緒に扱うべきプロジェクトの場合は、グループとしてインポートします。Orca がグループ化し、親フォルダーから作業できるようにします。", + "fb33359f69": "これらのリポジトリをグループ化しますか?", + "d75170194e": "これらのプロジェクトをまとめたい場合(モノレポでも、関連するリポジトリの集まりでも)に選択します。Orca がグループ化し、親フォルダーから作業できるようにします。", "39d51212cc": "グループ名", "aa0247680d": "いいえ、個別にインポート", - "a0bc4d1f8e": "グループとしてインポート", + "a0bc4d1f8e": "はい、グループとしてインポート", "8401a7a0d0": "1 個の repos", "d4f1df62ef": "{{value0}} 個の repos", "b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。", - "24eda6c8b2": "スキャン中... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "スキャン中... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "スキャンの停止", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0a0594501b5..d452e81de2c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3359,26 +3359,21 @@ "496f68cf8c": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", "a32bef9516": "스캔 중지", "2f8298f3c3": "스캔 중지", - "c157f31a95": "그룹으로 가져오기", - "40199ef7b3": "그룹 이름", - "787412361a": "그룹 이름은 무엇입니까?", "5f857ba8e6": "~에", "4df0d08cc5": "발견됨", "8db50afe1a": "폴더에서 repos 가져오기", "5b2e6fe3c8": "별도로 가져오기", "cf9d382ca1": "가져오기", "220dd32d83": "스캔 중...", - "fb33359f69": "이 폴더가 모노 repo 인가요?", - "d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", + "fb33359f69": "이 저장소들을 그룹으로 묶을까요?", + "d75170194e": "이 프로젝트들이 함께 속한 경우(모노레포이거나 단순히 관련된 저장소 모음) 이 옵션을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", "39d51212cc": "그룹 이름", "aa0247680d": "아니요, 별도로 가져오기", - "a0bc4d1f8e": "그룹으로 가져오기", + "a0bc4d1f8e": "예, 그룹으로 가져오기", "8401a7a0d0": "repos 1개", "d4f1df62ef": "repos {{value0}}개", "b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.", - "24eda6c8b2": "스캔 중... {{value0}}", - "b20bb7c24f": "이 리포지토리들을 하나의 그룹으로 묶어 둡니다. 마이크로서비스처럼 서로 관련된 리포지토리에 적합합니다.", - "e907ec8935": "모노 repo 이름이란?" + "24eda6c8b2": "스캔 중... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "스캔 중지", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b67d1db3bf5..0c7753af883 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3358,26 +3358,21 @@ "496f68cf8c": "扫描存储库。单击停止。", "a32bef9516": "停止扫描", "2f8298f3c3": "停止扫描", - "c157f31a95": "作为组导入", - "40199ef7b3": "团体名称", - "787412361a": "什么是群组名称?", "5f857ba8e6": "在", "4df0d08cc5": "成立", "8db50afe1a": "从文件夹导入存储库", "5b2e6fe3c8": "单独导入", "cf9d382ca1": "导入", "220dd32d83": "扫描...", - "fb33359f69": "这是 Monorepo 吗?", - "d75170194e": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。", + "fb33359f69": "将这些仓库分组吗?", + "d75170194e": "如果这些项目应放在一起(可以是 Monorepo,也可以只是一组相关仓库),请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。", "39d51212cc": "组名称", "aa0247680d": "否,单独导入", - "a0bc4d1f8e": "作为组导入", + "a0bc4d1f8e": "是,作为组导入", "8401a7a0d0": "1 个仓库", "d4f1df62ef": "{{value0}} 个仓库", "b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。", - "24eda6c8b2": "正在扫描... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "正在扫描... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "停止扫描", diff --git a/tests/e2e/folder-setup-shallow-priority.spec.ts b/tests/e2e/folder-setup-shallow-priority.spec.ts index 96fe04580a1..b970a29ba8f 100644 --- a/tests/e2e/folder-setup-shallow-priority.spec.ts +++ b/tests/e2e/folder-setup-shallow-priority.spec.ts @@ -8,7 +8,7 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' const tempRoots: string[] = [] -const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo' +const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group' function initializeGitRepo(repoPath: string): void { mkdirSync(repoPath, { recursive: true }) @@ -133,11 +133,11 @@ async function installCancellableNestedScanMock( }, scan) } -function getImportAsMonorepoButton(importDialog: Locator): Locator { +function getImportAsGroupButton(importDialog: Locator): Locator { // Why: this test should fail on import-dialog copy drift instead of falling // back to a retired accessible label. return importDialog.getByRole('button', { - name: IMPORT_AS_MONOREPO_BUTTON_NAME, + name: IMPORT_AS_GROUP_BUTTON_NAME, exact: true }) } @@ -177,7 +177,7 @@ test('prioritizes shallow sibling repositories in a bounded nested scan', async .filter({ hasText: 'z-web-client' }) .locator('input[type="checkbox"]') .check() - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( @@ -257,13 +257,13 @@ test('can stop a nested repo scan and import repositories found so far', async ( name: /Import repositories from folder/i }) await expect(importDialog.getByText(/Scanning\.\.\.\s*Found 1 repository in/)).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeDisabled() + await expect(getImportAsGroupButton(importDialog)).toBeDisabled() await importDialog.getByRole('button', { name: /Stop scan/i }).click() await expect(importDialog.getByText('Scan stopped early.')).toBeVisible() await expect(importDialog.getByText(/Found 1 repository in/)).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled() + await expect(getImportAsGroupButton(importDialog)).toBeEnabled() - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( diff --git a/tests/e2e/folder-setup.spec.ts b/tests/e2e/folder-setup.spec.ts index 107ce1088f2..cbabf8e0bbe 100644 --- a/tests/e2e/folder-setup.spec.ts +++ b/tests/e2e/folder-setup.spec.ts @@ -7,7 +7,7 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' import type { ElectronApplication, Locator } from '@stablyai/playwright-test' -const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo' +const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group' const tempRoots: string[] = [] @@ -95,11 +95,11 @@ async function chooseFolderInNativeDialog( }, folderPath) } -function getImportAsMonorepoButton(importDialog: Locator): Locator { +function getImportAsGroupButton(importDialog: Locator): Locator { // Why: the current accessible action is intentional; accepting retired // labels would hide deterministic dialog copy drift. return importDialog.getByRole('button', { - name: IMPORT_AS_MONOREPO_BUTTON_NAME, + name: IMPORT_AS_GROUP_BUTTON_NAME, exact: true }) } @@ -129,8 +129,8 @@ test.describe('Folder setup', () => { ).toBeVisible() await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible() await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled() - await getImportAsMonorepoButton(importDialog).click() + await expect(getImportAsGroupButton(importDialog)).toBeEnabled() + await getImportAsGroupButton(importDialog).click() await expect .poll( @@ -217,7 +217,7 @@ test.describe('Folder setup', () => { .locator('input[type="checkbox"]') .check() } - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( From 7c9943959e87abd23fc2dc24e194c4ded0bbbc7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:05:06 -0700 Subject: [PATCH 5/8] Prevent accidental tab drags when clicking (#6210) --- .../tab-group/useTabDragSplit.test.ts | 17 +++++++++++++++++ .../src/components/tab-group/useTabDragSplit.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 6a1efe52481..453096115b0 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -10,6 +10,8 @@ import type { TabDragItemData } from './useTabDragSplit' import { canDropTabForPaneColumnSplit, canDropTabIntoPaneBody, + getTabDragActivationDistance, + TAB_DRAG_ACTIVATION_DISTANCE_PX, useTabDragSplit } from './useTabDragSplit' @@ -177,6 +179,21 @@ afterEach(() => { vi.clearAllMocks() }) +describe('tab drag activation distance', () => { + it('uses the named threshold for enabled tab drags', () => { + expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBe(12) + expect(getTabDragActivationDistance(true)).toBe(TAB_DRAG_ACTIVATION_DISTANCE_PX) + }) + + it('keeps enabled tab drags above the old overly-sensitive distance', () => { + expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBeGreaterThan(5) + }) + + it('uses an impossible activation distance when tab dragging is disabled', () => { + expect(getTabDragActivationDistance(false)).toBe(Number.MAX_SAFE_INTEGER) + }) +}) + describe('canDropTabIntoPaneBody', () => { it('rejects pane-body drops that would split a single tab onto itself', () => { expect( diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 83d6b598163..8515942da7f 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -45,6 +45,10 @@ export type { HoveredTabInsertion } export type TabDropZone = 'center' | TabSplitDirection +// Why: tab activation waits for pointerup, so dnd-kit needs enough movement +// tolerance to avoid treating ordinary click jitter as an intentional drag. +export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12 + export type TabDragItemData = { kind: 'tab' worktreeId: string @@ -145,6 +149,10 @@ export function getTabPaneBodyDroppableId(groupId: string): UniqueIdentifier { return `tab-group-pane-body:${groupId}` } +export function getTabDragActivationDistance(enabled: boolean): number { + return enabled ? TAB_DRAG_ACTIVATION_DISTANCE_PX : Number.MAX_SAFE_INTEGER +} + export function useTabDragSplit({ worktreeId, enabled = true @@ -187,7 +195,7 @@ export function useTabDragSplit({ // the sensors array into a useEffect dependency list — changing its // length between renders violates React's rules of hooks. const pointerSensor = useSensor(PointerSensor, { - activationConstraint: { distance: enabled ? 5 : Number.MAX_SAFE_INTEGER } + activationConstraint: { distance: getTabDragActivationDistance(enabled) } }) const sensors = useSensors(pointerSensor) From 1b6e65c4efe867744432a4e19271890fe8cd83c0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:24:13 -0700 Subject: [PATCH 6/8] Fix terminal paste cancellation on transient blur (#6232) Fix terminal paste cancellation on transient blur --- .../terminal-paste-target-state.test.ts | 60 +++++++++++ .../terminal-paste-target-state.ts | 32 +++++- tests/e2e/terminal-paste-ownership.spec.ts | 101 +++++++++++++++++- 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts index 64a78e1ed7e..4d35ef7cfbe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts @@ -17,6 +17,21 @@ function makePaneContainer(acceptedElement: Element): Element { } as unknown as Element } +function makePaneContainerFor(acceptedElements: readonly Element[]): Element { + return { + contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element)) + } as unknown as Element +} + +function makeElement(tagName: string, classNames: string[] = []): Element { + return { + tagName, + classList: { + contains: (className: string) => classNames.includes(className) + } + } as unknown as Element +} + describe('terminal paste target state', () => { it('accepts the same mounted pane, transport, and PTY identity', () => { const transport = makeTransport() @@ -128,6 +143,51 @@ describe('terminal paste target state', () => { ).toBe(false) }) + it('keeps keyboard-owned paste current across transient document blur', () => { + const terminalInput = {} as Element + const body = makeElement('body') + const paneContainer = makePaneContainer(terminalInput) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: terminalInput, + paneContainer, + activeElement: body + }) + ).toBe(true) + }) + + it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => { + const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput]) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: originalTerminalInput, + paneContainer, + activeElement: replacementTerminalInput + }) + ).toBe(true) + }) + + it('rejects keyboard-owned paste when focus moves to another control in the pane', () => { + const terminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const searchInput = makeElement('input') + const paneContainer = makePaneContainerFor([terminalInput, searchInput]) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: terminalInput, + paneContainer, + activeElement: searchInput + }) + ).toBe(false) + }) + it('does not require focus continuity for programmatic terminal paste', () => { const terminalInput = {} as Element const otherInput = {} as Element diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts index 42bf8cc07bc..1f42adfaae9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts @@ -54,9 +54,31 @@ export function isTerminalPanePasteFocusCurrent({ if (!requireSameFocusedElement || activeElementAtDispatch === null) { return true } - // Why: clipboard reads are async, so focus may leave the terminal before - // execution. In that case the stale terminal must not receive the payload. - return ( - activeElement === activeElementAtDispatch && paneContainer.contains(activeElementAtDispatch) - ) + if (!paneContainer.contains(activeElementAtDispatch)) { + return false + } + if (activeElement === activeElementAtDispatch) { + return true + } + // Why: macOS dictation and clipboard permission handoffs can transiently + // blur xterm to body, and xterm may replace its helper textarea mid-paste. + if (isInertDocumentFocus(activeElement)) { + return true + } + if (activeElement === paneContainer) { + return true + } + return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement) +} + +function isInertDocumentFocus(element: Element | null): boolean { + if (!element) { + return true + } + const tagName = element.tagName?.toUpperCase() + return tagName === 'BODY' || tagName === 'HTML' +} + +function isXtermHelperTextarea(element: Element | null): boolean { + return element?.classList?.contains('xterm-helper-textarea') === true } diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts index 998aa844147..3e95b02f123 100644 --- a/tests/e2e/terminal-paste-ownership.spec.ts +++ b/tests/e2e/terminal-paste-ownership.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { rmSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { focusActiveTerminalInput, @@ -112,6 +112,62 @@ async function rightClickActiveTerminalSurface(page: Page): Promise { await page.mouse.click(point.x, point.y, { button: 'right' }) } +async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { + await app.evaluate(({ BrowserWindow, ipcMain }) => { + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + const global = globalThis as unknown as { + __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler + } + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + const handler = invokeHandlers?.get('clipboard:readText') + if (!invokeHandlers || !handler || global.__orcaOriginalClipboardReadTextHandler) { + return + } + global.__orcaOriginalClipboardReadTextHandler = handler + invokeHandlers.set('clipboard:readText', async (event, ...args) => { + const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()) + await Promise.all( + windows.map((window) => + window.webContents + .executeJavaScript( + ` + (document.activeElement && document.activeElement.blur && document.activeElement.blur()); + document.body.tabIndex = -1; + document.body.focus(); + ` + ) + .catch(() => undefined) + ) + ) + // Why: reproduce the focus churn window before the async clipboard read resolves. + await new Promise((resolve) => setTimeout(resolve, 0)) + return global.__orcaOriginalClipboardReadTextHandler!(event, ...args) + }) + }) +} + +async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { + await app.evaluate(({ ipcMain }) => { + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + const global = globalThis as unknown as { + __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler + } + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) { + invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler) + delete global.__orcaOriginalClipboardReadTextHandler + } + }) +} + async function openTerminalContextMenu(page: Page): Promise { const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac')) @@ -171,6 +227,49 @@ test.describe('terminal paste ownership', () => { } }) + test('keyboard paste survives transient terminal blur during clipboard read', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await installTerminalPtyWriteSpy(electronApp) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-paste-blur-${runId}.mjs`) + writeFileSync(scriptPath, pasteEchoScript(runId)) + let scriptStarted = false + + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + scriptStarted = true + await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000) + + const payload = `ORCA_E2E_TRANSIENT_BLUR_PASTE_${runId}` + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + await clearTerminalPtyWriteLog(electronApp) + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload) + await focusActiveTerminalInput(orcaPage) + await installClipboardReadTerminalBlurRepro(electronApp) + + await orcaPage.keyboard.press(keyboardPasteChords()[0]) + await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000) + + const writes = (await readTerminalPtyWrites(electronApp)).join('') + expect(countOccurrences(writes, payload), 'transient blur PTY write count').toBe(1) + } finally { + await restoreClipboardReadTerminalBlurRepro(electronApp).catch(() => undefined) + if (scriptStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('terminal context-menu Paste sends clipboard text exactly once', async ({ electronApp, orcaPage, From c3a089ff4432a5b071e32a63e8c5337f86822c1b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:39:41 -0700 Subject: [PATCH 7/8] Align folder workspace rows with new card style (#6137) Co-authored-by: Orca --- .../WorktreeCard.affiliate-list-mode.test.tsx | 1 + .../WorktreeCard.compact-hover.test.tsx | 75 +++++ ...orktreeCard.hosted-review-refresh.test.tsx | 1 + .../sidebar/WorktreeCard.lineage.test.tsx | 1 + .../WorktreeCard.pinned-repo-icon.test.tsx | 1 + .../sidebar/WorktreeCard.pr-display.test.tsx | 1 + .../WorktreeCard.quick-actions.test.tsx | 33 ++- ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 1 + .../src/components/sidebar/WorktreeCard.tsx | 61 ++-- .../WorktreeCardDisplayMenuSection.test.tsx | 22 ++ .../WorktreeCardDisplayMenuSection.tsx | 6 +- .../sidebar/WorktreeCardStatusSlot.test.tsx | 23 +- .../sidebar/WorktreeCardStatusSlot.tsx | 11 +- .../WorktreeList.lineage-child-card.test.ts | 24 +- .../src/components/sidebar/WorktreeList.tsx | 59 ++-- .../folder-workspace-card-pr-display.test.ts | 160 +++++++++++ .../folder-workspace-card-pr-display.ts | 272 ++++++++++++++++++ .../sidebar-workspace-option-items.test.ts | 26 ++ .../sidebar/sidebar-workspace-option-items.ts | 23 +- .../sidebar/worktree-list-indentation.test.ts | 124 ++++++++ .../sidebar/worktree-list-indentation.ts | 55 ++++ src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 13 +- 26 files changed, 958 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts create mode 100644 src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index 910fd31e1de..31e29c83984 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/store', () => ({ linearIssueCache: {}, openModal, openTaskPage: vi.fn(), + projectGroups: [], ptyIdsByTabId: {}, remoteBranchConflictByWorktreeId: {}, renamingWorktreeId: null, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index d93201f4738..b37e5e671aa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -17,6 +17,7 @@ const setWorkspacePortScanRefreshing = vi.fn() let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports'] let hostedReviewCache: Record = {} +let projectGroups: unknown[] = [] let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null let settings: Partial | null = { compactWorktreeCards: true } @@ -35,6 +36,7 @@ vi.mock('@/store', () => ({ linearIssueCache: {}, openModal, openTaskPage, + projectGroups, ptyIdsByTabId: {}, recordFeatureInteraction, remoteBranchConflictByWorktreeId: {}, @@ -172,6 +174,7 @@ describe('WorktreeCard compact hover details', () => { vi.clearAllMocks() worktreeCardProperties = ['status', 'ports'] hostedReviewCache = {} + projectGroups = [] workspacePortScan = null settings = { compactWorktreeCards: true } }) @@ -561,6 +564,78 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('text-[13px] leading-5') }) + it('does not show a folder path row in new-card mode when no project groups exist', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'branch'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('data-worktree-card-meta-row=""') + expect(markup).toContain('Docs folder') + expect(markup).not.toContain('/Users/x/projects/my-app') + }) + + it('shows a folder path row in new-card mode through the branch setting when project groups exist', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'branch'] + projectGroups = [{ id: 'group-1' }] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-worktree-card-meta-row=""') + expect(markup).toContain(' /Users/x/projects/my-app ') + expect(markup).not.toContain('>Folder') + }) + + it('keeps hidden folder path identity available from the new-card hover', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status'] + projectGroups = [{ id: 'group-1' }] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('data-worktree-card-meta-row=""') + expect(markup).toContain('data-hover-open-delay="100"') + expect(markup).toContain('/Users/x/projects/my-app') + expect(markup).toContain('Docs folder') + }) + it('shows the branch row for migrated Default cards with branch enabled', async () => { settings = { compactWorktreeCards: false } worktreeCardProperties = ['status', 'branch'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index f233618f162..48ce2425822 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -33,6 +33,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings: { experimentalNewWorktreeCardStyle: true }, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index e144c5182ee..71fa30bed26 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings: null, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index 37b5426cfdf..88d5d65d818 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 83311e622da..dbf90b0faa2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -29,6 +29,7 @@ vi.mock('@/store', () => ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index ced090972f4..3d235a6351d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -21,6 +21,7 @@ let tabsByWorktree: Record = {} let ptyIdsByTabId: Record = {} let browserTabsByWorktree: Record = {} let settings: Partial | null = null +let projectGroups: unknown[] = [] let workspaceDeleteModifierPressed = false let gitConflictOperationByWorktree: Record = {} let WorktreeCard: typeof WorktreeCardComponent @@ -35,6 +36,7 @@ vi.mock('@/store', () => ({ hostedReviewCache: {}, issueCache: {}, openModal, + projectGroups, remoteBranchConflictByWorktreeId: {}, settings, sshConnectionStates: new Map(), @@ -143,6 +145,7 @@ describe('WorktreeCard quick actions', () => { ptyIdsByTabId = {} browserTabsByWorktree = {} settings = null + projectGroups = [] workspaceDeleteModifierPressed = false gitConflictOperationByWorktree = {} }) @@ -181,7 +184,7 @@ describe('WorktreeCard quick actions', () => { expect(markup).not.toContain('bg-black/[0.08]') }) - it('renders folder kind and directory in the detailed metadata row', () => { + it('renders folder directory name in the detailed metadata row without a Folder badge', () => { const markup = renderToStaticMarkup( { ) expect(markup).toContain('Docs folder') - expect(markup).toContain('>Folder') + expect(markup).not.toContain('>Folder') expect(markup).toContain('>quick-action') expect(markup).toContain('data-worktree-card-meta-row=""') }) - it('renders synthetic folder workspace directory in the detailed metadata row', () => { + it('renders synthetic folder workspace directory name in the detailed metadata row without a Folder badge', () => { const markup = renderToStaticMarkup( { ) expect(markup).toContain('Docs folder') - expect(markup).toContain('>Folder') + expect(markup).not.toContain('>Folder') expect(markup).toContain('>quick-action') expect(markup).toContain('data-worktree-card-meta-row=""') }) + it('does not render a branch icon for synthetic folder workspace path identity', () => { + settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'branch'] + projectGroups = [{ id: 'project-group-1' }] + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('/repo/worktrees/quick-action') + expect(markup).not.toContain('lucide-git-branch') + }) + it('does not render a pending first-agent rename title badge', () => { const markup = renderToStaticMarkup( ({ issueCache: {}, linearIssueCache: {}, openModal, + projectGroups: [], remoteBranchConflictByWorktreeId: {}, runtimeStatusByEnvironmentId, settings: null, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index d8f306c0508..386ee7dcda5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -26,7 +26,7 @@ import WorktreeCardAgents from './WorktreeCardAgents' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import { cn } from '@/lib/utils' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' -import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' +import { isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { GitHubWorkItem, @@ -45,6 +45,7 @@ import { import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' import { writeWorkspaceDragData } from './workspace-status' import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import { coerceWorktreeCardVisibleTitle, getWorktreeCardTitleDisplay @@ -121,6 +122,7 @@ type WorktreeCardProps = { onCardDragEnd?: (event: React.DragEvent) => void nativeDragEnabled?: boolean affiliateListMode?: boolean + statusPrDisplay?: WorktreeCardPrDisplay | null } const EMPTY_WORKSPACE_PORTS = [] @@ -212,7 +214,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ lineageChildrenStyle, onLineageToggle, isLineageDropTarget = false, - affiliateListMode = false + affiliateListMode = false, + statusPrDisplay = null }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const openTaskPage = useAppStore((s) => s.openTaskPage) @@ -228,6 +231,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const fetchIssue = useAppStore((s) => s.fetchIssue) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) const cardProps = useAppStore((s) => s.worktreeCardProperties) + const projectGroups = useAppStore((s) => s.projectGroups) const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const compactCards = !newCardStyle && settings?.compactWorktreeCards === true const activeSurfaceIsSecondary = isActiveSurface && activeSurfaceVariant === 'secondary' @@ -367,6 +371,18 @@ const WorktreeCard = React.memo(function WorktreeCard({ const folderWorkspaceId = workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null + // Why: project groups are the product gate for 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( @@ -936,6 +952,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const hoverIssue = issueDisplay const hoverLinearIssue = linearIssueDisplay const hoverReview = prDisplay + const statusLaneReview = statusPrDisplay ?? hoverReview const hoverComment = worktree.comment const metaIssue = showIssue ? hoverIssue : null const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null @@ -1051,7 +1068,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showBranch = !isFolder && branch.length > 0 && - (newCardStyle ? cardProps.includes('branch') : !compactCards || branch !== worktree.displayName) + !newCardStyle && + (!compactCards || branch !== worktree.displayName) // Why: rebases already surface in source control; keep dense cards from // carrying a persistent rebase chip while preserving other interruption cues. const showConflictOperationBadge = @@ -1070,30 +1088,28 @@ const WorktreeCard = React.memo(function WorktreeCard({ const hasDetailedMetaRowContent = Boolean( (showRepoBadgeInMetaRow && repo) || showHostContextBadge || - isFolder || + folderMetaRowContent || showBranch || + showIdentityInNewCard || showDetachedHeadInMetaRow || showConflictOperationBadge || cacheStartedAt != null || showMetaRowDetails ) const hasMetaRow = compactCards - ? hasMetadataBadge || (newCardStyle && showBranch) || cacheStartedAt != null + ? hasMetadataBadge || cacheStartedAt != null : hasDetailedMetaRowContent const showHeaderActions = showTitleRowPrimary || showDeleteQuickAction // Why: the hover owns full identity when the row truncates; normalize once // so title/branch de-dupe and identity-only hover eligibility stay in sync. const trimmedVisibleCardTitle = visibleCardTitle.trim() const showBranchIdentityHover = newCardStyle - ? !isFolder && - branch.length > 0 && + ? Boolean(identityDisplay) && !cardProps.includes('branch') && - branch !== trimmedVisibleCardTitle + identityDisplay !== trimmedVisibleCardTitle : compactCards && showBranch const hoverBranchName = newCardStyle - ? !isFolder && branch.length > 0 - ? branch - : undefined + ? identityDisplay : showBranchIdentityHover ? branch : undefined @@ -1257,9 +1273,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ unreadTooltip={unreadTooltip} onPointerDown={stopQuickActionPointerPropagation} onToggleUnread={handleToggleUnreadQuick} - prDisplay={hoverReview} + prDisplay={statusLaneReview} newCardStyle={newCardStyle} - hasBranchIdentity={!isFolder && branch.length > 0} + hasBranchIdentity={Boolean(branchIdentityDisplay)} /> ) : null} @@ -1372,17 +1388,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ } /> - {isFolder && ( - - {repo - ? getRepoKindLabel(repo) - : translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')} - - )} - {typeof worktree.firstAgentMessageRenameError === 'string' && worktree.firstAgentMessageRenameError.length > 0 && !titleRenaming ? ( @@ -1542,7 +1547,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} - {isFolder ? ( + {showIdentityInNewCard ? ( + + ) : isFolder && !newCardStyle ? ( ({ useAppStore: (selector: (state: unknown) => unknown) => selector({ agentActivityDisplayMode: 'compact', + projectGroups, setAgentActivityDisplayMode, setWorktreeCardMode, setWorktreeCardProperties, @@ -112,6 +114,7 @@ function renderMenu(): void { beforeEach(() => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } + projectGroups = [] worktreeCardProperties = [ 'status', 'unread', @@ -153,4 +156,23 @@ describe('WorktreeCardDisplayMenuSection', () => { expect(setWorktreeCardMode).toHaveBeenCalledWith('Compact') }) + + it('keeps branch-only copy when project groups are unavailable', () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + + renderMenu() + + expect(container?.textContent).toContain('Branch name') + expect(container?.textContent).not.toContain('Branch / folder path') + }) + + it('mentions folder paths when project groups can create folder workspaces', () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + projectGroups = [{ id: 'group-1' }] + + renderMenu() + + expect(container?.textContent).toContain('Branch / folder path') + expect(container?.textContent).not.toContain('Branch name') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx index f2447813ca5..535ab541435 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx @@ -32,6 +32,7 @@ export function WorktreeCardDisplayMenuSection({ const setWorktreeCardMode = useAppStore((s) => s.setWorktreeCardMode) const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode) const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode) + const projectGroups = useAppStore((s) => s.projectGroups) const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const cardLayout = settings?.compactWorktreeCards ? 'compact' : 'detailed' const cardLayoutLabel = @@ -39,9 +40,10 @@ export function WorktreeCardDisplayMenuSection({ const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) => worktreeCardProperties.includes(opt.id) ).length + const hasProjectGroups = projectGroups.length > 0 const worktreeCardPropertyOptions = useMemo( - () => getWorktreeCardPropertyOptions({ newCardStyle }), - [newCardStyle] + () => getWorktreeCardPropertyOptions({ newCardStyle, hasProjectGroups }), + [newCardStyle, hasProjectGroups] ) const handleWorktreeCardPropertyChange = useCallback( (properties: readonly WorktreeCardProperty[], checked: boolean): void => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 13cf96fe897..4fdb666088a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -205,7 +205,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon instead of the quiet active dot when new card style has no review', () => { + it('uses a branch icon with branch-only tooltip copy by default', () => { const markup = renderToStaticMarkup( { ) expect(markup).toContain('Branch') + expect(markup).not.toContain('Branch or folder path') expect(markup).toContain('lucide-git-branch') expect(markup).toContain('size-[13px] translate-x-px text-muted-foreground/70') expect(markup).toContain('text-muted-foreground/70') expect(markup).not.toContain('bg-emerald-500') }) + it('uses context-aware branch or folder path tooltip copy', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Branch or folder path') + expect(markup).toContain('lucide-git-branch') + }) + it('keeps the quiet dot when the row has no branch identity', () => { const markup = renderToStaticMarkup( (['active', 'done', 'inactive']) // Why: a missing review display can also mean provider state is unavailable, -// so the passive label names the branch cue without claiming no review exists. -const BRANCH_STATUS_LABEL = 'Branch' +// so the passive label names the identity cue without claiming no review exists. +function getDefaultBranchIdentityLabel(): string { + return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch') +} // Why: branch-style SVGs are optically left-heavy; this keeps them aligned with // the centered activity dots in the shared status column. const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px' @@ -93,6 +97,7 @@ export function WorktreeCardStatusSlot({ prDisplay = null, newCardStyle = false, hasBranchIdentity = false, + branchIdentityLabel, className }: WorktreeCardStatusSlotProps): React.JSX.Element | null { const status = useWorktreeActivityStatus(worktreeId) @@ -112,7 +117,7 @@ export function WorktreeCardStatusSlot({ canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) : canShowBranchStatus - ? BRANCH_STATUS_LABEL + ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) : statusLabel const passiveStatusTooltip = newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 6ce0830d27d..6d35ee6a17f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -340,6 +340,7 @@ function makeFolderWorkspacePathStatusState(): Record { function setFolderWorkspaceFixtureState( options: { createdFrom?: ProjectGroup['createdFrom'] + experimentalNewWorktreeCardStyle?: boolean nestedGroup?: boolean } = {} ): void { @@ -405,7 +406,9 @@ function setFolderWorkspaceFixtureState( setRenamingWorktreeId: vi.fn(), setShowSleepingWorkspaces: vi.fn(), setSortBy: vi.fn(), - settings: null, + settings: options.experimentalNewWorktreeCardStyle + ? { experimentalNewWorktreeCardStyle: true } + : null, renamingWorktreeId: null, showSleepingWorkspaces: true, sortBy: 'manual', @@ -1040,6 +1043,25 @@ describe('WorktreeList lineage child card renderer', () => { ).toBe(20) }) + it('uses comparable new-card worktree geometry for experimental folder workspace rows', async () => { + setFolderWorkspaceFixtureState({ experimentalNewWorktreeCardStyle: true }) + const markup = await renderWorktreeListMarkup() + const folderWorktreeId = folderWorkspaceKey('folder-workspace-1') + const cardOpeningTag = getCardOpeningTag(markup, folderWorktreeId) + const surfaceOpeningTag = getFolderWorkspaceSurfaceOpeningTag(markup, 'folder-workspace-1') + const cardContentIndent = getDataNumber(cardOpeningTag, 'data-content-indent') + + expect(surfaceOpeningTag).toContain('padding-left:14px') + expect(cardOpeningTag).toContain('data-content-indent="16"') + expect(cardOpeningTag).toContain('data-flush-surface="true"') + expect( + getFlushCardContentStart({ + cardContentIndent, + surfaceInset: getPaddingLeft(surfaceOpeningTag) + }) + ).toBe(30) + }) + it('preserves manual folder workspace indentation outside folder-scanned groups', async () => { setFolderWorkspaceFixtureState({ createdFrom: 'manual' }) const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 1236d6dad5c..c303866f52e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -57,6 +57,7 @@ import type { ProjectOrderBy, WorktreeLineage, WorktreeMeta, + WorkspaceLineage, WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' @@ -225,8 +226,7 @@ import { LINEAGE_CHILDREN_INLINE_OFFSET, getFolderBackedRepoWorktreeCardContentIndent, getFolderBackedRepoWorktreeCardSurfaceInset, - getFolderWorkspaceCardContentIndent, - getFolderWorkspaceCardSurfaceInset, + getFolderWorkspaceRowGeometry, getLineageChildrenInlineStyle, getLineageNestedRowGeometry, getProjectGroupHeaderPaddingLeft, @@ -256,6 +256,7 @@ import { getFolderWorkspaceExecutionHostIdForRows, getProjectGroupExecutionHostIdForRows } from './worktree-list-host-filtering' +import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' export { getScrollTopToRevealBounds, @@ -613,6 +614,7 @@ type VirtualizedWorktreeViewportProps = { repoMap: Map worktreeMap: Map worktreeLineageById: Record + workspaceLineageByChildKey: Record repoOrder: Map // The full canonical state.repos id ordering — the drag controller commits // permutations of this list, even when some repos aren't currently visible @@ -622,6 +624,7 @@ type VirtualizedWorktreeViewportProps = { onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void onHostDragActiveChange: (active: boolean) => void prCache: Record | null + hostedReviewCache: Record | null workspaceStatuses: readonly WorkspaceStatusDefinition[] projectGrouping?: ProjectGroupingModel projectGroups?: readonly ProjectGroup[] @@ -1238,11 +1241,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoMap, worktreeMap, worktreeLineageById, + workspaceLineageByChildKey, repoOrder, allRepoIds, onReorderHostSections, onHostDragActiveChange, prCache, + hostedReviewCache, workspaceStatuses, projectGrouping, projectGroups = EMPTY_PROJECT_GROUPS, @@ -4761,30 +4766,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp folderWorkspacePathStatus?.exists === false && (isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) || folderWorkspacePathStatus.reason === 'ambiguous-connection') + const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap, + repoMap, + hostedReviewCache, + prCache, + settings + }) const isFolderBackedWorkspaceChild = groupBy === 'repo' && folderWorkspaceRow.projectGroup.createdFrom === 'folder-scan' - const contentIndent = isFolderBackedWorkspaceChild - ? getFolderWorkspaceCardContentIndent({ - groupDepth: folderWorkspaceRow.groupDepth - }) - : getWorktreeCardContentIndent({ - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth, - lineageDepth: folderWorkspaceRow.depth - }) - // Why: folder workspace surfaces should step inward with their - // project-group nesting, matching lineage child card surfaces - // instead of spanning from the sidebar edge at every depth. - const surfaceInset = isFolderBackedWorkspaceChild - ? getFolderWorkspaceCardSurfaceInset({ - isGrouped: true, - groupDepth: folderWorkspaceRow.groupDepth - }) - : getWorktreeCardSurfaceInset({ - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth - }) - const insetContentIndent = Math.max(0, contentIndent - surfaceInset) + const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: newCardStyle, + isFolderBackedWorkspaceChild, + isGrouped: groupBy !== 'none', + groupDepth: folderWorkspaceRow.groupDepth, + lineageDepth: folderWorkspaceRow.depth + }) return (
@@ -4910,6 +4911,7 @@ const WorktreeList = React.memo(function WorktreeList({ const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -5017,6 +5019,11 @@ const WorktreeList = React.memo(function WorktreeList({ ? s.prCache : null ) + const hostedReviewCache = useAppStore((s) => + s.settings?.experimentalNewWorktreeCardStyle === true && cardProps.includes('status') + ? s.hostedReviewCache + : null + ) const settings = useAppStore((s) => s.settings) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) @@ -6417,11 +6424,13 @@ const WorktreeList = React.memo(function WorktreeList({ repoMap={repoMap} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} + workspaceLineageByChildKey={workspaceLineageByChildKey} repoOrder={repoOrder} allRepoIds={allRepoIds} onReorderHostSections={handleReorderHostSections} onHostDragActiveChange={setHostDragActive} prCache={prCache} + hostedReviewCache={hostedReviewCache} workspaceStatuses={workspaceStatuses} projectGrouping={projectGrouping} projectGroups={projectGroups} diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts new file mode 100644 index 00000000000..6ca326b3c61 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { + CheckStatus, + Repo, + Worktree, + WorktreeLineage, + WorkspaceLineage +} from '../../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#999999', + addedAt: 1 +} + +function makeWorktree(overrides: Partial & { id: string }): Worktree { + const { id, ...rest } = overrides + return { + id, + repoId: repo.id, + path: `/worktrees/${id}`, + displayName: id, + branch: `refs/heads/${id}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...rest + } +} + +function makeWorkspaceLineage(worktree: Worktree): WorkspaceLineage { + return { + childWorkspaceKey: worktreeWorkspaceKey(worktree.id), + childInstanceId: worktree.instanceId ?? null, + parentWorkspaceKey: folderWorkspaceKey('folder-1'), + parentInstanceId: null, + origin: 'cli', + capture: { source: 'env-workspace', confidence: 'inferred' }, + createdAt: 1 + } +} + +function makeWorktreeLineage(child: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId ?? '', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId ?? '', + origin: 'cli', + capture: { source: 'env-workspace', confidence: 'inferred' }, + createdAt: 1 + } +} + +describe('getFolderWorkspaceCardPrDisplay', () => { + it('uses failing attached PR status ahead of pending and passing PRs', () => { + const passing = makeWorktree({ id: 'passing', linkedPR: 1 }) + const pending = makeWorktree({ id: 'pending', linkedPR: 2 }) + const failing = makeWorktree({ id: 'failing', linkedPR: 3 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { + [passing.id]: makeWorkspaceLineage(passing), + [pending.id]: makeWorkspaceLineage(pending), + [failing.id]: makeWorkspaceLineage(failing) + }, + worktreeLineageById: {}, + worktreeMap: new Map([ + [passing.id, passing], + [pending.id, pending], + [failing.id, failing] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::passing': { data: makePr(1, 'success') }, + 'repo-1::pending': { data: makePr(2, 'pending') }, + 'repo-1::failing': { data: makePr(3, 'failure') } + } + }) + + expect(display).toMatchObject({ number: 3, status: 'failure' }) + }) + + it('uses pending attached PR status ahead of passing PRs', () => { + const passing = makeWorktree({ id: 'passing', linkedPR: 1 }) + const pending = makeWorktree({ id: 'pending', linkedPR: 2 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { + [passing.id]: makeWorkspaceLineage(passing), + [pending.id]: makeWorkspaceLineage(pending) + }, + worktreeLineageById: {}, + worktreeMap: new Map([ + [passing.id, passing], + [pending.id, pending] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::passing': { data: makePr(1, 'success') }, + 'repo-1::pending': { data: makePr(2, 'pending') } + } + }) + + expect(display).toMatchObject({ number: 2, status: 'pending' }) + }) + + it('includes nested attached worktree PRs', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { [nested.id]: makeWorktreeLineage(nested, parent) }, + worktreeMap: new Map([ + [parent.id, parent], + [nested.id, nested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { + 'repo-1::nested': { data: makePr(4, 'success') } + } + }) + + expect(display).toMatchObject({ number: 4, status: 'success' }) + }) +}) + +function makePr(number: number, checksStatus: CheckStatus) { + return { + number, + title: `PR ${number}`, + state: 'open', + url: `https://example.test/pull/${number}`, + checksStatus, + updatedAt: '2026-01-01T00:00:00.000Z', + mergeable: 'UNKNOWN' + } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts new file mode 100644 index 00000000000..ae9e67b8b5a --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -0,0 +1,272 @@ +import type { AppState } from '@/store/types' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { branchName } from '@/lib/git-utils' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { + PRInfo, + Repo, + Worktree, + WorktreeLineage, + WorkspaceLineage +} from '../../../../shared/types' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getWorktreeCardPrDisplay, type WorktreeCardPrDisplay } from './worktree-card-pr-display' + +type HostedReviewCacheEntry = { + data?: HostedReviewInfo | null + linkedReviewHintKey?: string +} + +type PrCacheEntry = { + data?: PRInfo | null +} + +type FolderWorkspaceCardPrDisplayArgs = { + folderWorkspaceId: string + workspaceLineageByChildKey: Record | null | undefined + worktreeLineageById: Record | null | undefined + worktreeMap: ReadonlyMap + repoMap: ReadonlyMap + hostedReviewCache: Record | null + prCache: Record | null + settings?: AppState['settings'] +} + +const REVIEW_STATUS_PRIORITY: Record, number> = { + failure: 0, + pending: 1, + success: 2, + neutral: 3 +} + +export function getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap, + repoMap, + hostedReviewCache, + prCache, + settings +}: FolderWorkspaceCardPrDisplayArgs): WorktreeCardPrDisplay | null { + const reviews = getAttachedWorktreesForFolderWorkspaceCard({ + folderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap + }) + .map((worktree) => + getAttachedWorktreePrDisplay({ + worktree, + repo: repoMap.get(worktree.repoId), + hostedReviewCache, + prCache, + settings + }) + ) + .filter((review): review is WorktreeCardPrDisplay => review !== null) + + if (reviews.length === 0) { + return null + } + + return reviews.sort(compareReviewDisplays)[0] ?? null +} + +function getAttachedWorktreesForFolderWorkspaceCard({ + folderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap +}: Pick< + FolderWorkspaceCardPrDisplayArgs, + 'folderWorkspaceId' | 'workspaceLineageByChildKey' | 'worktreeLineageById' | 'worktreeMap' +>): Worktree[] { + const folderKey = folderWorkspaceKey(folderWorkspaceId) + const directChildren = Object.values(workspaceLineageByChildKey ?? {}) + .filter((lineage) => lineage.parentWorkspaceKey === folderKey) + .map((lineage) => getWorkspaceLineageChild(lineage, worktreeMap)) + .filter((worktree): worktree is Worktree => worktree !== null) + + const included = new Map(directChildren.map((worktree) => [worktree.id, worktree])) + let added = true + + while (added) { + added = false + for (const lineage of Object.values(worktreeLineageById ?? {})) { + if (included.has(lineage.worktreeId) || !included.has(lineage.parentWorktreeId)) { + continue + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + const child = worktreeMap.get(lineage.worktreeId) + if (!isCurrentLineagePair(parent, child, lineage)) { + continue + } + included.set(child.id, child) + added = true + } + } + + return [...included.values()] +} + +function getWorkspaceLineageChild( + lineage: WorkspaceLineage, + worktreeMap: ReadonlyMap +): Worktree | null { + const childScope = parseWorkspaceKey(lineage.childWorkspaceKey) + if (childScope?.type !== 'worktree') { + return null + } + const worktree = worktreeMap.get(childScope.worktreeId) + if (!worktree || worktree.isArchived) { + return null + } + if (lineage.childInstanceId && lineage.childInstanceId !== worktree.instanceId) { + return null + } + return worktree +} + +function isCurrentLineagePair( + parent: Worktree | undefined, + child: Worktree | undefined, + lineage: WorktreeLineage +): child is Worktree { + return Boolean( + parent && + child && + !parent.isArchived && + !child.isArchived && + child.instanceId === lineage.worktreeInstanceId && + parent.instanceId === lineage.parentWorktreeInstanceId + ) +} + +function getAttachedWorktreePrDisplay({ + worktree, + repo, + hostedReviewCache, + prCache, + settings +}: { + worktree: Worktree + repo: Repo | undefined + hostedReviewCache: Record | null + prCache: Record | null + settings?: AppState['settings'] +}): WorktreeCardPrDisplay | null { + if (!repo) { + return null + } + + const branch = branchName(worktree.branch).trim() + if (!branch) { + return getLinkedReviewDisplay(worktree) + } + + const hostedReviewCacheKey = getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId + ) + const hostedReviewEntry = hostedReviewCache?.[hostedReviewCacheKey] as + | HostedReviewCacheEntry + | undefined + const hostedReviewDisplay = hostedReviewEntry + ? getWorktreeCardPrDisplay( + hostedReviewEntry.data, + worktree.linkedPR, + worktree.linkedGitLabMR ?? null, + worktree.linkedBitbucketPR ?? null, + worktree.linkedAzureDevOpsPR ?? null, + worktree.linkedGiteaPR ?? null, + { reviewHintKey: hostedReviewEntry.linkedReviewHintKey } + ) + : null + if (hostedReviewDisplay) { + return hostedReviewDisplay + } + + const cachedGitHubPr = getCachedGitHubPr({ worktree, repo, branch, prCache, settings }) + if (cachedGitHubPr) { + return { + provider: 'github', + number: cachedGitHubPr.number, + title: cachedGitHubPr.title, + state: cachedGitHubPr.state, + url: cachedGitHubPr.url, + status: cachedGitHubPr.checksStatus + } + } + + return getLinkedReviewDisplay(worktree) +} + +function getLinkedReviewDisplay(worktree: Worktree): WorktreeCardPrDisplay | null { + return getWorktreeCardPrDisplay( + undefined, + worktree.linkedPR, + worktree.linkedGitLabMR ?? null, + worktree.linkedBitbucketPR ?? null, + worktree.linkedAzureDevOpsPR ?? null, + worktree.linkedGiteaPR ?? null + ) +} + +function getCachedGitHubPr({ + worktree, + repo, + branch, + prCache, + settings +}: { + worktree: Worktree + repo: Repo + branch: string + prCache: Record | null + settings?: AppState['settings'] +}): PRInfo | null { + if (!prCache || worktree.linkedPR === null) { + return null + } + + const cacheKey = getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId + ) + const canUseLegacyPRCache = + !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId && !repo.executionHostId + const legacyRepoScopedCacheKey = canUseLegacyPRCache + ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) + : '' + const legacyPathScopedCacheKey = canUseLegacyPRCache + ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) + : '' + const entry = + (prCache[cacheKey] as PrCacheEntry | undefined) ?? + (legacyRepoScopedCacheKey + ? (prCache[legacyRepoScopedCacheKey] as PrCacheEntry | undefined) + : undefined) ?? + (legacyPathScopedCacheKey + ? (prCache[legacyPathScopedCacheKey] as PrCacheEntry | undefined) + : undefined) + const pr = entry?.data ?? null + return pr?.number === worktree.linkedPR ? pr : null +} + +function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number { + return getReviewDisplayPriority(left) - getReviewDisplayPriority(right) +} + +function getReviewDisplayPriority(review: WorktreeCardPrDisplay): number { + return review.status ? REVIEW_STATUS_PRIORITY[review.status] : 4 +} diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts index e43f8b9d7ca..40efe8f0937 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts @@ -36,4 +36,30 @@ describe('worktree card property options', () => { expect(options.map((option) => option.label)).toContain('Linear issues') expect(options.map((option) => option.label)).toContain('Automation') }) + + it('uses branch-only copy by default and without project groups', () => { + const defaultOptions = getWorktreeCardPropertyOptions() + const newCardOptions = getWorktreeCardPropertyOptions({ + newCardStyle: true, + hasProjectGroups: false + }) + + expect(defaultOptions.find((option) => option.id === 'branch')?.label).toBe('Branch name') + expect(newCardOptions.find((option) => option.id === 'branch')?.label).toBe('Branch name') + }) + + it('keeps branch-only copy for legacy cards even with project groups', () => { + const options = getWorktreeCardPropertyOptions({ hasProjectGroups: true }) + + expect(options.find((option) => option.id === 'branch')?.label).toBe('Branch name') + }) + + it('mentions folder paths only for new card style with project groups', () => { + const options = getWorktreeCardPropertyOptions({ + newCardStyle: true, + hasProjectGroups: true + }) + + expect(options.find((option) => option.id === 'branch')?.label).toBe('Branch / folder path') + }) }) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index 1772ea4c14d..8623d35d0bb 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -158,21 +158,38 @@ const ISSUE_WORKTREE_CARD_PROPERTY_OPTIONS: WorktreeCardPropertyOption[] = [ type WorktreeCardPropertyOptionsInput = { newCardStyle?: boolean + hasProjectGroups?: boolean } export function getWorktreeCardPropertyOptions({ - newCardStyle = false + newCardStyle = false, + hasProjectGroups = false }: WorktreeCardPropertyOptionsInput = {}): WorktreeCardPropertyOption[] { const issueOptions = newCardStyle ? ISSUE_WORKTREE_CARD_PROPERTY_OPTIONS : [TASK_WORKTREE_CARD_PROPERTY_OPTION] + const branchOption: WorktreeCardPropertyOption = { + id: 'branch', + properties: ['branch'], + get label() { + // Why: new-card project groups can contain folder workspaces, so the + // branch setting copy must describe both repo and folder identity. + return newCardStyle && hasProjectGroups + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.folderPathIdentity', + 'Branch / folder path' + ) + : translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.219ebf1961', 'Branch name') + } + } if (newCardStyle) { - return [...issueOptions, ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1)] + return [...issueOptions, ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1, -1), branchOption] } return [ BASE_WORKTREE_CARD_PROPERTY_OPTIONS[0], ...issueOptions, - ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1) + ...BASE_WORKTREE_CARD_PROPERTY_OPTIONS.slice(1, -1), + branchOption ] } diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts index 0e65ebbdd6c..cb9d12aeaf5 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts @@ -12,6 +12,7 @@ import { getFolderBackedRepoWorktreeCardSurfaceInset, getFolderWorkspaceCardContentIndent, getFolderWorkspaceCardSurfaceInset, + getFolderWorkspaceRowGeometry, getFlushWorktreeCardPaddingLeft, getLineageChildrenInlineStyle, getLineageEffectiveChildStart, @@ -21,6 +22,17 @@ import { getWorktreeCardSurfaceInset } from './worktree-list-indentation' +function getFlushCardContentStart(args: { + surfaceInset: number + cardContentIndent: number +}): number { + return ( + args.surfaceInset + + WORKTREE_CARD_SURFACE_MARGIN + + Math.max(FLUSH_CARD_MIN_CONTENT_INSET, args.cardContentIndent - FLUSH_CARD_CONTENT_PULLBACK) + ) +} + describe('worktree list indentation', () => { it('keeps ungrouped workspaces flush with the list', () => { expect(getWorktreeCardContentIndent({ isGrouped: false, groupDepth: 4, lineageDepth: 0 })).toBe( @@ -78,6 +90,118 @@ describe('worktree list indentation', () => { expect(getFolderWorkspaceCardSurfaceInset({ isGrouped: false, groupDepth: 2 })).toBe(0) }) + it('preserves legacy folder-scanned folder workspace row geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: false, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 6 + }) + expect(getFlushCardContentStart(geometry)).toBe(20) + }) + + it('preserves legacy nested folder-scanned folder workspace row geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: false, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 2, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 24, + cardContentIndent: 6 + }) + expect(getFlushCardContentStart(geometry)).toBe(30) + }) + + it('preserves legacy manual grouped folder workspace row geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: false, + isFolderBackedWorkspaceChild: false, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 24 + }) + expect(getFlushCardContentStart(geometry)).toBe(38) + }) + + it('uses comparable repo worktree geometry for experimental folder-scanned folder workspaces', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 16 + }) + expect(getFlushCardContentStart(geometry)).toBe(30) + }) + + it('uses comparable repo worktree geometry for experimental nested folder workspaces', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: true, + isGrouped: true, + groupDepth: 4, + lineageDepth: 3 + }) + + expect(geometry).toEqual({ + surfaceInset: 54, + cardContentIndent: 6 + }) + expect(getFlushCardContentStart(geometry)).toBe(60) + }) + + it('keeps experimental manual grouped folder workspaces on normal worktree geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: false, + isGrouped: true, + groupDepth: 1, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 14, + cardContentIndent: 24 + }) + expect(getFlushCardContentStart(geometry)).toBe(38) + }) + + it('keeps experimental flat folder workspaces on normal worktree geometry', () => { + const geometry = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: true, + isFolderBackedWorkspaceChild: false, + isGrouped: false, + groupDepth: 3, + lineageDepth: 0 + }) + + expect(geometry).toEqual({ + surfaceInset: 0, + cardContentIndent: 0 + }) + expect(getFlushCardContentStart(geometry)).toBe(6) + }) + it('caps header indentation separately from workspace content indentation', () => { expect(getProjectGroupHeaderPaddingLeft(100)).toBe(70) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.ts index f312e197e3e..08787a8ae25 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.ts @@ -102,6 +102,61 @@ export function getFolderWorkspaceCardSurfaceInset(args: { return Math.min(genericSurfaceInset, Math.max(0, maxSurfaceInset)) } +export function getFolderWorkspaceRowGeometry(args: { + experimentalNewWorktreeCardStyle: boolean + isFolderBackedWorkspaceChild: boolean + isGrouped: boolean + groupDepth: number + lineageDepth: number +}): { + surfaceInset: number + cardContentIndent: number +} { + if (args.experimentalNewWorktreeCardStyle && args.isFolderBackedWorkspaceChild) { + // Why: standalone folder workspace rows do not get a lineage wrapper + // offset, so align them to the comparable folder-backed repo row anchor. + const contentAnchor = getFolderBackedRepoWorktreeCardContentIndent({ + groupDepth: args.groupDepth, + lineageDepth: 0 + }) + const surfaceInset = getFolderBackedRepoWorktreeCardSurfaceInset({ + groupDepth: args.groupDepth, + lineageDepth: 0 + }) + + return { + surfaceInset, + cardContentIndent: Math.max(0, contentAnchor - surfaceInset) + } + } + + const contentIndent = args.isFolderBackedWorkspaceChild + ? getFolderWorkspaceCardContentIndent({ + groupDepth: args.groupDepth + }) + : getWorktreeCardContentIndent({ + isGrouped: args.isGrouped, + groupDepth: args.groupDepth, + lineageDepth: args.lineageDepth + }) + // Why: legacy folder-scanned folder workspaces keep their compact anchor, + // while all other folder rows share the normal worktree row surface path. + const surfaceInset = args.isFolderBackedWorkspaceChild + ? getFolderWorkspaceCardSurfaceInset({ + isGrouped: true, + groupDepth: args.groupDepth + }) + : getWorktreeCardSurfaceInset({ + isGrouped: args.isGrouped, + groupDepth: args.groupDepth + }) + + return { + surfaceInset, + cardContentIndent: Math.max(0, contentIndent - surfaceInset) + } +} + export function getWorktreeCardSurfaceInset(args: { isGrouped: boolean groupDepth: number diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c05c5058070..db36d7b51b5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3779,7 +3779,8 @@ "8d62c68b35": "Notes", "2d74665a56": "Ports", "65a9820bd1": "Agent statuses", - "219ebf1961": "Branch name" + "219ebf1961": "Branch name", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Connecting...", @@ -3855,7 +3856,9 @@ "021538e1d1": "SSH disconnected", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "Current", "searchPlaceholder": "Search worktrees...", "empty": "No matching eligible worktrees." + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1aad6ed2381..495c24dfeaf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3765,7 +3765,8 @@ "automation": "Automation", "2d74665a56": "Puertos", "65a9820bd1": "Estados del agente", - "219ebf1961": "Nombre de rama" + "219ebf1961": "Nombre de rama", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -3841,7 +3842,9 @@ "021538e1d1": "SSH desconectado", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "Actual", "searchPlaceholder": "Buscar árboles de trabajo...", "empty": "No hay árboles de trabajo elegibles que coincidan." + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e2ac5ce512..78a9c4dffad 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3746,7 +3746,8 @@ "2d74665a56": "ポート", "65a9820bd1": "Agent ステータス", "219ebf1961": "ブランチ名", - "automation": "Automation" + "automation": "Automation", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "接続中...", @@ -3822,7 +3823,9 @@ "021538e1d1": "SSH が切断されました", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "現在", "searchPlaceholder": "ワークツリーを検索...", "empty": "一致する対象ワークツリーがありません。" + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d452e81de2c..00981aea841 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3746,7 +3746,8 @@ "2d74665a56": "포트", "65a9820bd1": "Agent 상태", "219ebf1961": "브랜치 이름", - "automation": "자동화" + "automation": "자동화", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "연결 중...", @@ -3822,7 +3823,9 @@ "021538e1d1": "SSH 연결이 끊어졌습니다.", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4301,6 +4304,9 @@ "current": "현재", "searchPlaceholder": "워크트리 검색...", "empty": "일치하는 적격 워크트리가 없습니다." + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0c7753af883..4aa4d8ad800 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1102,7 +1102,8 @@ "importHostSetup": "导入", "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", - "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。" + "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -3745,7 +3746,8 @@ "2d74665a56": "端口", "65a9820bd1": "Agent 状态", "219ebf1961": "分支名称", - "automation": "Automation" + "automation": "Automation", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "正在连接...", @@ -3821,7 +3823,9 @@ "021538e1d1": "SSH 已断开连接", "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", - "automationCreated": "Created by automation" + "automationCreated": "Created by automation", + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4300,6 +4304,9 @@ "current": "当前", "searchPlaceholder": "搜索工作树...", "empty": "没有匹配的可用工作树。" + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { From 5106e339477d236d0a708dfe7fbd9082d2a08253 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:16:20 -0700 Subject: [PATCH 8/8] fix(changes-tab): prevent freeze and stale highlights when staging/unstaging (#6229) * fix(changes-tab): prevent freeze and stale highlights when staging/unstaging When files are staged/unstaged while the Changes tab is open, the diff viewer was re-rendering all sections, causing UI freezes and highlight flickering. - Add resolveCombinedUncommittedSnapshotEntries() to reconcile snapshot entries with live git status without destroying existing loaded diff content. - Track retainedResolvedSnapshotEntries from current sections to preserve area state when live git status temporarily loses entries. - Use Map-based O(snapshot + live) algorithm instead of nested loops. - Add unit tests for the resolver logic. - Add e2e test with performance measurement for stale unstaged diffs. Co-authored-by: Orca * Prevent duplicate entries in uncommitted snapshot resolution Track resolved snapshot entries using composite area and path keys to ensure staged and unstaged sections remain distinct and do not duplicate when live Git status changes or disappears. Additionally, switch the retained entries from a Map to a list to support multiple areas per path, and update CombinedDiffViewer to avoid rebuilding the snapshot list on row load state changes. * test: add test coverage for duplicate-path snapshot remapping - Add unit test verifying that duplicate-path snapshots do not remap to a retained fallback area. - Robustify E2E large diff freeze repro test by ensuring interval timers are always cleared in a finally block. - Standardize path utilities in large diff fixtures to ensure cross-platform compatibility. --------- Co-authored-by: Orca --- .../components/editor/CombinedDiffViewer.tsx | 35 ++++- .../editor/combined-diff-entries.test.ts | 148 ++++++++++++++++++ .../editor/combined-diff-entries.ts | 90 ++++++++++- src/renderer/src/i18n/locales/zh.json | 2 +- tests/e2e/large-diff-freeze-repro.spec.ts | 133 +++++++++++----- tests/e2e/large-diff-repro-fixtures.ts | 93 +++++++++++ 6 files changed, 455 insertions(+), 46 deletions(-) create mode 100644 tests/e2e/large-diff-repro-fixtures.ts diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 00fdc450bbf..f140c5b392b 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -66,6 +66,7 @@ import { import { getCombinedBranchEntries, getCombinedUncommittedEntries, + resolveCombinedUncommittedSnapshotEntries, shouldAutoReloadCombinedDiffFromGitStatus } from './combined-diff-entries' import { getCombinedDiffCommitMessageBody } from './combined-diff-commit-message' @@ -127,6 +128,23 @@ function invalidateCombinedDiffCachesForRelativePath(relativePath: string): void } } +function getRetainedResolvedSnapshotEntries(sections: readonly DiffSection[]): GitStatusEntry[] { + return sections.flatMap((section) => + section.area === undefined + ? [] + : [ + { + path: section.path, + status: section.status as GitStatusEntry['status'], + area: section.area, + oldPath: section.oldPath, + added: section.added, + removed: section.removed + } + ] + ) +} + if (typeof window !== 'undefined') { window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, (event) => { const detail = (event as CustomEvent).detail @@ -423,11 +441,18 @@ export default function CombinedDiffViewer({ () => file.uncommittedEntriesSnapshot?.filter((e) => e.conflictStatus !== 'unresolved'), [file.uncommittedEntriesSnapshot] ) - const uncommittedEntries = React.useMemo( - () => - snapshotEntries ?? getCombinedUncommittedEntries(gitStatusEntries, file.combinedAreaFilter), - [snapshotEntries, gitStatusEntries, file.combinedAreaFilter] - ) + const uncommittedEntries = React.useMemo(() => { + if (!snapshotEntries) { + return getCombinedUncommittedEntries(gitStatusEntries, file.combinedAreaFilter) + } + // Why: row load state changes must not rebuild the snapshot entry list; + // the ref is only consulted when live Git status changes. + return resolveCombinedUncommittedSnapshotEntries( + snapshotEntries, + gitStatusEntries, + getRetainedResolvedSnapshotEntries(sectionsRef.current) + ) + }, [snapshotEntries, gitStatusEntries, file.combinedAreaFilter]) const branchEntries = React.useMemo(() => { return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) }, [file.branchEntriesSnapshot, liveBranchEntries]) diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index 859cd92d882..c3ff00ecab0 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getCombinedBranchEntries, getCombinedUncommittedEntries, + resolveCombinedUncommittedSnapshotEntries, shouldAutoReloadCombinedDiffFromGitStatus } from './combined-diff-entries' import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' @@ -50,6 +51,153 @@ describe('getCombinedUncommittedEntries', () => { }) }) +describe('resolveCombinedUncommittedSnapshotEntries', () => { + it('uses the live staged area when a snapshot unstaged file has been staged', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2, removed: 1 } + ] + const liveEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 2, removed: 1 } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, liveEntries)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 2, removed: 1 } + ]) + }) + + it('uses the live unstaged area when a snapshot staged file has been unstaged', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged' } + ] + const liveEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'unstaged' } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, liveEntries)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'unstaged' } + ]) + }) + + it('keeps the snapshot area when that area is still present for a path', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2 } + ] + const liveEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged' }, + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 9 } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, liveEntries)).toEqual( + snapshotEntries + ) + }) + + it('keeps a retained resolved area when live status no longer includes the path', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'unstaged' } + ] + const retained: GitStatusEntry[] = [{ path: 'src/file.ts', status: 'modified', area: 'staged' }] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, [], retained)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'staged' } + ]) + }) + + it('uses live metadata when a stale snapshot area moves to a rename entry', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/new.ts', status: 'modified', area: 'unstaged', oldPath: 'src/old-copy.ts' } + ] + const liveEntries: GitStatusEntry[] = [ + { + path: 'src/new.ts', + status: 'renamed', + area: 'staged', + oldPath: 'src/old.ts', + added: 3, + removed: 1 + } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, liveEntries)).toEqual([ + { + path: 'src/new.ts', + status: 'renamed', + area: 'staged', + oldPath: 'src/old.ts', + added: 3, + removed: 1 + } + ]) + }) + + it('keeps retained staged and unstaged sections distinct when live status disappears', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 }, + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2 } + ] + const retained: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged' }, + { path: 'src/file.ts', status: 'modified', area: 'unstaged' } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, [], retained)).toEqual( + snapshotEntries + ) + }) + + it('does not remap duplicate-path snapshots to a retained fallback area', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 }, + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2 } + ] + const retained: GitStatusEntry[] = [{ path: 'src/file.ts', status: 'modified', area: 'staged' }] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, [], retained)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 } + ]) + }) + + it('does not let a retained stale area duplicate an original target area', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2 }, + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 } + ] + const retained: GitStatusEntry[] = [{ path: 'src/file.ts', status: 'modified', area: 'staged' }] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, [], retained)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 } + ]) + }) + + it('drops a stale snapshot area when resolving it would duplicate an existing area', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2 }, + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 } + ] + const liveEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 6 } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, liveEntries)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 } + ]) + }) + + it('keeps the original target area when it appears before a stale area', () => { + const snapshotEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 }, + { path: 'src/file.ts', status: 'modified', area: 'unstaged', added: 2 } + ] + const liveEntries: GitStatusEntry[] = [ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 6 } + ] + + expect(resolveCombinedUncommittedSnapshotEntries(snapshotEntries, liveEntries)).toEqual([ + { path: 'src/file.ts', status: 'modified', area: 'staged', added: 4 } + ]) + }) +}) + describe('getCombinedBranchEntries', () => { it('uses an explicitly empty snapshot instead of falling back to live entries', () => { const liveEntries: GitBranchChangeEntry[] = [{ path: 'src/live.ts', status: 'modified' }] diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index e27e5037644..73d2fe44a70 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -19,6 +19,94 @@ export function getCombinedUncommittedEntries( }) } +export function resolveCombinedUncommittedSnapshotEntries( + snapshotEntries: readonly GitStatusEntry[], + liveEntries: readonly GitStatusEntry[], + retainedResolvedEntries: readonly GitStatusEntry[] = [] +): GitStatusEntry[] { + const liveEntriesByPath = getGitStatusEntriesByPath(liveEntries) + const retainedEntriesByPath = getGitStatusEntriesByPath(retainedResolvedEntries) + const snapshotAreaKeys = new Set(snapshotEntries.map(getUncommittedAreaPathKey)) + const resolvedEntries: GitStatusEntry[] = [] + const resolvedAreaKeys = new Set() + const pushResolvedEntry = (entry: GitStatusEntry): void => { + const areaKey = getUncommittedAreaPathKey(entry) + if (resolvedAreaKeys.has(areaKey)) { + return + } + resolvedAreaKeys.add(areaKey) + resolvedEntries.push(entry) + } + + for (const snapshotEntry of snapshotEntries) { + const livePathEntries = liveEntriesByPath.get(snapshotEntry.path) ?? [] + if (livePathEntries.some((liveEntry) => liveEntry.area === snapshotEntry.area)) { + pushResolvedEntry(snapshotEntry) + continue + } + + const retainedPathEntries = retainedEntriesByPath.get(snapshotEntry.path) ?? [] + if ( + livePathEntries.length === 0 && + retainedPathEntries.some((retainedEntry) => retainedEntry.area === snapshotEntry.area) + ) { + pushResolvedEntry(snapshotEntry) + continue + } + + const movedEntry = + livePathEntries[0] ?? (retainedPathEntries.length === 1 ? retainedPathEntries[0] : undefined) + if (!movedEntry || movedEntry.area === snapshotEntry.area) { + pushResolvedEntry(snapshotEntry) + continue + } + + const movedAreaKey = getUncommittedAreaPathKey({ + path: snapshotEntry.path, + area: movedEntry.area + }) + if (snapshotAreaKeys.has(movedAreaKey)) { + continue + } + if (resolvedAreaKeys.has(movedAreaKey)) { + continue + } + + // Why: a snapshot-backed Changes tab can outlive stage/unstage actions. + // Load the area Git now reports so Monaco doesn't diff identical files. + pushResolvedEntry({ + ...snapshotEntry, + area: movedEntry.area, + status: movedEntry.status, + oldPath: movedEntry.oldPath, + added: movedEntry.added, + removed: movedEntry.removed, + submodule: movedEntry.submodule + }) + } + + return resolvedEntries +} + +function getGitStatusEntriesByPath( + entries: readonly GitStatusEntry[] +): Map { + const entriesByPath = new Map() + for (const entry of entries) { + const pathEntries = entriesByPath.get(entry.path) + if (pathEntries) { + pathEntries.push(entry) + } else { + entriesByPath.set(entry.path, [entry]) + } + } + return entriesByPath +} + +function getUncommittedAreaPathKey(entry: Pick): string { + return `${entry.area}\0${entry.path}` +} + export function getCombinedBranchEntries( snapshotEntries: readonly GitBranchChangeEntry[] | undefined, liveEntries: readonly GitBranchChangeEntry[] @@ -35,7 +123,7 @@ export function shouldAutoReloadCombinedDiffFromGitStatus({ mode: CombinedDiffFileTreeMode hasUncommittedEntriesSnapshot: boolean }): boolean { - // Why: snapshot-backed tabs intentionally preserve the tab-open diff while + // Why: snapshot-backed tabs preserve the tab-open file list while // staging/commit status churns; targeted editor-write reloads still refresh. return mode === 'uncommitted' && !hasUncommittedEntriesSnapshot } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4aa4d8ad800..36f73ea9abb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1103,7 +1103,7 @@ "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", - "createMultiple": "Create more" + "createMultiple": "创建更多" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" diff --git a/tests/e2e/large-diff-freeze-repro.spec.ts b/tests/e2e/large-diff-freeze-repro.spec.ts index 2696e45e767..dc8292d5427 100644 --- a/tests/e2e/large-diff-freeze-repro.spec.ts +++ b/tests/e2e/large-diff-freeze-repro.spec.ts @@ -1,38 +1,13 @@ -import { execFileSync } from 'child_process' -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs' -import os from 'os' -import path from 'path' -import { randomUUID } from 'crypto' +import { rmSync, writeFileSync } from 'fs' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' import { getLargeDiffRenderLimit } from '../../src/shared/large-diff-render-limit' - -type IsolatedLargeDiffRepo = { - repoPath: string - relativePath: string - absolutePath: string -} - -function runGit(repoPath: string, args: string[]): void { - execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) -} - -function createIsolatedLargeDiffRepo(): IsolatedLargeDiffRepo { - const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-large-diff-repro-'))) - runGit(repoPath, ['init']) - runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) - runGit(repoPath, ['config', 'user.name', 'E2E Test']) - - mkdirSync(path.join(repoPath, 'src'), { recursive: true }) - const relativePath = path.join('src', `large-diff-${randomUUID()}.ts`) - const absolutePath = path.join(repoPath, relativePath) - writeFileSync(absolutePath, 'export const seed = 1\n') - runGit(repoPath, ['add', '-A']) - runGit(repoPath, ['commit', '-m', 'Initial large diff repro fixture']) - - return { repoPath, relativePath, absolutePath } -} +import { + buildLargeTypeScriptFile, + createIsolatedLargeDiffRepo, + createIsolatedStagedLocaleDiffRepo +} from './large-diff-repro-fixtures' async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise { const repoId = await orcaPage.evaluate(async (pathToRepo: string) => { @@ -92,14 +67,6 @@ async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise { test.describe.configure({ mode: 'serial' }) test.use({ seedTestRepo: false }) @@ -192,4 +159,92 @@ test.describe('Large diff freeze repro', () => { rmSync(fixture.repoPath, { recursive: true, force: true }) } }) + + test('opening stale unstaged combined diffs after staging keeps the renderer responsive', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + const fixture = createIsolatedStagedLocaleDiffRepo() + + try { + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const measurement = await orcaPage.evaluate( + async ({ wId, repoPath, expectedPaths }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const status = await window.api.git.status({ worktreePath: repoPath }) + store.getState().setGitStatus(wId, status) + const entries = status.entries.filter((entry) => entry.area === 'staged') + const entryPaths = entries.map((entry) => entry.path) + const missing = expectedPaths.filter((path) => !entryPaths.includes(path)) + if (missing.length > 0) { + throw new Error(`staged locale fixture missing entries: ${missing.join(', ')}`) + } + + // Why: reproduce stale snapshot behavior by opening combined diffs + // as "unstaged" using entries captured from the staged status snapshot. + const staleUnstagedEntries = entries.map((entry) => ({ ...entry, area: 'unstaged' })) + const intervalMs = 50 + const samples: number[] = [] + let last = performance.now() + let maxLagMs = 0 + const timer = window.setInterval(() => { + const now = performance.now() + const lag = Math.max(0, now - last - intervalMs) + maxLagMs = Math.max(maxLagMs, lag) + samples.push(lag) + last = now + }, intervalMs) + + const startedAt = performance.now() + store.getState().openAllDiffs(wId, repoPath, undefined, 'unstaged', staleUnstagedEntries) + + let editorCount = 0 + let fallbackCount = 0 + try { + while (performance.now() - startedAt < 30_000) { + await new Promise((resolve) => window.setTimeout(resolve, 50)) + editorCount = document.querySelectorAll('.monaco-diff-editor').length + fallbackCount = document.querySelectorAll( + '[data-testid="large-diff-fallback"]' + ).length + if (editorCount + fallbackCount >= Math.min(entries.length, 5)) { + await new Promise((resolve) => window.setTimeout(resolve, 1_000)) + break + } + } + } finally { + window.clearInterval(timer) + } + + const classHits = Array.from( + document.querySelectorAll( + '.monaco-diff-editor .line-insert, .monaco-diff-editor .line-delete, .monaco-diff-editor .char-insert, .monaco-diff-editor .char-delete' + ) + ).length + return { + editorCount, + fallbackCount, + classHits, + maxLagMs, + sampleCount: samples.length, + p95LagMs: samples.length + ? [...samples].sort((a, b) => a - b)[Math.floor(samples.length * 0.95)] + : 0 + } + }, + { wId: worktreeId, repoPath: fixture.repoPath, expectedPaths: fixture.relativePaths } + ) + + console.log(`stale unstaged combined diff measurement ${JSON.stringify(measurement)}`) + expect(measurement.editorCount + measurement.fallbackCount).toBeGreaterThanOrEqual(5) + expect(measurement.classHits).toBeGreaterThan(0) + expect(measurement.maxLagMs).toBeLessThan(1_000) + } finally { + rmSync(fixture.repoPath, { recursive: true, force: true }) + } + }) }) diff --git a/tests/e2e/large-diff-repro-fixtures.ts b/tests/e2e/large-diff-repro-fixtures.ts new file mode 100644 index 00000000000..fe1f803a46a --- /dev/null +++ b/tests/e2e/large-diff-repro-fixtures.ts @@ -0,0 +1,93 @@ +import { execFileSync } from 'child_process' +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import { randomUUID } from 'crypto' + +export type IsolatedLargeDiffRepo = { + repoPath: string + relativePath: string + absolutePath: string +} + +export type IsolatedStagedLocaleDiffRepo = { + repoPath: string + relativePaths: string[] +} + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +export function createIsolatedLargeDiffRepo(): IsolatedLargeDiffRepo { + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-large-diff-repro-'))) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + + mkdirSync(path.join(repoPath, 'src'), { recursive: true }) + const relativePath = path.join('src', `large-diff-${randomUUID()}.ts`) + const absolutePath = path.join(repoPath, relativePath) + writeFileSync(absolutePath, 'export const seed = 1\n') + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial large diff repro fixture']) + + return { repoPath, relativePath, absolutePath } +} + +export function buildLargeTypeScriptFile(lineCount: number): string { + const lines: string[] = [] + for (let i = 0; i < lineCount; i += 1) { + lines.push(`export const largeDiffValue${i} = ${i}`) + } + return `${lines.join('\n')}\n` +} + +function buildLocaleLikeJson(fileIndex: number, entryCount: number): string { + const lines = ['{'] + for (let i = 0; i < entryCount; i += 1) { + const value = `locale ${fileIndex} original ${i} `.repeat(5).trim() + const comma = i + 1 === entryCount ? '' : ',' + lines.push(` "entry_${String(i).padStart(5, '0')}": "${value}"${comma}`) + } + lines.push('}') + return `${lines.join('\n')}\n` +} + +function modifyLocaleLikeJson(content: string, fileIndex: number): string { + const lines = content.split('\n') + const changedLineIndex = 3200 + fileIndex + lines[changedLineIndex] = lines[changedLineIndex].replace('original', 'updated') + lines.splice(changedLineIndex + 4, 1) + return lines.join('\n') +} + +export function createIsolatedStagedLocaleDiffRepo(): IsolatedStagedLocaleDiffRepo { + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-staged-locale-repro-'))) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + + mkdirSync(path.join(repoPath, 'src', 'locales'), { recursive: true }) + const toAbsoluteFsPath = (relativePosixPath: string): string => + path.join(repoPath, ...relativePosixPath.split(path.posix.sep)) + const relativePaths: string[] = [] + for (let fileIndex = 0; fileIndex < 5; fileIndex += 1) { + const relativePath = path.posix.join('src', 'locales', `locale-${fileIndex}.json`) + const absolutePath = toAbsoluteFsPath(relativePath) + const original = buildLocaleLikeJson(fileIndex, 3600) + writeFileSync(absolutePath, original) + relativePaths.push(relativePath) + } + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial locale fixture']) + + for (let fileIndex = 0; fileIndex < relativePaths.length; fileIndex += 1) { + const absolutePath = toAbsoluteFsPath(relativePaths[fileIndex]) + const original = buildLocaleLikeJson(fileIndex, 3600) + writeFileSync(absolutePath, modifyLocaleLikeJson(original, fileIndex)) + } + runGit(repoPath, ['add', '-A']) + + return { repoPath, relativePaths } +}