Merge branch 'main' of https://github.com/stablyai/orca into brennanb2025/issue-5918-automation-setup-hook

# Conflicts:
#	src/renderer/src/i18n/locales/zh.json
This commit is contained in:
brennanb2025
2026-06-23 22:32:28 -07:00
53 changed files with 2390 additions and 328 deletions
@@ -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({
@@ -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'])
@@ -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)
})
})
@@ -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()
+1 -1
View File
@@ -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
}
+7 -103
View File
@@ -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<unknown>
@@ -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 ?? '<unknown>'}`
)
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 {
@@ -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<string, unknown> {
if (!msg.payload) {
return {}
}
try {
const parsed: unknown = JSON.parse(msg.payload)
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
} 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 ?? '<unknown>'}`
)
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 }
}
@@ -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<string, unknown> = {}
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(
@@ -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))
}
@@ -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<EditorPathMutationTarget>).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<GitBranchChangeEntry[]>(() => {
return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries)
}, [file.branchEntriesSnapshot, liveBranchEntries])
@@ -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' }]
@@ -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<string>()
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<string, GitStatusEntry[]> {
const entriesByPath = new Map<string, GitStatusEntry[]>()
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<GitStatusEntry, 'area' | 'path'>): 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
}
@@ -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</button>')
})
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</button>')
})
@@ -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(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
expect(html).toMatch(/<button[^>]*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()
})
})
@@ -105,13 +105,13 @@ export function AddRepoNestedImportStep({
<p className="text-sm font-medium text-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69',
'Is this a monorepo?'
'Group these repositories?'
)}
</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
@@ -156,7 +156,7 @@ export function AddRepoNestedImportStep({
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
'Import as group'
'Yes, import as group'
)}
</Button>
</div>
@@ -30,6 +30,7 @@ vi.mock('@/store', () => ({
linearIssueCache: {},
openModal,
openTaskPage: vi.fn(),
projectGroups: [],
ptyIdsByTabId: {},
remoteBranchConflictByWorktreeId: {},
renamingWorktreeId: null,
@@ -17,6 +17,7 @@ const setWorkspacePortScanRefreshing = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports']
let hostedReviewCache: Record<string, unknown> = {}
let projectGroups: unknown[] = []
let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null
let settings: Partial<GlobalSettings> | 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(
<WorktreeCard
worktree={makeWorktree({
displayName: 'Docs folder',
branch: '',
path: ' /Users/x/projects/my-app '
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
hideRepoBadge
/>
)
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(
<WorktreeCard
worktree={makeWorktree({
displayName: 'Docs folder',
branch: '',
path: ' /Users/x/projects/my-app '
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain(' /Users/x/projects/my-app ')
expect(markup).not.toContain('>Folder</span>')
})
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(
<WorktreeCard
worktree={makeWorktree({
displayName: 'Docs folder',
branch: '',
path: '/Users/x/projects/my-app'
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
hideRepoBadge
/>
)
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']
@@ -33,6 +33,7 @@ vi.mock('@/store', () => ({
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
settings: { experimentalNewWorktreeCardStyle: true },
sshConnectionStates: new Map(),
@@ -24,6 +24,7 @@ vi.mock('@/store', () => ({
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
settings: null,
sshConnectionStates: new Map(),
@@ -25,6 +25,7 @@ vi.mock('@/store', () => ({
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
settings,
sshConnectionStates: new Map(),
@@ -29,6 +29,7 @@ vi.mock('@/store', () => ({
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
settings,
sshConnectionStates: new Map(),
@@ -21,6 +21,7 @@ let tabsByWorktree: Record<string, { id: string }[]> = {}
let ptyIdsByTabId: Record<string, string[]> = {}
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
let settings: Partial<GlobalSettings> | null = null
let projectGroups: unknown[] = []
let workspaceDeleteModifierPressed = false
let gitConflictOperationByWorktree: Record<string, GitConflictOperation> = {}
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(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Docs folder', branch: '' })}
@@ -191,12 +194,12 @@ describe('WorktreeCard quick actions', () => {
)
expect(markup).toContain('Docs folder')
expect(markup).toContain('>Folder</span>')
expect(markup).not.toContain('>Folder</span>')
expect(markup).toContain('>quick-action</span>')
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(
<WorktreeCard
worktree={makeWorktree({
@@ -211,11 +214,33 @@ describe('WorktreeCard quick actions', () => {
)
expect(markup).toContain('Docs folder')
expect(markup).toContain('>Folder</span>')
expect(markup).not.toContain('>Folder</span>')
expect(markup).toContain('>quick-action</span>')
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(
<WorktreeCard
worktree={makeWorktree({
id: 'folder:folder-1',
displayName: 'Docs folder',
branch: '',
path: '/repo/worktrees/quick-action'
})}
repo={undefined}
isActive={false}
/>
)
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(
<WorktreeCard
@@ -28,6 +28,7 @@ vi.mock('@/store', () => ({
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
runtimeStatusByEnvironmentId,
settings: null,
@@ -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<HTMLDivElement>) => 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)}
/>
</div>
) : null}
@@ -1372,17 +1388,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
/>
{isFolder && (
<Badge
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
>
{repo
? getRepoKindLabel(repo)
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
</Badge>
)}
{typeof worktree.firstAgentMessageRenameError === 'string' &&
worktree.firstAgentMessageRenameError.length > 0 &&
!titleRenaming ? (
@@ -1542,7 +1547,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
</Badge>
)}
{isFolder ? (
{showIdentityInNewCard ? (
<TruncatedSidebarLabel
text={identityDisplay!}
className="text-[11px] text-muted-foreground leading-none"
tooltipEnabled={!hasHoverDetails}
/>
) : isFolder && !newCardStyle ? (
<span
className="min-w-0 truncate font-mono text-[11px] leading-none text-muted-foreground"
title={worktree.path}
@@ -11,6 +11,7 @@ const setWorktreeCardProperties = vi.fn()
const setAgentActivityDisplayMode = vi.fn()
let settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
let projectGroups: unknown[] = []
let worktreeCardProperties = [
'status',
'unread',
@@ -27,6 +28,7 @@ vi.mock('@/store', () => ({
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')
})
})
@@ -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 => {
@@ -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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -221,12 +221,33 @@ describe('WorktreeCardStatusSlot', () => {
)
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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
hasBranchIdentity
branchIdentityLabel="Branch or folder path"
/>
)
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(
<WorktreeCardStatusSlot
@@ -1,6 +1,7 @@
import React from 'react'
import { Bell, GitBranch } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
import { FilledBellIcon } from './WorktreeCardHelpers'
@@ -20,13 +21,16 @@ type WorktreeCardStatusSlotProps = {
prDisplay?: WorktreeCardPrDisplay | null
newCardStyle?: boolean
hasBranchIdentity?: boolean
branchIdentityLabel?: string
className?: string
}
const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set<WorktreeStatus>(['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
@@ -340,6 +340,7 @@ function makeFolderWorkspacePathStatusState(): Record<string, unknown> {
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()
@@ -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<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
repoOrder: Map<string, number>
// 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<string, unknown> | null
hostedReviewCache: Record<string, unknown> | 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 (
<div
key={vItem.key}
@@ -4815,7 +4815,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
repo={undefined}
isActive={activeWorktreeId === folderWorktree.id}
isCurrentWorktree={currentWorktreeId === folderWorktree.id}
contentIndent={insetContentIndent}
contentIndent={cardContentIndent}
flushSurface
nativeDragEnabled={false}
onImmediateActivate={
@@ -4826,6 +4826,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activationRowKey={folderWorktree.id}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
statusPrDisplay={folderPrDisplay}
/>
<div className="pointer-events-auto absolute right-3 top-1.5">
<FolderPathStatusIndicator status={folderWorkspacePathStatus} />
@@ -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}
@@ -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<Worktree> & { 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'
}
}
@@ -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<string, WorkspaceLineage> | null | undefined
worktreeLineageById: Record<string, WorktreeLineage> | null | undefined
worktreeMap: ReadonlyMap<string, Worktree>
repoMap: ReadonlyMap<string, Repo>
hostedReviewCache: Record<string, unknown> | null
prCache: Record<string, unknown> | null
settings?: AppState['settings']
}
const REVIEW_STATUS_PRIORITY: Record<NonNullable<WorktreeCardPrDisplay['status']>, 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<string, Worktree>
): 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<string, unknown> | null
prCache: Record<string, unknown> | 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<string, unknown> | 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
}
@@ -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')
})
})
@@ -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
]
}
@@ -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)
})
@@ -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
@@ -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(
@@ -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)
@@ -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 () => {
@@ -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<void> {
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 {
@@ -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<void> {
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 })
@@ -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
@@ -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
}
+12 -11
View File
@@ -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": {
+12 -11
View File
@@ -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": {
+12 -11
View File
@@ -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": {
+12 -11
View File
@@ -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": {
+13 -12
View File
@@ -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": {
@@ -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(
+6 -6
View File
@@ -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(
+94 -39
View File
@@ -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<string> {
const repoId = await orcaPage.evaluate(async (pathToRepo: string) => {
@@ -92,14 +67,6 @@ async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise<str
return worktreeId
}
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`
}
test.describe('Large diff freeze repro', () => {
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 })
}
})
})
+93
View File
@@ -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 }
}
+100 -1
View File
@@ -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<void> {
await page.mouse.click(point.x, point.y, { button: 'right' })
}
async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise<void> {
await app.evaluate(({ BrowserWindow, ipcMain }) => {
type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise<unknown> | unknown
const global = globalThis as unknown as {
__orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
}
const invokeHandlers = (
ipcMain as unknown as {
_invokeHandlers?: Map<string, ClipboardReadHandler>
}
)._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<void> {
await app.evaluate(({ ipcMain }) => {
type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise<unknown> | unknown
const global = globalThis as unknown as {
__orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
}
const invokeHandlers = (
ipcMain as unknown as {
_invokeHandlers?: Map<string, ClipboardReadHandler>
}
)._invokeHandlers
if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) {
invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler)
delete global.__orcaOriginalClipboardReadTextHandler
}
})
}
async function openTerminalContextMenu(page: Page): Promise<void> {
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,