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 } 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)) } 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/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/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/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) 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 }) 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/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5c2803fd6d0..77b75f83a25 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3391,22 +3391,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}}.", @@ -3791,7 +3786,8 @@ "8d62c68b35": "Notes", "2d74665a56": "Ports", "65a9820bd1": "Agent statuses", - "219ebf1961": "Branch name" + "219ebf1961": "Branch name", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Connecting...", @@ -3867,7 +3863,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" @@ -4313,6 +4311,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 dcb3e134d4b..b35f94a20f9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3385,26 +3385,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", @@ -3777,7 +3772,8 @@ "automation": "Automation", "2d74665a56": "Puertos", "65a9820bd1": "Estados del agente", - "219ebf1961": "Nombre de rama" + "219ebf1961": "Nombre de rama", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -3853,7 +3849,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" @@ -4313,6 +4311,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 49398c1c8ca..f64e9c6b906 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3366,26 +3366,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": "スキャンの停止", @@ -3758,7 +3753,8 @@ "2d74665a56": "ポート", "65a9820bd1": "Agent ステータス", "219ebf1961": "ブランチ名", - "automation": "Automation" + "automation": "Automation", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "接続中...", @@ -3834,7 +3830,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" @@ -4313,6 +4311,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 72af1db00ad..c58141e1d7c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3366,26 +3366,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": "스캔 중지", @@ -3758,7 +3753,8 @@ "2d74665a56": "포트", "65a9820bd1": "Agent 상태", "219ebf1961": "브랜치 이름", - "automation": "자동화" + "automation": "자동화", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "연결 중...", @@ -3834,7 +3830,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" @@ -4313,6 +4311,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 f6431fd9d2c..667cf827215 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1110,7 +1110,7 @@ "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", - "createMultiple": "继续创建" + "createMultiple": "创建更多" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -3366,26 +3366,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": "停止扫描", @@ -3758,7 +3753,8 @@ "2d74665a56": "端口", "65a9820bd1": "Agent 状态", "219ebf1961": "分支名称", - "automation": "Automation" + "automation": "Automation", + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "正在连接...", @@ -3834,7 +3830,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" @@ -4313,6 +4311,9 @@ "current": "当前", "searchPlaceholder": "搜索工作树...", "empty": "没有匹配的可用工作树。" + }, + "WorktreeCardStatusSlot": { + "branchIdentity": "Branch" } }, "shared": { 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( 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 } +} 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,