mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/retire-generated-workspace-names
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "オフ"
|
||||
},
|
||||
|
||||
@@ -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": "꺼짐"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user