Merge remote-tracking branch 'origin/main' into brennanb2025/retire-generated-workspace-names

This commit is contained in:
Brennan Benson
2026-08-13 14:36:58 -07:00
52 changed files with 1165 additions and 496 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ If your change affects UI or interaction behavior, verify it on the platforms it
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
CI enforces this for `src/preload/` and `src/shared/`.
## Pull Requests
+2 -2
View File
@@ -74,14 +74,14 @@ jobs:
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See docs/preload-typecheck-hole.md.
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
- name: Guard against project-owned .d.ts in preload/shared
run: |
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
if [ -n "$matches" ]; then
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
echo "See docs/preload-typecheck-hole.md."
echo "See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts."
echo "Found:"
echo "$matches"
exit 1
@@ -1,6 +1,8 @@
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
@@ -18,6 +20,7 @@ import {
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
const execFileAsync = promisify(execFile)
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
@@ -92,6 +95,56 @@ describe('bundled skill guide generator', () => {
}
})
it('uses the exported recipe id variable in per-workspace environment examples', async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
expect(source).toContain('ORCA_RECIPE_ID')
expect(source).not.toContain('ORCA_VM_RECIPE_ID')
expect(source).toContain('recipe_id="${recipe_id//./-}"')
expect(source).toContain('max_recipe_id_length=$((128 - ${#instance_id} - 6))')
expect(source).toContain('name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"')
})
it.skipIf(process.platform === 'win32')(
'keeps Vercel sandbox names valid while preserving the instance suffix',
async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
const startMarker = 'recipe_id="${ORCA_RECIPE_ID:-vercel-sandbox}"'
const endMarker = 'name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"'
const start = source.indexOf(startMarker)
const endStart = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(endStart).toBeGreaterThan(start)
const script = `${source.slice(start, endStart + endMarker.length)}\nprintf '%s' "$name"`
const renderName = async (recipeId, instanceId) =>
(
await execFileAsync('bash', ['-u', '-c', script], {
env: { ...process.env, ORCA_RECIPE_ID: recipeId, ORCA_VM_INSTANCE_ID: instanceId }
})
).stdout
const instanceId = 'orca-123e4567-e89b-12d3-a456-426614174000'
const dotted = await renderName('provider.cloud_sandbox', instanceId)
const maximum = await renderName(`a${'.'.repeat(63)}`, instanceId)
const longInstanceId = 'i'.repeat(100)
const capped = await renderName(
'provider.cloud_sandbox.with.a.long.recipe.identifier',
longInstanceId
)
expect(dotted).toBe(`orca-provider-cloud_sandbox-${instanceId}`)
expect(maximum).toMatch(/^[a-zA-Z0-9_-]{1,128}$/u)
expect(capped).toHaveLength(128)
expect(capped.endsWith(`-${longInstanceId}`)).toBe(true)
}
)
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
+7 -2
View File
@@ -266,7 +266,7 @@ set -euo pipefail
set -euo pipefail
# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)
# fail clearly if snapshotId is missing (point back to Phases 2–3)
# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)
# name = orca-${ORCA_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)
# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address
# (an externally reachable wss:// URL); trap: remove sandbox on error
# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)
@@ -369,7 +369,12 @@ set -euo pipefail
vercel_args=(); [ -n "$scope" ] && vercel_args+=(--scope "$scope"); [ -n "$project" ] && vercel_args+=(--project "$project")
[ -n "$snapshot_id" ] || { echo "snapshotId missing — run Phases 2–3 first" >&2; exit 1; }
gh_token="${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}"
name="orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}" # sanitize+cap to 63 chars
recipe_id="${ORCA_RECIPE_ID:-vercel-sandbox}"
recipe_id="${recipe_id//./-}" # Vercel names forbid dots.
instance_id="${ORCA_VM_INSTANCE_ID:-$(date +%s)}"
max_recipe_id_length=$((128 - ${#instance_id} - 6)) # Preserve the unique instance suffix.
[ "$max_recipe_id_length" -gt 0 ] || { echo "ORCA_VM_INSTANCE_ID is too long for a Vercel sandbox name" >&2; exit 1; }
name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"
# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.
cleanup_on_error() { [ "$?" -ne 0 ] && vercel sandbox remove "$name" "${vercel_args[@]}" >/dev/null 2>&1 || true; }
File diff suppressed because one or more lines are too long
@@ -159,6 +159,8 @@ export async function installTranscriptWatcher(
if (contentReplaced) {
resetIncrementalTranscriptState(state)
}
// Why: subscriber callbacks may replace the path before the drain can finish.
watchedVersion ??= current
const replacementSnapshot =
// Why: 0 is a valid window — an explicit undefined check keeps an empty
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import type { FSWatcher } from 'node:fs'
import { renameSync, type FSWatcher } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { appendFile, mkdir, mkdtemp, rename, rm, utimes, writeFile } from 'node:fs/promises'
import { appendFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -33,7 +33,6 @@ vi.mock('node:fs', async () => {
import { subscribeNativeChatTranscript } from './transcript-watch'
const roots: string[] = []
const HOST_FILESYSTEM_RECONCILIATION_TIMEOUT_MS = 8_000
afterEach(async () => {
vi.useRealTimers()
@@ -187,7 +186,7 @@ describe('native chat transcript watcher liveness', () => {
const root = dirname(filePath)
const oldRoot = `${root}.old`
roots.push(oldRoot)
const snapshots = vi.fn()
const snapshots = vi.fn(() => renameSync(root, oldRoot))
const replacements = vi.fn()
const appends = vi.fn()
const subscription = await subscribeNativeChatTranscript({
@@ -203,13 +202,9 @@ describe('native chat transcript watcher liveness', () => {
})
await waitFor(() => snapshots.mock.calls.length === 1)
await rename(root, oldRoot)
await mkdir(root)
await writeFile(filePath, claudeLine('new-file', 'user', 'after'))
await waitFor(
() => replacements.mock.calls.length === 1 && watchers.length === 2,
HOST_FILESYSTEM_RECONCILIATION_TIMEOUT_MS
)
await writeFile(filePath, claudeLine('new-file', 'user', 'after!'))
await waitFor(() => replacements.mock.calls.length === 1 && watchers.length === 2)
await appendFile(filePath, claudeLine('new-followup', 'assistant', 'event-driven'))
watchCallbacks[1]!('change', 'transcript.jsonl')
await waitFor(() => appends.mock.calls.flat(2).some((message) => message.id === 'new-followup'))
+304
View File
@@ -1114,6 +1114,16 @@ class InMemoryOrchestrationMessages {
return this.getUnreadMessages(toHandle, types).filter((message) => !message.delivered_at)
}
getUndeliveredUnreadMailboxHandles(): string[] {
return [
...new Set(
this.messages
.filter((message) => message.read === 0 && !message.delivered_at)
.map((message) => message.to_handle)
)
]
}
setActiveCoordinatorRun(run: { coordinator_handle: string } | null): void {
this.activeCoordinatorRun = run
}
@@ -34226,6 +34236,292 @@ describe('OrcaRuntimeService', () => {
}
})
it('repoints worker_done when a stale waiter wakes without consuming it', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
setInMemoryOrchestrationMessages(runtime, db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
db.setRun({
id: 'run_stale_waiter',
coordinator_handle: terminal.handle,
coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}`
})
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
write.mockClear()
const staleWait = runtime.waitForMessage('run:run_stale_waiter', {
typeFilter: ['worker_done'],
timeoutMs: 60_000
})
db.insertMessage({
from: 'term_final_worker',
to: 'run:run_stale_waiter',
subject: 'final worker settled',
type: 'worker_done'
})
runtime.notifyMessageArrived('run:run_stale_waiter', 'worker_done')
await expect(staleWait).resolves.toBe('notified')
expect(write).not.toHaveBeenCalled()
db.insertMessage({
from: 'term_other_worker',
to: 'run:run_stale_waiter',
subject: 'newer status',
type: 'status'
})
runtime.deliverPendingMessagesForHandle('run:run_stale_waiter', new Set(['worker_done']))
await vi.advanceTimersByTimeAsync(500)
const pointers = () =>
write.mock.calls.filter(
([, payload]) =>
typeof payload === 'string' && payload.includes('orca orchestration check')
)
expect(pointers()).toHaveLength(1)
expect(pointers()[0]?.[1]).toContain('You have 1 orchestration message')
await vi.advanceTimersByTimeAsync(1_500)
expect(pointers()).toHaveLength(2)
expect(pointers()[1]?.[1]).toContain('You have 2 orchestration messages')
db.close()
} finally {
vi.useRealTimers()
}
})
it('repoints on the retry edge when live idle won the send race', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
setInMemoryOrchestrationMessages(runtime, db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
db.insertMessage({
from: 'term_worker',
to: terminal.handle,
subject: 'settled at idle edge',
type: 'worker_done'
})
runtime.notifyMessageArrived(terminal.handle, 'worker_done')
await Promise.resolve()
expect(write).not.toHaveBeenCalled()
const leaf = [
...(
runtime as unknown as {
leaves: Map<
string,
{ lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean }
>
}
).leaves.values()
][0]
leaf.lastAgentStatus = 'idle'
leaf.lastAgentStatusObservedLive = true
await vi.advanceTimersByTimeAsync(2_000)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
db.close()
} finally {
vi.useRealTimers()
}
})
it('leaves later delivery to the idle edge instead of polling a working mailbox', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const pendingReads = vi.spyOn(db, 'getUndeliveredUnreadMessages')
const write = vi.fn().mockReturnValue(true)
setInMemoryOrchestrationMessages(runtime, db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'wait for idle' })
runtime.notifyMessageArrived(terminal.handle, 'status')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(20_000)
expect(pendingReads).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
expect(pendingReads).toHaveBeenCalledTimes(1)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
db.close()
} finally {
vi.useRealTimers()
}
})
it('points restored mail when a live-idle PTY remounts after the repair edge', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.registerPreAllocatedHandleForPty('pty-1', terminal.handle)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
runtime.markRendererReloading(1)
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'restored' })
setInMemoryOrchestrationMessages(runtime, db)
await vi.advanceTimersByTimeAsync(2_000)
expect(write).not.toHaveBeenCalled()
syncSinglePty(runtime)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
db.close()
} finally {
vi.useRealTimers()
}
})
it('does not keep retrying when every pending row was already pointed', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
setInMemoryOrchestrationMessages(runtime, db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'once' })
runtime.notifyMessageArrived(terminal.handle, 'status')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(2_500)
expect(
write.mock.calls.filter(
([, payload]) =>
typeof payload === 'string' && payload.includes('orca orchestration check')
)
).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
db.close()
} finally {
vi.useRealTimers()
}
})
it('repoints pending rows restored with a live-idle coordinator', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
db.insertMessage({
from: 'term_worker',
to: terminal.handle,
subject: 'survived restart',
type: 'worker_done'
})
write.mockClear()
setInMemoryOrchestrationMessages(runtime, db)
await vi.advanceTimersByTimeAsync(2_000)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
db.close()
} finally {
vi.useRealTimers()
}
})
it('stops a pending repoint after its database closes', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new OrchestrationDb(':memory:')
const write = vi.fn().mockReturnValue(true)
runtime.setOrchestrationDb(db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
db.insertMessage({ from: 'term_worker', to: terminal.handle, subject: 'pending' })
runtime.notifyMessageArrived(terminal.handle, 'status')
db.close()
await vi.advanceTimersByTimeAsync(2_000)
expect(write).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('points already-idle Run mail after Codex replaces its completion title', async () => {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
@@ -34856,6 +35152,14 @@ describe('OrcaRuntimeService', () => {
await vi.advanceTimersByTimeAsync(500)
const enterWrites = write.mock.calls.filter(([, payload]) => payload === '\r')
expect(enterWrites).toHaveLength(1)
await vi.advanceTimersByTimeAsync(2_000)
expect(
write.mock.calls.filter(
([, payload]) =>
typeof payload === 'string' && payload.includes('orca orchestration check')
)
).toHaveLength(1)
db.close()
} finally {
vi.useRealTimers()
+90 -13
View File
@@ -165,6 +165,7 @@ import type {
import { clearFederationAckCheckpoints } from './orchestration/federation-ack-checkpoints'
import { syncFederatedDispatch } from './orchestration/federation-sync'
import { formatMessagePointer } from './orchestration/formatter'
import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoint-scheduler'
import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session'
import type {
Automation,
@@ -2842,6 +2843,11 @@ export class OrcaRuntimeService {
private handleByPtyId = new Map<string, string>()
// Why: pointer state is process-local; one harmless replay after restart avoids a wire or schema change.
private readonly lastPointedMessageSequenceByHandle = new Map<string, number>()
// Why: a waiter can reserve an older row while a newer row advances the sequence watermark.
private readonly pointedMessageIdsByHandle = new Map<string, Set<string>>()
private readonly mailPointerRepointScheduler = new MailPointerRepointScheduler((handle) =>
this.repointPendingMessagesForHandle(handle)
)
private syntheticTerminalHandles = new Set<string>()
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
private graphSyncCallbacks: (() => void)[] = []
@@ -3862,14 +3868,19 @@ export class OrcaRuntimeService {
const dbPath = join(app.getPath('userData'), 'orchestration.db')
this._orchestrationDb = new OrchestrationDb(dbPath)
this.ensureOrchestrationFederationRelay()
this.scheduleRestoredMessageRepoints()
}
return this._orchestrationDb
}
setOrchestrationDb(db: OrchestrationDb): void {
this.stopOrchestrationFederationRelay()
this.mailPointerRepointScheduler.clear()
this.lastPointedMessageSequenceByHandle.clear()
this.pointedMessageIdsByHandle.clear()
this._orchestrationDb = db
this.ensureOrchestrationFederationRelay()
this.scheduleRestoredMessageRepoints()
}
private getLegacyWorkerTerminalRecoveryPlan(): LegacyWorkerTerminalRecoveryPlan {
@@ -5568,6 +5579,7 @@ export class OrcaRuntimeService {
throw new Error('Runtime graph publisher does not match the authoritative window')
}
const graphWasReady = this.graphStatus === 'ready'
const previousTabs = this.tabs
const previousLeaves = this.leaves
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
@@ -5738,6 +5750,20 @@ export class OrcaRuntimeService {
this.refreshWritableFlags()
for (const leaf of this.leaves.values()) {
this.adoptPreAllocatedHandle(leaf)
const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (
this._orchestrationDb &&
leaf.lastAgentStatus === 'idle' &&
leaf.lastAgentStatusObservedLive &&
leaf.writable &&
(!graphWasReady ||
previousLeaf?.ptyId !== leaf.ptyId ||
!previousLeaf.writable ||
previousLeaf.lastAgentStatus !== 'idle' ||
!previousLeaf.lastAgentStatusObservedLive)
) {
this.deliverPendingMessagesForLeaf(leaf)
}
}
// Why: createTerminal waits for the renderer's graph sync to populate the
@@ -31969,6 +31995,23 @@ export class OrcaRuntimeService {
}
}
private scheduleRestoredMessageRepoints(): void {
const handles = this._orchestrationDb?.getUndeliveredUnreadMailboxHandles?.() ?? []
for (const handle of handles) {
if (!handle.startsWith('dispatch:')) {
this.mailPointerRepointScheduler.schedule(handle)
}
}
}
private repointPendingMessagesForHandle(handle: string): void {
try {
this.deliverPendingMessagesForHandle(handle)
} catch {
// The unref'd repair can outlive a test/runtime-owned database during shutdown.
}
}
private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void {
this.deliverPendingMessages(leaf)
if (!this._orchestrationDb) {
@@ -31982,6 +32025,9 @@ export class OrcaRuntimeService {
// Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling.
notifyMessageArrived(handle: string, messageType?: string): void {
if (!handle.startsWith('dispatch:')) {
this.mailPointerRepointScheduler.schedule(handle)
}
// Why: push-on-idle is driven by status transitions; a message that
// arrives while the recipient is already idle never sees a transition, so
// deliver now (#12536). deliverPendingMessagesForHandle no-ops when the
@@ -32652,15 +32698,19 @@ export class OrcaRuntimeService {
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (handle) {
this.lastPointedMessageSequenceByHandle.delete(handle)
this.pointedMessageIdsByHandle.delete(handle)
this.mailPointerRepointScheduler.schedule(handle)
}
const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`)
if (run) {
this.lastPointedMessageSequenceByHandle.delete(`run:${run.id}`)
this.pointedMessageIdsByHandle.delete(`run:${run.id}`)
this.mailPointerRepointScheduler.schedule(`run:${run.id}`)
}
}
}
// Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection.
// Why: normal delivery stays event-driven; the bounded mailbox retry only repairs missed liveness edges.
private deliverPendingMessages(
leaf: RuntimeLeafRecord,
options: {
@@ -32700,25 +32750,43 @@ export class OrcaRuntimeService {
// still-blocked case; reservedTypes carries the notify-time snapshot for a
// waiter resolved later in the same drain, which is already gone from the map.
const waiters = this.messageWaitersByHandle.get(mailboxHandle)
const unread = this._orchestrationDb
.getUndeliveredUnreadMessages(mailboxHandle)
.filter(
(message) =>
!options.reservedTypes?.has(message.type) &&
!messageTypeHasLiveWaiter(waiters, message.type)
)
const pending = this._orchestrationDb.getUndeliveredUnreadMessages(mailboxHandle)
const pendingIds = new Set(pending.map((message) => message.id))
const pointedIds = this.pointedMessageIdsByHandle.get(mailboxHandle)
if (pointedIds) {
for (const id of pointedIds) {
if (!pendingIds.has(id)) {
pointedIds.delete(id)
}
}
if (pointedIds.size === 0) {
this.pointedMessageIdsByHandle.delete(mailboxHandle)
}
}
const unread = pending.filter(
(message) =>
!options.reservedTypes?.has(message.type) &&
!messageTypeHasLiveWaiter(waiters, message.type)
)
if (unread.length === 0) {
return
}
const watermark = this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1
const priorPointedIds = this.pointedMessageIdsByHandle.get(mailboxHandle)
if (
!unread.some(
(message) => message.sequence > watermark || priorPointedIds?.has(message.id) !== true
)
) {
return
}
if (!leaf.writable || !leaf.ptyId) {
return
}
const newestSequence = unread.at(-1)?.sequence
if (
newestSequence === undefined ||
newestSequence <= (this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1)
) {
if (newestSequence === undefined) {
return
}
@@ -32789,7 +32857,16 @@ export class OrcaRuntimeService {
if (!wrote) {
return
}
this.lastPointedMessageSequenceByHandle.set(mailboxHandle, newestSequence)
this.lastPointedMessageSequenceByHandle.set(
mailboxHandle,
Math.max(watermark, newestSequence)
)
const pointedIdsAfterWrite =
this.pointedMessageIdsByHandle.get(mailboxHandle) ?? new Set<string>()
for (const message of unread) {
pointedIdsAfterWrite.add(message.id)
}
this.pointedMessageIdsByHandle.set(mailboxHandle, pointedIdsAfterWrite)
const tabTitle = this.tabs.get(leaf.tabId)?.title
if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) {
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from 'vitest'
import { OrchestrationDb } from './db'
describe('undelivered orchestration mailboxes', () => {
let db: OrchestrationDb | undefined
afterEach(() => db?.close())
it('lists only mailboxes with undelivered unread messages', () => {
db = new OrchestrationDb(':memory:')
const delivered = db.insertMessage({ from: 'a', to: 'delivered', subject: 'done' })
const read = db.insertMessage({ from: 'a', to: 'read', subject: 'seen' })
db.insertMessage({ from: 'a', to: 'pending', subject: 'first' })
db.insertMessage({ from: 'a', to: 'pending', subject: 'second' })
db.markAsDelivered([delivered.id])
db.markAsRead([read.id])
expect(db.getUndeliveredUnreadMailboxHandles()).toEqual(['pending'])
})
})
+12
View File
@@ -3510,6 +3510,18 @@ export class OrchestrationDb {
)
}
getUndeliveredUnreadMailboxHandles(): string[] {
return (
this.db
.prepare(
`SELECT DISTINCT to_handle FROM messages
WHERE read = 0 AND delivered_at IS NULL
AND delivery_contract = 'current_delivery'`
)
.all() as { to_handle: string }[]
).map((row) => row.to_handle)
}
getAllMessages(toHandle: string, limit = 20): MessageRow[] {
return exposeMessageListTimestamps(
this.db
@@ -0,0 +1,29 @@
const MAIL_POINTER_REPOINT_DELAY_MS = 2_000
export class MailPointerRepointScheduler {
private readonly timersByHandle = new Map<string, ReturnType<typeof setTimeout>>()
constructor(private readonly repoint: (handle: string) => void) {}
schedule(handle: string): void {
if (this.timersByHandle.has(handle)) {
return
}
const timer = setTimeout(() => {
if (this.timersByHandle.get(handle) !== timer) {
return
}
this.timersByHandle.delete(handle)
this.repoint(handle)
}, MAIL_POINTER_REPOINT_DELAY_MS)
timer.unref?.()
this.timersByHandle.set(handle, timer)
}
clear(): void {
for (const timer of this.timersByHandle.values()) {
clearTimeout(timer)
}
this.timersByHandle.clear()
}
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog'
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
assertWorkerLaunchPreferencesCreateTerminal,
@@ -32,14 +33,75 @@ describe('orchestration worker launch preferences', () => {
).toEqual({ model: 'gpt-5.6-sol' })
})
it('rejects model-specific effort combinations the catalog disproves', () => {
expect(() =>
resolveWorkerLaunchPreferences({
agent: 'codex',
model: 'gpt-5.6-luna',
effort: 'xhigh'
})
).toThrow('does not support effort xhigh')
it.each([
{
model: 'gpt-5.6-sol',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['future-effort']
},
{
model: 'gpt-5.6-terra',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
rejected: ['future-effort']
},
{
model: 'gpt-5.6-luna',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
rejected: ['ultra', 'future-effort']
},
{
model: 'gpt-5.5',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.2-codex',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.4',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.4-mini',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'gpt-5.3-codex-spark',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
},
{
model: 'future-codex-model',
accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'],
rejected: ['max', 'ultra', 'future-effort']
}
])('enforces the Codex effort ceiling for $model', ({ model, accepted, rejected }) => {
const catalog = getAgentSessionOptionCatalog('codex')!
const effort =
catalog.models
.find((candidate) => candidate.id === model)
?.options.find((option) => option.id === 'effort') ??
catalog.unknownModelOptions?.find((option) => option.id === 'effort')
expect(effort?.kind.type).toBe('select')
expect(
effort?.kind.type === 'select' ? effort.kind.choices.map(({ value }) => value) : []
).toEqual(accepted)
for (const effortValue of accepted) {
expect(
resolveWorkerLaunchPreferences({ agent: 'codex', model, effort: effortValue }).preferences
).toEqual({ model, effort: effortValue })
}
for (const effortValue of rejected) {
expect(() =>
resolveWorkerLaunchPreferences({ agent: 'codex', model, effort: effortValue })
).toThrow(`does not support effort ${effortValue}`)
}
})
it('rejects effort without a model', () => {
@@ -266,6 +266,7 @@ function makeOrchestrationDbStub(toHandle: () => string) {
// Mirrors the real query: `read = 0 AND delivered_at IS NULL`.
getUndeliveredUnreadMessages: (handle: string) =>
rows.filter((row) => row.to_handle === handle && row.read === 0 && !row.delivered_at),
getUndeliveredUnreadMailboxHandles: () => [toHandle()],
getActiveCoordinatorRun: () => null,
getCurrentRunForPane: () => undefined,
// Consulted by onPtyExit's dispatch-failure path.
+10
View File
@@ -60,6 +60,8 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-agent-question: var(--agent-question);
--color-agent-question-text: var(--agent-question-text);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -169,6 +171,12 @@
--ring: #a1a1a1;
--terminal-pane-locate: var(--color-blue-600);
--ai-action-accent: var(--color-violet-500);
/* "An agent is asking you something" — one hue for every surface that shows
it (sidebar, tabs, dashboard, agent map). Orange, not amber: on the map it
has to stay separable from working-yellow at a glance. */
--agent-question: var(--color-orange-600);
/* Legible weight for text/glyphs on a tinted --agent-question surface. */
--agent-question-text: var(--color-orange-700);
--status-success: #15803d;
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
@@ -272,6 +280,8 @@
--ring: #737373;
--terminal-pane-locate: var(--color-blue-400);
--ai-action-accent: var(--color-violet-400);
--agent-question: var(--color-orange-500);
--agent-question-text: var(--color-orange-300);
--status-success: #86efac;
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
@@ -0,0 +1,23 @@
import React from 'react'
import { MessageCircleQuestion } from 'lucide-react'
import { cn } from '@/lib/utils'
// Why: "the agent is asking you something" shows up in the sidebar, terminal
// tabs, the dashboard, the kanban and the agent map. One icon + one token
// (--agent-question) so the four never drift apart; the map paints the same
// token from agent-map.css. Callers pass sizing via className.
type AgentQuestionIconProps = React.ComponentProps<typeof MessageCircleQuestion>
export function AgentQuestionIcon({
className,
...props
}: AgentQuestionIconProps): React.JSX.Element {
return (
<MessageCircleQuestion
{...props}
className={cn('text-agent-question', className)}
aria-hidden="true"
/>
)
}
@@ -1,4 +1,6 @@
import React from 'react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AgentStateDot, type AgentDotState } from './AgentStateDot'
@@ -17,6 +19,15 @@ function renderDotClassNames(state: AgentDotState): string[] {
}
describe('AgentStateDot', () => {
it('keeps the question glyph above the light-theme non-text contrast floor', () => {
const css = readFileSync(join(__dirname, '../assets/main.css'), 'utf8')
const lightTheme = css.match(/:root\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body
const darkTheme = css.match(/\.dark\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body
expect(lightTheme).toContain('--agent-question: var(--color-orange-600)')
expect(darkTheme).toContain('--agent-question: var(--color-orange-500)')
})
it('renders working as a yellow spinner', () => {
const markup = renderMarkup('working')
@@ -45,13 +56,14 @@ describe('AgentStateDot', () => {
})
it.each(['permission', 'waiting'] satisfies AgentDotState[])(
'renders %s as an amber question glyph',
'renders %s as the shared question glyph',
(state) => {
const markup = renderMarkup(state)
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('bg-amber-500')
// One token across sidebar, tabs, dashboard and map — never a raw hue.
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('data-agent-spinner')
}
)
@@ -1,6 +1,7 @@
import React from 'react'
import { CircleCheck, MessageCircleQuestion } from 'lucide-react'
import { CircleCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
// Why: shared state-indicator primitive so the dashboard and the sidebar's
@@ -27,8 +28,8 @@ export type AgentDotState =
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
// collapses blocked + waiting into a single "needs attention" state. Keep
// this as a distinct member so that flow can render without inventing a new
// vocabulary, while rendering it with the same amber attention color as the
// worktree-level permission dot.
// vocabulary, while rendering it with the same question glyph and
// --agent-question color as the worktree-level permission indicator.
| 'permission'
/** Return the accessible label shared by every visual agent-state marker. */
@@ -101,7 +102,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<MessageCircleQuestion className={cn('text-amber-500', icon)} aria-hidden="true" />
<AgentQuestionIcon className={icon} />
</span>
)
}
@@ -181,7 +181,7 @@ describe('AgentKanbanCard', () => {
card: card({ bucket: 'attention', dotState: 'waiting' }),
now: 2_000
})
expect(attention.firstElementChild?.className).toContain('border-amber-500/40')
expect(attention.firstElementChild?.className).toContain('border-agent-question/40')
cleanup()
const { container: done } = renderCard({
@@ -198,7 +198,7 @@ describe('AgentKanbanCard', () => {
const idleClassName = idle.firstElementChild?.className ?? ''
expect(idleClassName).toContain('border-border/60')
expect(idleClassName).not.toContain('emerald')
expect(idleClassName).not.toContain('amber')
expect(idleClassName).not.toContain('agent-question')
})
it('heads the card with the conversation name and drops the worktree to the footer', () => {
@@ -5,11 +5,11 @@ import {
GitMerge,
GitPullRequest,
GitPullRequestClosed,
GitPullRequestDraft,
MessageCircleQuestion
GitPullRequestDraft
} from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentStateDot } from '@/components/AgentStateDot'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -203,9 +203,9 @@ export const AgentKanbanCard = memo(
}: AgentKanbanCardProps): React.JSX.Element {
useTranslation()
const [subagentsOpen, setSubagentsOpen] = useState(false)
// Why: the two outcomes worth scanning for get a tinted card — amber for
// "answer me", green for "finished, look at it". Everything else stays
// neutral so the tint keeps meaning something.
// Why: the two outcomes worth scanning for get a tinted card — the
// --agent-question accent for "answer me", green for "finished, look at
// it". Everything else stays neutral so the tint keeps meaning something.
const needsYou = card.bucket === 'attention'
const displayState = dashboardCardDisplayState(card)
const isDone = displayState === 'done'
@@ -224,7 +224,7 @@ export const AgentKanbanCard = memo(
className={cn(
'group flex w-full flex-col gap-1.5 rounded-lg border p-2.5 text-left transition-colors',
needsYou
? 'border-amber-500/40 bg-amber-500/[0.06] hover:border-amber-500/60 hover:bg-amber-500/10'
? 'border-agent-question/40 bg-agent-question/[0.06] hover:border-agent-question/60 hover:bg-agent-question/10'
: isDone
? 'border-emerald-500/40 bg-emerald-500/[0.06] hover:border-emerald-500/60 hover:bg-emerald-500/10'
: 'border-border/60 bg-card hover:border-border hover:bg-accent/40'
@@ -277,8 +277,8 @@ export const AgentKanbanCard = memo(
) : null}
{card.askSummary ? (
<div className="flex w-full items-start gap-1 rounded-md bg-amber-500/15 px-1.5 py-1 text-[11px] text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-400">
<MessageCircleQuestion className="mt-px size-3 shrink-0" aria-hidden />
<div className="flex w-full items-start gap-1 rounded-md bg-agent-question/15 px-1.5 py-1 text-[11px] text-agent-question-text ring-1 ring-inset ring-agent-question/25">
<AgentQuestionIcon className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{card.askSummary}</span>
</div>
) : null}
@@ -18,7 +18,7 @@ import {
type AgentMapHostFilter
} from './agent-map-filter'
import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout'
import { selectAgentMapRecentFinishPaneKeys } from './agent-map-node-metadata'
import { selectAgentMapRecentFlareStatuses } from './agent-map-node-metadata'
import './agent-map.css'
type AgentMapProps = {
@@ -121,8 +121,8 @@ export function AgentMap({
() => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces),
[visibleCards, visibleWorkspaces, now]
)
const recentFinishPaneKeys = useMemo(
() => selectAgentMapRecentFinishPaneKeys(visibleCards),
const recentFlareStatuses = useMemo(
() => selectAgentMapRecentFlareStatuses(visibleCards),
[visibleCards]
)
useEffect(() => {
@@ -178,7 +178,7 @@ export function AgentMap({
selectedPaneKey={selectedPaneKey}
allowAggregation
showOrchestrationLinks={showOrchestrationLinks}
recentFinishPaneKeys={recentFinishPaneKeys}
recentFlareStatuses={recentFlareStatuses}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
@@ -17,6 +17,7 @@ import type {
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/types'
import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout'
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
import { AgentMapScene } from './AgentMapScene'
import { agentFocusZoom, clamp, MAX_ZOOM, MIN_ZOOM } from './agent-map-canvas-zoom'
import { AgentMapViewportControls } from './AgentMapViewportControls'
@@ -46,7 +47,7 @@ type AgentMapCanvasProps = {
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFinishPaneKeys: ReadonlySet<string>
recentFlareStatuses: ReadonlyMap<string, AgentMapFlareStatus>
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
workspaceContextMenusEnabled?: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
@@ -63,7 +64,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFinishPaneKeys,
recentFlareStatuses,
launchableAgentsByWorktreeId,
workspaceContextMenusEnabled = false,
onWorkspaceContextMenuOpenChange,
@@ -114,7 +115,6 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
[allowAggregation, layout, selectedPaneKey, zoom]
)
const hasProjects = layout.projects.length > 0
const hasMotionProjects = motionLayout.projects.length > 0
const aspect = size.width / Math.max(1, size.height)
const baseWidth = Math.max(layout.width, layout.height * aspect)
const baseHeight = baseWidth / aspect
@@ -303,7 +303,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
return (
<div ref={containerRef} className="agent-map-canvas relative min-h-0 flex-1 overflow-hidden">
{!hasMotionProjects ? (
{motionLayout.projects.length === 0 ? (
<div className="absolute inset-0 grid place-items-center text-center text-xs text-muted-foreground">
{translate('dashboardPopout.map.empty', 'No agents match the current filters.')}
</div>
@@ -387,7 +387,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
showOrchestrationLinks={showOrchestrationLinks}
recentFinishPaneKeys={recentFinishPaneKeys}
recentFlareStatuses={recentFlareStatuses}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
@@ -45,7 +45,7 @@ describe('AgentMapScene project labels', () => {
selectedPaneKey={null}
allowAggregation
showOrchestrationLinks
recentFinishPaneKeys={new Set()}
recentFlareStatuses={new Map()}
nodeRefs={{ current: new Map() }}
onSelectAgent={vi.fn()}
onAgentKeyDown={vi.fn()}
@@ -109,7 +109,7 @@ describe('AgentMapScene project labels', () => {
selectedPaneKey={null}
allowAggregation
showOrchestrationLinks
recentFinishPaneKeys={new Set()}
recentFlareStatuses={new Map()}
nodeRefs={{ current: new Map() }}
onSelectAgent={vi.fn()}
onAgentKeyDown={vi.fn()}
@@ -0,0 +1,37 @@
import React from 'react'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
// Why: hue alone can't carry 'waiting' on the map — it sits one step from
// blocked-red, and nodes shrink as you zoom out. The badge repeats the same
// question glyph every other surface uses, so the state is readable by shape.
/** Question badge marking an agent that is waiting on the user. */
export function AgentMapQuestionMarker({
radius,
markerScale
}: {
radius: number
markerScale: number
}): React.JSX.Element {
const iconSize = radius * 0.74 * markerScale
// Mirrors the unread dot across the node so the two never stack.
const offset = radius * Math.SQRT1_2
return (
<g transform={`translate(${offset} ${-offset})`} aria-hidden="true">
{/* Cuts the node ring out from behind the glyph. */}
<circle
className="agent-map-agent-question-backdrop"
data-agent-question-marker=""
r={iconSize * 0.62}
vectorEffect="none"
/>
<AgentQuestionIcon
className="agent-map-agent-question-icon"
x={-iconSize / 2}
y={-iconSize / 2}
width={iconSize}
height={iconSize}
/>
</g>
)
}
@@ -13,6 +13,7 @@ import type {
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { selectVisibleAgentMapLabels } from './agent-map-label-declutter'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel'
import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode'
import { DashboardHostBadge } from './DashboardHostBadge'
@@ -29,7 +30,7 @@ type AgentMapSceneProps = {
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFinishPaneKeys: ReadonlySet<string>
recentFlareStatuses: ReadonlyMap<string, AgentMapFlareStatus>
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
@@ -73,7 +74,7 @@ export const AgentMapScene = memo(function AgentMapScene({
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFinishPaneKeys,
recentFlareStatuses,
launchableAgentsByWorktreeId,
nodeRefs,
onSelectAgent,
@@ -220,7 +221,7 @@ export const AgentMapScene = memo(function AgentMapScene({
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
showOrchestrationLinks={showOrchestrationLinks}
recentFinishPaneKeys={recentFinishPaneKeys}
recentFlareStatuses={recentFlareStatuses}
launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
@@ -30,7 +30,7 @@ describe('AgentMap status glow', () => {
}
)
it('caps a 200-completion burst at four flares without dropping static emphasis', () => {
it('caps a 200-status burst at four flares without dropping static emphasis', () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const { container } = renderMap(
Array.from({ length: 200 }, (_, index) =>
@@ -47,9 +47,50 @@ describe('AgentMap status glow', () => {
)
clock.mockRestore()
expect(container.querySelectorAll('[data-agent-map-agent-finish-flare]')).toHaveLength(4)
expect(container.querySelectorAll('[data-agent-map-agent-status-flare]')).toHaveLength(4)
expect(container.querySelectorAll('[data-agent-map-agent-status-glow]')).toHaveLength(200)
expect(container.querySelectorAll('.fleet-status-done .agent-map-agent-mark')).toHaveLength(200)
expect(container.querySelectorAll('[data-agent-unread-marker]')).toHaveLength(200)
})
it.each([
{ bucket: 'attention', dotState: 'waiting', className: 'fleet-status-waiting' },
{ bucket: 'done', dotState: 'done', className: 'fleet-status-done' }
] as const)('flares a fresh $dotState state', ({ bucket, dotState, className }) => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const { container } = renderMap([card({ bucket, dotState, unseen: true, stateChangedAt: NOW })])
clock.mockRestore()
expect(container.querySelector('[data-agent-map-agent-status-flare]')).toHaveClass(className)
})
it.each([
{ dotState: 'waiting', marked: true },
{ dotState: 'working', marked: false },
{ dotState: 'blocked', marked: false }
] as const)('marks $dotState agents with a question badge: $marked', (state) => {
const { container } = renderMap([card({ bucket: 'attention', dotState: state.dotState })])
const marker = container.querySelector('[data-agent-question-marker]')
if (!state.marked) {
expect(marker).not.toBeInTheDocument()
return
}
expect(marker).toBeInTheDocument()
// Same glyph the sidebar and tabs use, not a map-local invention.
expect(container.querySelector('svg.agent-map-agent-question-icon')).toBeInTheDocument()
expect(marker!.parentElement!.querySelector('foreignObject')).not.toBeInTheDocument()
})
it('keeps the question badge clear of the unread dot', () => {
const { container } = renderMap([
card({ bucket: 'attention', dotState: 'waiting', unseen: true })
])
const question = container.querySelector('[data-agent-question-marker]')!.parentElement!
const unread = container.querySelector('[data-agent-unread-marker]')!
// Unread sits top-left, the badge top-right — opposite signs on x.
expect(question.getAttribute('transform')).toMatch(/translate\(\d/)
expect(Number(unread.getAttribute('cx'))).toBeLessThan(0)
})
})
@@ -14,6 +14,8 @@ import type {
AgentMapWorktreeRing
} from './agent-map-layout'
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { AgentMapQuestionMarker } from './AgentMapQuestionMarker'
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
import {
agentMapAttentionMarkerScale,
agentMapStatusLabel,
@@ -33,7 +35,7 @@ type AgentMapWorktreeRingNodeProps = {
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFinishPaneKeys: ReadonlySet<string>
recentFlareStatuses: ReadonlyMap<string, AgentMapFlareStatus>
launchableAgents?: readonly TuiAgent[]
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
@@ -166,7 +168,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFinishPaneKeys,
recentFlareStatuses,
launchableAgents,
nodeRefs,
onSelectAgent,
@@ -295,6 +297,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
{worktree.agents.map((agent) => {
const iconSize = Math.max(12, Math.min(22, agent.radius * 1.05))
const agentExiting = exiting || agent.motionState === 'exiting'
const flareStatus = recentFlareStatuses.get(agent.card.paneKey)
// `done` here is the unread finish only — `done-seen` demotes to a bare
// emerald ring so the halo keeps meaning "this one is still unread".
const hasStatusGlow =
@@ -344,12 +347,11 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
aria-hidden="true"
/>
) : null}
{/* One-shot ripple at the moment of finishing. Mounted only inside the
flare window so animated paint stays off the resting fleet. */}
{recentFinishPaneKeys.has(agent.card.paneKey) && !agentExiting ? (
{/* One-shot ripple for fresh questions and finishes. */}
{flareStatus && !agentExiting ? (
<circle
className="agent-map-agent-finish-flare"
data-agent-map-agent-finish-flare=""
className={`agent-map-agent-status-flare fleet-status-${flareStatus}`}
data-agent-map-agent-status-flare=""
r={agent.radius + 1}
aria-hidden="true"
/>
@@ -382,6 +384,12 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
aria-hidden="true"
/>
) : null}
{agent.status === 'waiting' ? (
<AgentMapQuestionMarker
radius={agent.radius}
markerScale={agentMapAttentionMarkerScale(mapScale)}
/>
) : null}
</g>
</g>
)
@@ -42,31 +42,44 @@ describe('Agent Map glow performance boundary', () => {
expect(markRule).not.toMatch(/filter:|animation:|transition:/)
})
it('confines the finish flare to nodes inside the recency window', () => {
it('keeps the waiting badge on the native SVG paint path', () => {
const marker = source('AgentMapQuestionMarker.tsx')
const css = source('agent-map.css')
const markerRules = css.match(/\.agent-map-agent-question-[^{}]*\{[^}]+\}/gs) ?? []
expect(marker).not.toContain('<foreignObject')
expect(marker).toContain('<AgentQuestionIcon')
expect(markerRules).toHaveLength(2)
for (const rule of markerRules) {
expect(rule).not.toMatch(/filter:|animation:|transition:/)
}
})
it('confines status flares to nodes inside the recency window', () => {
const component = source('AgentMapWorktreeRingNode.tsx')
const metadata = source('agent-map-node-metadata.ts')
// The flare is the one animated element on an agent node, so it must stay gated on
// the globally capped recent-finish set rather than on status alone.
expect(component.match(/data-agent-map-agent-finish-flare/g)).toHaveLength(1)
expect(component).toMatch(/recentFinishPaneKeys\.has\(agent\.card\.paneKey\)\s*&&/)
// the globally capped recent-status map rather than on status alone.
expect(component.match(/data-agent-map-agent-status-flare/g)).toHaveLength(1)
expect(component).toMatch(/recentFlareStatuses\.get\(agent\.card\.paneKey\)/)
expect(component).not.toMatch(/<filter|filter=/)
const css = source('agent-map.css')
const flareRule = css.match(/\.agent-map-agent-finish-flare\s*\{[^}]+\}/s)?.[0] ?? ''
const flareRule = css.match(/\.agent-map-agent-status-flare\s*\{[^}]+\}/s)?.[0] ?? ''
expect(flareRule).toContain('pointer-events: none')
expect(flareRule).toContain('vector-effect: non-scaling-stroke')
// Transform and opacity only — no filter or layout-affecting paint work.
expect(flareRule).not.toMatch(/filter:/)
expect(css).toMatch(/@keyframes agent-map-finish-flare/)
expect(css).toMatch(/@keyframes agent-map-status-flare/)
// The mount window and the CSS duration are declared in different languages and
// drift silently: too short a window unmounts the element mid-ripple, too long
// leaves an invisible node animating. Pin them to the same number.
const windowMs = Number(
metadata.match(/AGENT_MAP_FINISH_FLARE_MS = ([\d_]+)/)?.[1].replaceAll('_', '')
metadata.match(/AGENT_MAP_STATUS_FLARE_MS = ([\d_]+)/)?.[1].replaceAll('_', '')
)
const cssMs = Number(flareRule.match(/animation: agent-map-finish-flare (\d+)ms/)?.[1])
const cssMs = Number(flareRule.match(/animation: agent-map-status-flare (\d+)ms/)?.[1])
expect(windowMs).toBeGreaterThan(0)
expect(cssMs).toBe(windowMs)
})
@@ -75,7 +88,7 @@ describe('Agent Map glow performance boundary', () => {
const map = source('AgentMap.tsx')
const scene = source('AgentMapScene.tsx')
expect(map).toMatch(/selectAgentMapRecentFinishPaneKeys\(visibleCards\)/)
expect(scene).not.toContain('selectAgentMapRecentFinishPaneKeys')
expect(map).toMatch(/selectAgentMapRecentFlareStatuses\(visibleCards\)/)
expect(scene).not.toContain('selectAgentMapRecentFlareStatuses')
})
})
@@ -1,13 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
AGENT_MAP_FINISH_FLARE_MS,
AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES,
AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES,
AGENT_MAP_STATUS_FLARE_MS,
agentMapRecentFlareStatus,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
isAgentMapRecentFinish,
selectAgentMapRecentFinishPaneKeys
selectAgentMapRecentFlareStatuses
} from './agent-map-node-metadata'
const NOW = 2_000_000_000
@@ -59,7 +59,7 @@ describe('agentMapNodeStatus', () => {
})
})
describe('isAgentMapRecentFinish', () => {
describe('agentMapRecentFlareStatus', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
@@ -70,47 +70,84 @@ describe('isAgentMapRecentFinish', () => {
vi.setSystemTime(NOW)
const justFinished = card({ dotState: 'done', unseen: true, stateChangedAt: NOW })
expect(isAgentMapRecentFinish(justFinished)).toBe(true)
vi.setSystemTime(NOW + AGENT_MAP_FINISH_FLARE_MS - 1)
expect(isAgentMapRecentFinish(justFinished)).toBe(true)
vi.setSystemTime(NOW + AGENT_MAP_FINISH_FLARE_MS + 1)
expect(isAgentMapRecentFinish(justFinished)).toBe(false)
expect(agentMapRecentFlareStatus(justFinished)).toBe('done')
vi.setSystemTime(NOW + AGENT_MAP_STATUS_FLARE_MS - 1)
expect(agentMapRecentFlareStatus(justFinished)).toBe('done')
vi.setSystemTime(NOW + AGENT_MAP_STATUS_FLARE_MS + 1)
expect(agentMapRecentFlareStatus(justFinished)).toBeNull()
})
it('samples the wall clock once and caps bursty fleet updates', () => {
it('flares on the transition into a question', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
agentMapRecentFlareStatus(
card({ bucket: 'attention', dotState: 'waiting', unseen: true, stateChangedAt: NOW })
)
).toBe('waiting')
})
it('does not reuse an earlier finish timestamp for a question with unknown timing', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
agentMapRecentFlareStatus(
card({ dotState: 'waiting', stateChangedAt: 0, finishedAt: NOW, unseen: true })
)
).toBeNull()
})
it('samples the wall clock once and caps mixed bursty fleet updates', () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const selected = selectAgentMapRecentFinishPaneKeys(
const selected = selectAgentMapRecentFlareStatuses(
Array.from({ length: 200 }, (_, index) =>
card({ paneKey: `pane-${index}`, unseen: true, stateChangedAt: NOW - index })
card({
paneKey: `pane-${index}`,
bucket: index % 2 === 0 ? 'done' : 'attention',
dotState: index % 2 === 0 ? 'done' : 'waiting',
unseen: true,
stateChangedAt: NOW - index
})
)
)
expect(clock).toHaveBeenCalledOnce()
expect(selected.size).toBe(AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES)
expect([...selected]).toEqual(['pane-0', 'pane-1', 'pane-2', 'pane-3'])
expect(selected.size).toBe(AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES)
expect([...selected]).toEqual([
['pane-0', 'done'],
['pane-1', 'waiting'],
['pane-2', 'done'],
['pane-3', 'waiting']
])
})
it('does not flare work that finished before this session', () => {
it('does not flare status changes from before this session', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
isAgentMapRecentFinish(card({ dotState: 'done', unseen: true, stateChangedAt: NOW - 60_000 }))
).toBe(false)
agentMapRecentFlareStatus(
card({ dotState: 'done', unseen: true, stateChangedAt: NOW - 60_000 })
)
).toBeNull()
// A clock skew that puts the finish in the future must not latch a flare on forever.
expect(
isAgentMapRecentFinish(card({ dotState: 'done', unseen: true, stateChangedAt: NOW + 5_000 }))
).toBe(false)
agentMapRecentFlareStatus(
card({ dotState: 'done', unseen: true, stateChangedAt: NOW + 5_000 })
)
).toBeNull()
})
it('never flares a state that is not an unread finish', () => {
it('never flares a state that is not a question or unread finish', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
isAgentMapRecentFinish(card({ dotState: 'done', unseen: false, stateChangedAt: NOW }))
).toBe(false)
agentMapRecentFlareStatus(card({ dotState: 'done', unseen: false, stateChangedAt: NOW }))
).toBeNull()
expect(
isAgentMapRecentFinish(card({ dotState: 'working', unseen: true, stateChangedAt: NOW }))
).toBe(false)
agentMapRecentFlareStatus(card({ dotState: 'working', unseen: true, stateChangedAt: NOW }))
).toBeNull()
})
})
@@ -25,56 +25,65 @@ export function agentMapNodeStatus(card: DashboardCard): AgentMapNodeStatus {
return dashboardCardDisplayState(card)
}
/** How long a fresh finish keeps its one-shot flare. Long enough to catch the eye from
* across the map, short enough that a busy fleet is never permanently animating.
* Must stay in step with the `agent-map-finish-flare` duration in `agent-map.css`, or
* the element unmounts mid-ripple; the glow performance test asserts the two match. */
export const AGENT_MAP_FINISH_FLARE_MS = 1_400
// Static completion emphasis remains uncapped; this bounds animated SVG paint only.
export const AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES = 4
export type AgentMapFlareStatus = Extract<DashboardCardDotState, 'waiting' | 'done'>
/** Uses wall time because the map's relative-timestamp clock advances only every 30s. */
export function isAgentMapRecentFinish(card: DashboardCard, currentTime = Date.now()): boolean {
if (card.dotState !== 'done' || !card.unseen) {
return false
}
const changedAt = card.stateChangedAt || card.finishedAt || 0
if (changedAt <= 0) {
return false
}
const elapsed = currentTime - changedAt
// A fleet that loads with finished work already on it must not flare all at once, so a
// finish that happened before this render window is never treated as fresh.
return elapsed >= 0 && elapsed < AGENT_MAP_FINISH_FLARE_MS
/** How long a fresh question or finish keeps its one-shot flare. Long enough to catch
* the eye from across the map, short enough that a busy fleet is never permanently
* animating. Must stay in step with the `agent-map-status-flare` duration in
* `agent-map.css`, or the element unmounts mid-ripple. */
export const AGENT_MAP_STATUS_FLARE_MS = 1_400
// Static status emphasis remains uncapped; this bounds animated SVG paint only.
export const AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES = 4
function agentMapFlareChangedAt(card: DashboardCard): number {
return card.stateChangedAt || (card.dotState === 'done' ? card.finishedAt : 0) || 0
}
/** Selects only the freshest finishes so burst updates cannot animate the whole fleet. */
export function selectAgentMapRecentFinishPaneKeys(
/** Uses wall time because the map's relative-timestamp clock advances only every 30s. */
export function agentMapRecentFlareStatus(
card: DashboardCard,
currentTime = Date.now()
): AgentMapFlareStatus | null {
if (card.dotState !== 'waiting' && (card.dotState !== 'done' || !card.unseen)) {
return null
}
const changedAt = agentMapFlareChangedAt(card)
if (changedAt <= 0) {
return null
}
const elapsed = currentTime - changedAt
// A fleet that loads with old status changes must not flare all at once.
return elapsed >= 0 && elapsed < AGENT_MAP_STATUS_FLARE_MS ? card.dotState : null
}
/** Selects only the freshest question/finish changes so bursts cannot animate the fleet. */
export function selectAgentMapRecentFlareStatuses(
cards: readonly DashboardCard[]
): ReadonlySet<string> {
): ReadonlyMap<string, AgentMapFlareStatus> {
const currentTime = Date.now()
const recent: { paneKey: string; changedAt: number }[] = []
const recent: { paneKey: string; changedAt: number; status: AgentMapFlareStatus }[] = []
for (const card of cards) {
if (!isAgentMapRecentFinish(card, currentTime)) {
const status = agentMapRecentFlareStatus(card, currentTime)
if (!status) {
continue
}
const changedAt = card.stateChangedAt || card.finishedAt || 0
const changedAt = agentMapFlareChangedAt(card)
const index = recent.findIndex(
(item) =>
changedAt > item.changedAt || (changedAt === item.changedAt && card.paneKey < item.paneKey)
)
if (index === -1) {
if (recent.length < AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES) {
recent.push({ paneKey: card.paneKey, changedAt })
if (recent.length < AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES) {
recent.push({ paneKey: card.paneKey, changedAt, status })
}
continue
}
recent.splice(index, 0, { paneKey: card.paneKey, changedAt })
if (recent.length > AGENT_MAP_MAX_CONCURRENT_FINISH_FLARES) {
recent.splice(index, 0, { paneKey: card.paneKey, changedAt, status })
if (recent.length > AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES) {
recent.pop()
}
}
return new Set(recent.map((item) => item.paneKey))
return new Map(recent.map((item) => [item.paneKey, item.status]))
}
export type AgentMapStatusCounts = Record<AgentMapNodeStatus, number>
@@ -153,7 +153,7 @@
}
.agent-map-worktree-status-glow.fleet-status-waiting {
stroke: color-mix(in srgb, var(--color-amber-500) 28%, transparent);
stroke: color-mix(in srgb, var(--agent-question) 32%, transparent);
}
.agent-map-worktree-status-glow.fleet-status-working {
@@ -199,7 +199,7 @@
}
.agent-map-worktree-ring.is-waiting {
stroke: var(--color-amber-500);
stroke: var(--agent-question);
}
.agent-map-worktree-ring.is-blocked {
@@ -329,20 +329,27 @@
vector-effect: non-scaling-stroke;
}
.agent-map-agent-finish-flare {
.agent-map-agent-status-flare {
fill: none;
pointer-events: none;
stroke: var(--color-emerald-500);
stroke-width: 2;
transform-box: fill-box;
transform-origin: center;
vector-effect: non-scaling-stroke;
/* Keep in step with AGENT_MAP_FINISH_FLARE_MS, which gates how long the element stays
/* Keep in step with AGENT_MAP_STATUS_FLARE_MS, which gates how long the element stays
mounted. The performance test asserts the two agree. */
animation: agent-map-finish-flare 1400ms cubic-bezier(0.25, 0.5, 0.25, 1) both;
animation: agent-map-status-flare 1400ms cubic-bezier(0.25, 0.5, 0.25, 1) both;
}
@keyframes agent-map-finish-flare {
.agent-map-agent-status-flare.fleet-status-waiting {
stroke: var(--agent-question);
}
.agent-map-agent-status-flare.fleet-status-done {
stroke: var(--color-emerald-500);
}
@keyframes agent-map-status-flare {
0% {
opacity: 0.9;
transform: scale(0.62);
@@ -366,7 +373,7 @@
}
.agent-map-agent-status-glow.fleet-status-waiting {
stroke: color-mix(in srgb, var(--color-amber-500) 42%, transparent);
stroke: color-mix(in srgb, var(--agent-question) 48%, transparent);
}
.agent-map-agent-status-glow.fleet-status-blocked {
@@ -418,7 +425,7 @@
}
.fleet-status-waiting .agent-map-agent-mark {
stroke: var(--color-amber-500);
stroke: var(--agent-question);
}
/* Unread: filled core. Fill survives zoom-out further than a halo does, and it is the
@@ -445,6 +452,19 @@
stroke-width: 2;
}
/* Shape cue for 'waiting': hue alone can't carry it at low zoom or for
red-green CVD, where orange and blocked-red converge. */
.agent-map-agent-question-backdrop {
fill: var(--background);
pointer-events: none;
stroke: none;
}
.agent-map-agent-question-icon {
overflow: visible;
pointer-events: none;
}
.agent-map-aggregate-node circle {
fill: color-mix(in srgb, var(--muted-foreground) 12%, var(--card));
stroke: color-mix(in srgb, var(--muted-foreground) 46%, transparent);
@@ -482,8 +502,8 @@
}
.agent-map-legend-dot.fleet-status-waiting {
background: var(--color-amber-500);
box-shadow: 0 0 4px color-mix(in srgb, var(--color-amber-500) 72%, transparent);
background: var(--agent-question);
box-shadow: 0 0 4px color-mix(in srgb, var(--agent-question) 72%, transparent);
opacity: 1;
}
@@ -540,7 +560,7 @@
.agent-map-agent-node,
.agent-map-agent-visual,
.agent-map-agent-mark,
.agent-map-agent-finish-flare,
.agent-map-agent-status-flare,
.agent-map-lineage-link,
.agent-map-worktree-lineage-link {
animation: none;
@@ -548,7 +568,7 @@
}
/* No flare without motion — the halo and filled core already carry the state. */
.agent-map-agent-finish-flare {
.agent-map-agent-status-flare {
display: none;
}
@@ -247,13 +247,14 @@ describe('DashboardAgentRow', () => {
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
})
it('renders waiting rows with the amber question glyph', () => {
it('renders waiting rows with the shared question glyph', () => {
const markup = renderRow(makeAgent({}, { state: 'waiting' }))
const tokens = classTokens(markup)
expect(markup).toContain('aria-label="Waiting for input"')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(tokens).toContain('text-amber-500')
expect(tokens).toContain('text-agent-question')
expect(tokens).not.toContain('text-amber-500')
expect(tokens).not.toContain('bg-red-500')
})
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
import { nativeChatModelPillLabel } from './native-chat-session-option-labels'
import { describe, expect, it, vi } from 'vitest'
import { translate } from '@/i18n/i18n'
import {
nativeChatModelPillLabel,
nativeChatSessionChoiceLabel
} from './native-chat-session-option-labels'
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
vi.mock('@/i18n/i18n', () => ({
translate: vi.fn((_key: string, fallback: string) => fallback)
}))
function modelDescriptor(
valueSource: SessionOptionDescriptor['valueSource'],
currentValue?: string
@@ -41,3 +49,14 @@ describe('nativeChatModelPillLabel', () => {
expect(nativeChatModelPillLabel(modelDescriptor('reported', 'grok-build'))).toBe('grok-build')
})
})
describe('nativeChatSessionChoiceLabel', () => {
it('routes ultra through the localized effort label', () => {
nativeChatSessionChoiceLabel({ value: 'ultra', label: 'Ultra' })
expect(translate).toHaveBeenCalledWith(
'components.native-chat.composer.optionValue.ultra',
'Ultra'
)
})
})
@@ -34,6 +34,8 @@ export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice):
return translate('components.native-chat.composer.optionValue.xhigh', 'Extra high')
case 'max':
return translate('components.native-chat.composer.optionValue.max', 'Max')
case 'ultra':
return translate('components.native-chat.composer.optionValue.ultra', 'Ultra')
default:
return choice.label
}
@@ -1,6 +1,7 @@
import { LayoutDashboard, MessageCircleQuestion } from 'lucide-react'
import { LayoutDashboard } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot'
import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts'
import { translate } from '@/i18n/i18n'
@@ -46,7 +47,7 @@ function DashboardBucketCounts({
className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55"
>
{bucket === 'attention' ? (
<MessageCircleQuestion className="size-2.5 text-amber-500" aria-hidden />
<AgentQuestionIcon className="size-2.5" />
) : (
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
)}
@@ -16,7 +16,15 @@ vi.mock('@/components/ui/tooltip', () => ({
const roots: Root[] = []
async function renderLine(): Promise<HTMLDivElement> {
type RenderOverrides = {
inboxCount?: number
pending?: boolean
error?: string | null
onReview?: () => void
onSuppress?: () => void
}
async function renderLine(overrides: RenderOverrides = {}): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
@@ -26,20 +34,11 @@ async function renderLine(): Promise<HTMLDivElement> {
root.render(
<NewExternalWorktreesInboxLine
repoDisplayName="orca"
inboxWorktrees={[
{
id: 'external-1',
displayName: 'payments-refactor',
branch: 'refs/heads/payments-refactor',
path: '/worktrees/orca/payments-refactor'
}
]}
pending={false}
error={null}
onImportWorktree={vi.fn()}
onKeepHidden={vi.fn()}
onImportAll={vi.fn()}
onSuppress={vi.fn()}
inboxCount={overrides.inboxCount ?? 24}
pending={overrides.pending ?? false}
error={overrides.error ?? null}
onReview={overrides.onReview ?? vi.fn()}
onSuppress={overrides.onSuppress ?? vi.fn()}
/>
)
})
@@ -47,6 +46,10 @@ async function renderLine(): Promise<HTMLDivElement> {
return container
}
function getReviewButton(container: HTMLDivElement): HTMLButtonElement | null {
return container.querySelector<HTMLButtonElement>('button[aria-label^="Review "]')
}
describe('NewExternalWorktreesInboxLine', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
@@ -60,27 +63,83 @@ describe('NewExternalWorktreesInboxLine', () => {
vi.clearAllMocks()
})
it('keeps suppress as a hover-revealed header icon instead of expanded text action', async () => {
it('states the count without naming any worktree', async () => {
const container = await renderLine()
expect(container.textContent).toContain('24')
expect(container.textContent).toContain('hidden worktrees')
// The modal owns the list; the sidebar must not enumerate paths or names.
expect(container.querySelectorAll('li')).toHaveLength(0)
expect(container.textContent).not.toContain('Import')
})
it('opens review from a single card-wide button', async () => {
const onReview = vi.fn()
const container = await renderLine({ onReview })
const review = getReviewButton(container)
expect(review).not.toBeNull()
expect(review?.getAttribute('aria-label')).toBe('Review 24 hidden worktrees in orca')
await act(async () => {
review?.click()
})
expect(onReview).toHaveBeenCalledTimes(1)
})
it('uses the singular noun for one worktree', async () => {
const container = await renderLine({ inboxCount: 1 })
expect(container.textContent).toContain('hidden worktree')
expect(container.textContent).not.toContain('hidden worktrees')
expect(getReviewButton(container)?.getAttribute('aria-label')).toBe(
'Review 1 hidden worktree in orca'
)
})
it('keeps suppress as a hover-revealed control that does not trigger review', async () => {
const onReview = vi.fn()
const onSuppress = vi.fn()
const container = await renderLine({ onReview, onSuppress })
const suppressButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Hide external worktrees permanently for orca"]'
)
expect(suppressButton).not.toBeNull()
expect(suppressButton?.className).toContain('can-hover:group-hover:opacity-100')
expect(container.textContent).toContain("Don't show again")
// Nested buttons would make the suppress click ambiguous.
expect(getReviewButton(container)?.contains(suppressButton)).toBe(false)
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand new externally-created worktrees for orca"]'
)
await act(async () => {
expandButton?.click()
suppressButton?.click()
})
expect(onSuppress).toHaveBeenCalledTimes(1)
expect(onReview).not.toHaveBeenCalled()
})
expect(container.textContent).toContain('payments-refactor')
const textButtons = [...container.querySelectorAll('button')].filter(
(button) => button.textContent === "Don't show again"
)
expect(textButtons).toHaveLength(0)
it('disables both actions while a mutation is pending', async () => {
const container = await renderLine({ pending: true })
expect(getReviewButton(container)?.disabled).toBe(true)
expect(
container.querySelector<HTMLButtonElement>(
'button[aria-label="Hide external worktrees permanently for orca"]'
)?.disabled
).toBe(true)
expect(container.querySelector('section')?.getAttribute('aria-busy')).toBe('true')
})
it('renders nothing when the inbox is empty', async () => {
const container = await renderLine({ inboxCount: 0 })
expect(container.querySelector('section')).toBeNull()
})
it('surfaces the action error as an alert', async () => {
const container = await renderLine({ error: 'Could not import external worktrees. Try again.' })
const alert = container.querySelector('[role="alert"]')
expect(alert?.textContent).toBe('Could not import external worktrees. Try again.')
})
})
@@ -1,37 +1,30 @@
import React, { useState } from 'react'
import React from 'react'
import { ChevronRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { NewExternalWorktreeInboxPreview } from './new-external-worktrees-inbox-candidates'
type NewExternalWorktreesInboxLineProps = {
repoDisplayName: string
inboxWorktrees: readonly NewExternalWorktreeInboxPreview[]
inboxCount: number
pending: boolean
error: string | null
onImportWorktree?: (worktreeId: string) => void
onKeepHidden?: () => void
onImportAll?: () => void
onReview?: () => void
onSuppress?: () => void
className?: string
}
export default function NewExternalWorktreesInboxLine({
repoDisplayName,
inboxWorktrees,
inboxCount,
pending,
error,
onImportWorktree,
onKeepHidden,
onImportAll,
onReview,
onSuppress,
className
}: NewExternalWorktreesInboxLineProps): React.JSX.Element | null {
const [isExpanded, setIsExpanded] = useState(false)
const inboxCount = inboxWorktrees.length
const suppressLabel = translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2',
"Don't show again"
@@ -41,6 +34,27 @@ export default function NewExternalWorktreesInboxLine({
'Hide external worktrees permanently for {{value0}}',
{ value0: repoDisplayName }
)
const isSingular = inboxCount === 1
const countLabel = isSingular
? translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.2a6f31d8c7',
'hidden worktree'
)
: translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.5b90e4a2f6',
'hidden worktrees'
)
const reviewAriaLabel = isSingular
? translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.7f18c5b0d3',
'Review {{value0}} hidden worktree in {{value1}}',
{ value0: inboxCount, value1: repoDisplayName }
)
: translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.4e2b7a9c05',
'Review {{value0}} hidden worktrees in {{value1}}',
{ value0: inboxCount, value1: repoDisplayName }
)
if (inboxCount === 0) {
return null
@@ -51,151 +65,55 @@ export default function NewExternalWorktreesInboxLine({
aria-busy={pending}
className={cn('mx-1 my-0.5 ml-3 text-worktree-sidebar-foreground', className)}
>
<div
className={cn(
'group flex min-h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-[11px] leading-none text-muted-foreground transition-colors',
'hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground'
)}
>
<Button
<div className="group relative">
<button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-expanded={isExpanded}
aria-label={
isExpanded
? translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.d9f7b2a14c',
'Collapse new externally-created worktrees for {{value0}}',
{ value0: repoDisplayName }
)
: translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.e2c4a8d91f',
'Expand new externally-created worktrees for {{value0}}',
{ value0: repoDisplayName }
)
}
onClick={() => setIsExpanded((value) => !value)}
className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground"
>
<ChevronRight
className={cn('size-3 transition-transform', isExpanded && 'rotate-90')}
aria-hidden="true"
/>
</Button>
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.7c4e9b2a81',
'New externally-created worktrees'
disabled={pending || !onReview}
aria-label={reviewAriaLabel}
onClick={onReview}
className={cn(
'flex min-h-8 w-full min-w-0 items-center gap-2 rounded-md border border-worktree-sidebar-border px-2 py-1.5',
'text-[11px] leading-none text-muted-foreground transition-colors',
'hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-worktree-sidebar-ring',
'disabled:pointer-events-none disabled:opacity-60'
)}
</span>
<span className="relative inline-grid size-6 shrink-0 place-items-center">
<span
>
<span className="inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full border border-border px-1.5 text-[10px] font-medium leading-none tabular-nums">
{inboxCount}
</span>
<span className="min-w-0 flex-1 truncate text-left">{countLabel}</span>
<ChevronRight
aria-hidden="true"
className={cn(
'inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-border px-1.5 text-[10px] font-medium leading-none text-muted-foreground transition-opacity',
'size-3 shrink-0',
// Why: the suppress control takes over this slot on hover.
onSuppress &&
'can-hover:group-hover:opacity-0 can-hover:group-focus-within:opacity-0 [@media(hover:none)]:opacity-0'
)}
>
{inboxCount}
</span>
{onSuppress ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-label={suppressAriaLabel}
onClick={onSuppress}
className="absolute inset-0 text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground can-hover:pointer-events-none can-hover:opacity-0 can-hover:group-hover:pointer-events-auto can-hover:group-hover:opacity-100 can-hover:group-focus-within:pointer-events-auto can-hover:group-focus-within:opacity-100"
>
<X className="size-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{suppressLabel}
</TooltipContent>
</Tooltip>
) : null}
</span>
</div>
{isExpanded ? (
<div className="ml-4 mt-0.5 border-l border-worktree-sidebar-border pb-1 pl-2">
<p className="px-1.5 py-1 text-[10px] leading-4 text-muted-foreground">
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.4d7a1c9e53',
'These worktrees were created outside of Orca.'
)}
</p>
<ul className="grid gap-0.5">
{inboxWorktrees.map((worktree) => (
<li
key={worktree.id ?? worktree.path ?? worktree.displayName}
className="flex min-h-7 min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-xs hover:bg-worktree-sidebar-accent"
/>
</button>
{onSuppress ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={pending}
aria-label={suppressAriaLabel}
onClick={onSuppress}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground can-hover:pointer-events-none can-hover:opacity-0 can-hover:group-hover:pointer-events-auto can-hover:group-hover:opacity-100 can-hover:group-focus-within:pointer-events-auto can-hover:group-focus-within:opacity-100"
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{worktree.displayName}</div>
{worktree.path ? (
<div className="truncate font-mono text-[10px] text-muted-foreground">
{worktree.path}
</div>
) : null}
</div>
{onImportWorktree && worktree.id ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pending}
onClick={() => onImportWorktree(worktree.id!)}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.8b3f2e1d74',
'Import'
)}
</Button>
) : null}
</li>
))}
</ul>
<div className="grid gap-1 px-1.5 pb-1 pt-1">
<div className="flex flex-wrap gap-1.5">
{onKeepHidden ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={pending}
onClick={onKeepHidden}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.1c9e7a4b28',
'Keep hidden'
)}
</Button>
) : null}
{onImportAll ? (
<Button
type="button"
variant="default"
size="xs"
disabled={pending}
onClick={onImportAll}
>
{translate(
'auto.components.sidebar.NewExternalWorktreesInboxLine.6f2d8c1e95',
'Import all'
)}
</Button>
) : null}
</div>
</div>
</div>
) : null}
<X className="size-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{suppressLabel}
</TooltipContent>
</Tooltip>
) : null}
</div>
{error ? (
<p className="px-1.5 pb-1 pt-0.5 text-[11px] leading-4 text-destructive" role="alert">
@@ -31,12 +31,12 @@ describe('StatusIndicator', () => {
expect(markup).toContain('motion-reduce:border-t-yellow-500')
})
it('renders permission as an amber question glyph', () => {
it('renders permission as the shared question glyph', () => {
const markup = renderMarkup('permission')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('bg-amber-500')
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('data-agent-spinner')
})
@@ -1,6 +1,6 @@
import React from 'react'
import { MessageCircleQuestion } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
@@ -47,7 +47,7 @@ const StatusIndicator = React.memo(function StatusIndicator({
title={resolvedTitle}
{...rest}
>
<MessageCircleQuestion className="size-3 text-amber-500" aria-hidden="true" />
<AgentQuestionIcon className="size-3" />
</span>
)
}
@@ -127,7 +127,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Needs permission · Unread')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('data-worktree-status-lane-unread=""')
expect(markup).not.toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('aria-label="Mark as read"')
@@ -380,7 +380,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Needs permission')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('PR checks: Failed')
})
@@ -270,8 +270,6 @@ import {
type ImportedWorktreeCardActionState
} from './imported-worktrees-card-actions'
import {
importNewExternalWorktreeInboxPaths,
keepNewExternalWorktreeInboxHidden,
suppressNewExternalWorktreeInbox,
type NewExternalWorktreesInboxActionState
} from './new-external-worktrees-inbox-actions'
@@ -280,10 +278,7 @@ import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
} from './imported-worktrees-card-candidates'
import {
buildNewExternalWorktreesInboxCandidates,
toNewExternalWorktreeInboxPreview
} from './new-external-worktrees-inbox-candidates'
import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates'
import {
WORKTREE_SECTION_HEADER_PADDING_LEFT,
LINEAGE_CHILDREN_INLINE_OFFSET,
@@ -668,9 +663,6 @@ type VirtualizedWorktreeViewportProps = {
handleShowImportedWorktrees: (projectId: string) => void
handleKeepImportedWorktreesHidden: (projectId: string) => void
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
handleImportNewExternalWorktree: (projectId: string, worktreeId: string) => void
handleImportAllNewExternalWorktrees: (projectId: string) => void
handleKeepNewExternalWorktreeInboxHidden: (projectId: string) => void
handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void
newExternalWorktreeInboxActionState: ReadonlyMap<string, NewExternalWorktreesInboxActionState>
handleRemoveProject: (repo: Repo) => void
@@ -1335,9 +1327,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleShowImportedWorktrees,
handleKeepImportedWorktreesHidden,
importedWorktreeCardActionState,
handleImportNewExternalWorktree,
handleImportAllNewExternalWorktrees,
handleKeepNewExternalWorktreeInboxHidden,
handleOpenSuppressExternalWorktreeInbox,
newExternalWorktreeInboxActionState,
handleRemoveProject,
@@ -5058,14 +5047,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
>
<NewExternalWorktreesInboxLine
repoDisplayName={row.repo.displayName}
inboxWorktrees={row.inboxWorktrees.map(toNewExternalWorktreeInboxPreview)}
inboxCount={row.inboxWorktrees.length}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
onImportWorktree={(worktreeId) =>
handleImportNewExternalWorktree(row.repo.id, worktreeId)
}
onKeepHidden={() => handleKeepNewExternalWorktreeInboxHidden(row.repo.id)}
onImportAll={() => handleImportAllNewExternalWorktrees(row.repo.id)}
onReview={() => handleOpenWorktreeVisibility(row.repo)}
onSuppress={() => handleOpenSuppressExternalWorktreeInbox(row.repo.id)}
/>
</div>
@@ -6159,52 +6144,6 @@ const WorktreeList = React.memo(function WorktreeList({
[fetchWorktrees, repos, setNewExternalWorktreeInboxState, updateRepo]
)
const handleImportNewExternalWorktree = useCallback(
async (projectId: string, worktreeId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const worktree = inboxWorktrees.find((candidate) => candidate.id === worktreeId)
if (!worktree) {
return
}
const args = getNewExternalWorktreeInboxActionArgs(projectId, [worktree.path])
if (!args) {
return
}
await importNewExternalWorktreeInboxPaths(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleImportAllNewExternalWorktrees = useCallback(
async (projectId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
return
}
await importNewExternalWorktreeInboxPaths(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleKeepNewExternalWorktreeInboxHidden = useCallback(
async (projectId: string) => {
const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? []
const args = getNewExternalWorktreeInboxActionArgs(
projectId,
inboxWorktrees.map((worktree) => worktree.path)
)
if (!args) {
return
}
await keepNewExternalWorktreeInboxHidden(args)
},
[getNewExternalWorktreeInboxActionArgs, newExternalWorktreesInboxByRepo]
)
const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => {
setSuppressExternalWorktreeInboxRepoId(projectId)
}, [])
@@ -6866,9 +6805,6 @@ const WorktreeList = React.memo(function WorktreeList({
handleShowImportedWorktrees={handleShowImportedWorktrees}
handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden}
importedWorktreeCardActionState={importedWorktreeCardActionState}
handleImportNewExternalWorktree={handleImportNewExternalWorktree}
handleImportAllNewExternalWorktrees={handleImportAllNewExternalWorktrees}
handleKeepNewExternalWorktreeInboxHidden={handleKeepNewExternalWorktreeInboxHidden}
handleOpenSuppressExternalWorktreeInbox={handleOpenSuppressExternalWorktreeInbox}
newExternalWorktreeInboxActionState={newExternalWorktreeInboxActionState}
handleRemoveProject={handleRemoveProject}
@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'
import { Folder, Grid2X2, SquareTerminal, Trash2 } from 'lucide-react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -200,19 +200,8 @@ export default function WorktreeVisibilitySourceList({
return (
<div
key={key}
className={`grid min-h-14 grid-cols-[28px_minmax(0,1fr)_auto] items-center gap-2.5 px-2.5 py-2 ${index > 0 ? 'border-t border-border' : ''}`}
className={`grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-2.5 px-2.5 py-2 ${index > 0 ? 'border-t border-border' : ''}`}
>
<span className="flex size-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
{source.kind === 'built-in' ? (
source.id === 'claude' ? (
<SquareTerminal className="size-3.5" />
) : (
<Grid2X2 className="size-3.5" />
)
) : (
<Folder className="size-3.5" />
)}
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] font-medium">{label}</span>
@@ -1,9 +1,4 @@
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
} from '../../../../shared/types'
import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types'
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { NewExternalWorktreesInboxCandidate } from './worktree-list-groups'
@@ -39,19 +34,3 @@ export function buildNewExternalWorktreesInboxCandidates(args: {
}
return candidates
}
export type NewExternalWorktreeInboxPreview = Pick<
DetectedWorktree,
'id' | 'displayName' | 'path' | 'branch'
>
export function toNewExternalWorktreeInboxPreview(
worktree: DetectedWorktree
): NewExternalWorktreeInboxPreview {
return {
id: worktree.id,
displayName: worktree.displayName,
path: worktree.path,
branch: worktree.branch
}
}
@@ -36,12 +36,12 @@ describe('TerminalTabLeadingIcon', () => {
expect(markup).toContain('data-agent-icon="codex"')
})
it('shows a needs-input (permission) state as an amber question glyph', () => {
it('shows a needs-input (permission) state as the shared question glyph', () => {
const markup = renderStatus('permission')
expect(markup).toContain('data-agent-activity-status="permission"')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('bg-red-500')
})
+5 -7
View File
@@ -5219,14 +5219,11 @@
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "Hide external worktrees permanently for {{value0}}",
"d9f7b2a14c": "Collapse new externally-created worktrees for {{value0}}",
"e2c4a8d91f": "Expand new externally-created worktrees for {{value0}}",
"7c4e9b2a81": "New externally-created worktrees",
"5e1b8d3f62": "Hide external worktrees permanently",
"4d7a1c9e53": "These worktrees were created outside of Orca.",
"8b3f2e1d74": "Import",
"1c9e7a4b28": "Keep hidden",
"6f2d8c1e95": "Import all",
"2a6f31d8c7": "hidden worktree",
"5b90e4a2f6": "hidden worktrees",
"7f18c5b0d3": "Review {{value0}} hidden worktree in {{value1}}",
"4e2b7a9c05": "Review {{value0}} hidden worktrees in {{value1}}",
"c3e8a1f4b2": "Don't show again"
},
"newExternalWorktreesInboxActions": {
@@ -15239,6 +15236,7 @@
"high": "High",
"xhigh": "Extra high",
"max": "Max",
"ultra": "Ultra",
"on": "On",
"off": "Off"
}
+1 -7
View File
@@ -5039,14 +5039,7 @@
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "Ocultar worktrees externos permanentemente para {{value0}}",
"d9f7b2a14c": "Contraer worktrees nuevos creados externamente para {{value0}}",
"e2c4a8d91f": "Expandir worktrees nuevos creados externamente para {{value0}}",
"7c4e9b2a81": "Nuevos worktrees creados externamente",
"5e1b8d3f62": "Ocultar worktrees externos permanentemente",
"4d7a1c9e53": "Estos worktrees se crearon fuera de Orca.",
"8b3f2e1d74": "Importar",
"1c9e7a4b28": "Mantener ocultos",
"6f2d8c1e95": "Importar todo",
"c3e8a1f4b2": "No volver a mostrar"
},
"newExternalWorktreesInboxActions": {
@@ -14332,6 +14325,7 @@
"high": "Alto",
"xhigh": "Muy alto",
"max": "Máximo",
"ultra": "Supremo",
"on": "Activado",
"off": "Desactivado"
},
+1 -7
View File
@@ -5039,14 +5039,7 @@
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "{{value0}} の外部ワークツリーを完全に非表示にする",
"d9f7b2a14c": "{{value0}} の外部で作成された新規ワークツリーを折りたたむ",
"e2c4a8d91f": "{{value0}} の外部で作成された新規ワークツリーを展開",
"7c4e9b2a81": "外部で作成された新規ワークツリー",
"5e1b8d3f62": "外部ワークツリーを完全に非表示にする",
"4d7a1c9e53": "これらのワークツリーは Orca の外部で作成されました。",
"8b3f2e1d74": "インポート",
"1c9e7a4b28": "非表示のままにする",
"6f2d8c1e95": "すべてインポート",
"c3e8a1f4b2": "今後表示しない"
},
"newExternalWorktreesInboxActions": {
@@ -14332,6 +14325,7 @@
"high": "高",
"xhigh": "最高",
"max": "最大",
"ultra": "ウルトラ",
"on": "オン",
"off": "オフ"
},
+1 -7
View File
@@ -5039,14 +5039,7 @@
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "{{value0}}의 외부 워크트리를 영구적으로 숨기기",
"d9f7b2a14c": "{{value0}}의 외부에서 생성된 새 워크트리 접기",
"e2c4a8d91f": "{{value0}}의 외부에서 생성된 새 워크트리 펼치기",
"7c4e9b2a81": "외부에서 생성된 새 워크트리",
"5e1b8d3f62": "외부 워크트리를 영구적으로 숨기기",
"4d7a1c9e53": "이 워크트리는 Orca 외부에서 생성되었습니다.",
"8b3f2e1d74": "가져오기",
"1c9e7a4b28": "숨긴 상태 유지",
"6f2d8c1e95": "모두 가져오기",
"c3e8a1f4b2": "다시 표시하지 않기"
},
"newExternalWorktreesInboxActions": {
@@ -14343,6 +14336,7 @@
"high": "높음",
"xhigh": "매우 높음",
"max": "최대",
"ultra": "울트라",
"on": "켜짐",
"off": "꺼짐"
},
+1 -7
View File
@@ -5051,14 +5051,7 @@
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "永久隐藏 {{value0}} 的外部工作树",
"d9f7b2a14c": "折叠 {{value0}} 的外部创建的新工作树",
"e2c4a8d91f": "展开 {{value0}} 的外部创建的新工作树",
"7c4e9b2a81": "外部创建的新工作树",
"5e1b8d3f62": "永久隐藏外部工作树",
"4d7a1c9e53": "这些工作树是在 Orca 外部创建的。",
"8b3f2e1d74": "导入",
"1c9e7a4b28": "保持隐藏",
"6f2d8c1e95": "全部导入",
"c3e8a1f4b2": "不再显示"
},
"newExternalWorktreesInboxActions": {
@@ -14352,6 +14345,7 @@
"high": "高",
"xhigh": "极高",
"max": "最大",
"ultra": "极致",
"on": "开",
"off": "关"
},
@@ -4,13 +4,30 @@ import es from './locales/es.json'
import ja from './locales/ja.json'
import ko from './locales/ko.json'
import zh from './locales/zh.json'
import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex'
const localizedCatalogs = { es, ja, ko, zh }
const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat
const englishSearch = en.auto.components.settings.experimental.search.nativeChat
const englishComposer = en.components['native-chat'].composer
const localizedEffortValues = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
const codexEffortValues = new Set(
[
...CODEX_SESSION_OPTION_CATALOG.models.flatMap((model) => model.options),
...(CODEX_SESSION_OPTION_CATALOG.unknownModelOptions ?? [])
].flatMap((option) =>
option.id === 'effort' && option.kind.type === 'select'
? option.kind.choices.map((choice) => choice.value)
: []
)
)
describe('native chat locale copy', () => {
it('covers every Codex effort choice', () => {
expect([...codexEffortValues].sort()).toEqual([...localizedEffortValues].sort())
})
it.each(Object.entries(localizedCatalogs))(
'%s keeps provider-neutral copy localized',
(_code, catalog) => {
@@ -42,7 +59,7 @@ describe('native chat locale copy', () => {
expect(composer[key].trim()).not.toBe('')
expect(composer[key]).not.toBe(englishComposer[key])
}
for (const key of ['fast', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const) {
for (const key of ['fast', ...localizedEffortValues] as const) {
expect(composer.optionValue[key].trim()).not.toBe('')
expect(composer.optionValue[key]).not.toBe(englishComposer.optionValue[key])
}
@@ -185,19 +185,21 @@ const CODEX_EFFORT_CHOICES = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' }
{ value: 'xhigh', label: 'Extra high' },
{ value: 'max', label: 'Max' },
{ value: 'ultra', label: 'Ultra' }
]
function codexEffort(includeExtraHigh: boolean): CatalogOption {
// Why: Codex can clamp higher values, so expose only each model's advertised levels.
function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption {
const ceilingIndex = CODEX_EFFORT_CHOICES.findIndex((choice) => choice.value === ceiling)
return {
id: 'effort',
label: 'Reasoning effort',
category: 'thought_level',
kind: {
type: 'select',
choices: includeExtraHigh
? CODEX_EFFORT_CHOICES
: CODEX_EFFORT_CHOICES.filter((choice) => choice.value !== 'xhigh'),
choices: CODEX_EFFORT_CHOICES.slice(0, ceilingIndex + 1),
defaultValue: 'medium'
},
apply: {
@@ -214,14 +216,14 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// Why: Codex model access depends on auth. Keep this seed short and allow
// unknown persisted ids to pass through instead of claiming a complete list.
models: [
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort(true)] },
{ id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort(true)] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort(false)] },
{ id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort(true)] },
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort('max')] },
{ id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort('xhigh')] },
{
id: 'gpt-5.2-codex',
label: 'GPT-5.2 Codex',
options: [codexEffort(true)]
options: [codexEffort('xhigh')]
}
],
modelApply: {
@@ -232,5 +234,5 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// command and let its own picker apply the account-supported model.
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
},
unknownModelOptions: [codexEffort(true)]
unknownModelOptions: [codexEffort('xhigh')]
}
@@ -38,7 +38,7 @@ describe('grok session option catalog', () => {
})
it('offers only effort values the shared option labels localize', () => {
const localized = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']
const localized = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']
const effort = grokEffortOption()
for (const choice of effort.kind.type === 'select' ? effort.kind.choices : []) {
expect(localized).toContain(choice.value)