mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user