WIP: agent dashboard and status updates before rebase

This commit is contained in:
brennanb2025 2026-04-20 17:48:35 -07:00
parent 7e0f1d882b
commit 4671f31f0a
39 changed files with 1670 additions and 493 deletions

View file

@ -155,6 +155,51 @@ See "Changed Files Summary" above. Categories are:
## Iteration State
Current iteration: 1
Last completed phase: Setup
Files fixed this iteration: []
Current iteration: 2
Last completed phase: Iteration 1 Fix complete, typecheck clean
Files fixed iter 1:
- src/main/agent-hooks/server.ts (C1, C2, M2)
- src/main/ipc/pty.ts (H1)
- src/main/claude/hook-service.ts (H2a)
- src/main/codex/hook-service.ts (H2b)
- src/renderer/src/components/terminal-pane/pty-transport.ts (H3)
- src/renderer/src/hooks/useIpcEvents.ts (H4, M5, M6)
- src/renderer/src/components/terminal-pane/pty-connection.ts (H5)
- src/renderer/src/store/slices/terminals.ts (M1)
- src/preload/index.d.ts (M3)
- src/renderer/src/components/dashboard/useDashboardKeyboard.ts (M4)
- src/renderer/src/components/dashboard/AgentDashboard.tsx (M4 — container ref)
## Validated Fix Manifest (Iteration 1)
### Critical
- C1: src/main/agent-hooks/server.ts:20-23 - readJsonBody missing `return` after reject+destroy; no request timeout.
### High
- H1: src/main/ipc/pty.ts:194 - Hardcoded `:` PATH separator breaks Windows dev.
- H2a: src/main/claude/hook-service.ts:94-98 - getStatus returns `installed` if ANY event has managed command; should require ALL CLAUDE_EVENTS and return `partial` otherwise.
- H2b: src/main/codex/hook-service.ts:83-87 - Same as H2a for CODEX_EVENTS.
- H3: src/renderer/src/components/terminal-pane/pty-transport.ts:107-142 - Unbounded `pending` buffer in OSC processor.
- H4: src/renderer/src/hooks/useIpcEvents.ts:407,424 - Debug console.log leaks payload contents on every event.
- H5: src/renderer/src/components/terminal-pane/pty-connection.ts:162-175 - `onAgentStatus` callback on transport not wired from pty-connection, so OSC 9999 payloads are parsed but never forwarded to store.
### Medium
- M1: src/renderer/src/store/slices/terminals.ts:344-349,404 - Inline agent-status sweep skips epoch bump; call `removeAgentStatusByTabPrefix` instead.
- M2: src/main/agent-hooks/server.ts:121-168 - Startup `once('error', reject)` never removed, no persistent error listener; runtime errors can crash main process.
- M3: src/preload/index.d.ts:208-228 - Api type references undefined types (HooksApi, CacheApi, SessionApi, UpdaterApi, FsApi, GitApi, UIApi, RuntimeApi) and duplicates PreloadApi fields.
- M4: src/renderer/src/components/dashboard/useDashboardKeyboard.ts - Global keydown listener intercepts arrow keys and digits even when focus is in terminal, can break terminal navigation.
- M5: src/renderer/src/hooks/useIpcEvents.ts:440-452 - findTerminalTitleForPaneKey returns tab-level title not pane-level; wrong for split panes.
- M6: src/renderer/src/hooks/useIpcEvents.ts:406-431 - No paneKey validation; orphan status entries persist forever.
### Deferred to next iteration (Medium, non-blocking)
- M7: src/renderer/src/components/sidebar/WorktreeCard.tsx:148-150 - Double Object.values filter.
- M8: src/renderer/src/components/sidebar/AgentStatusHover.tsx:334-338 - `now` useMemo staleness.
- M9: src/renderer/src/components/dashboard/useDashboardData.ts:80-82 - O(W*T*P) per render.
- M10: src/cli/runtime-client.ts:386-391 - ORCA_USER_DATA_PATH no trim/validate.
### Skipped (won't fix)
- L: Dead exports AgentStatusPayload, WellKnownAgentType — they document the wire contract and cost nothing.
- L: `'status'` in isCommandGroup — benign per reviewer; callers consult findCommandSpec first.
- L: normalizeField whitespace collapse scope — matches design intent.
- L: pty-transport dead regex exports (extractAgentStatusOsc, stripAgentStatusOsc) — may be test helpers; low churn. Will revisit in iter 2.
- L: `isExplicitAgentStatusFresh` 3x per entry in smart-sort — trivial, defer.

View file

@ -4,23 +4,49 @@ import {
parseAgentStatusPayload,
type ParsedAgentStatusPayload
} from '../../shared/agent-status-types'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
type AgentHookSource = 'claude' | 'codex'
type AgentHookSource = 'claude' | 'codex' | 'gemini'
type AgentHookEventPayload = {
paneKey: string
tabId?: string
worktreeId?: string
payload: ParsedAgentStatusPayload
}
// Why: only log a given version/env mismatch once per process so a stale hook
// script that fires on every keystroke doesn't flood the logs.
const warnedVersions = new Set<string>()
const warnedEnvs = new Set<string>()
// Why: Claude documents `prompt` on UserPromptSubmit; other agents may use
// different field names. Probe a small allowlist so we can surface the real
// user prompt in the dashboard regardless of which agent is reporting.
function extractPromptText(hookPayload: Record<string, unknown>): string {
const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message']
for (const key of candidateKeys) {
const value = hookPayload[key]
if (typeof value === 'string' && value.trim().length > 0) {
return value
}
}
return ''
}
function readJsonBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString('utf8')
if (body.length > 1_000_000) {
// Why: check size before appending and return immediately after destroy
// so we don't keep accumulating bytes after rejecting, which could let a
// malicious client push memory usage well past the advertised limit.
if (body.length + chunk.length > 1_000_000) {
reject(new Error('payload too large'))
req.destroy()
return
}
body += chunk.toString('utf8')
})
req.on('end', () => {
try {
@ -33,7 +59,26 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
})
}
function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | null {
// Why: only UserPromptSubmit carries the user's prompt. Subsequent events in
// the same turn (PostToolUse, PermissionRequest, Stop, …) arrive with no
// prompt, so we cache the last prompt per pane and reuse it until a new
// prompt arrives. The cache survives across `done` so the user can still see
// what finished; it's reset on the next UserPromptSubmit.
const lastPromptByPaneKey = new Map<string, string>()
function resolvePrompt(paneKey: string, promptText: string): string {
if (promptText) {
lastPromptByPaneKey.set(paneKey, promptText)
return promptText
}
return lastPromptByPaneKey.get(paneKey) ?? ''
}
function normalizeClaudeEvent(
eventName: unknown,
promptText: string,
paneKey: string
): ParsedAgentStatusPayload | null {
const state =
eventName === 'UserPromptSubmit' ||
eventName === 'PostToolUse' ||
@ -52,18 +97,46 @@ function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | nu
return parseAgentStatusPayload(
JSON.stringify({
state,
summary:
state === 'waiting'
? 'Waiting for permission'
: state === 'done'
? 'Turn complete'
: 'Responding to prompt',
prompt: resolvePrompt(paneKey, promptText),
agentType: 'claude'
})
)
}
function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | null {
// Why: Gemini CLI exposes BeforeAgent/AfterAgent/AfterTool hooks. BeforeAgent
// fires at turn start and AfterTool resumes the working state after a tool
// call completes; AfterAgent fires when the agent becomes idle. Gemini has no
// permission-prompt hook, so we cannot surface a waiting state for Gemini.
function normalizeGeminiEvent(
eventName: unknown,
promptText: string,
paneKey: string
): ParsedAgentStatusPayload | null {
const state =
eventName === 'BeforeAgent' || eventName === 'AfterTool'
? 'working'
: eventName === 'AfterAgent'
? 'done'
: null
if (!state) {
return null
}
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText),
agentType: 'gemini'
})
)
}
function normalizeCodexEvent(
eventName: unknown,
promptText: string,
paneKey: string
): ParsedAgentStatusPayload | null {
const state =
eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
? 'working'
@ -80,20 +153,25 @@ function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | nul
return parseAgentStatusPayload(
JSON.stringify({
state,
summary:
state === 'waiting'
? 'Waiting for permission'
: state === 'done'
? 'Turn complete'
: 'Responding to prompt',
prompt: resolvePrompt(paneKey, promptText),
agentType: 'codex'
})
)
}
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
function normalizeHookPayload(
source: AgentHookSource,
body: unknown
body: unknown,
expectedEnv: string
): AgentHookEventPayload | null {
if (typeof body !== 'object' || body === null) {
return null
@ -106,28 +184,93 @@ function normalizeHookPayload(
return null
}
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
const payload =
source === 'claude' ? normalizeClaudeEvent(eventName) : normalizeCodexEvent(eventName)
// Why: scripts installed by an older app build may send a different shape.
// We accept the request (fail-open) but log once so stale installs are
// diagnosable instead of silently degrading.
const version = readStringField(record, 'version')
if (version && version !== ORCA_HOOK_PROTOCOL_VERSION && !warnedVersions.has(version)) {
warnedVersions.add(version)
console.warn(
`[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` +
'Reinstall agent hooks from Settings to upgrade the managed script.'
)
}
return payload ? { paneKey, payload } : null
// Why: detects dev-vs-prod cross-talk. A hook installed by a dev build but
// triggered inside a prod terminal (or vice versa) still points at whichever
// loopback port the shell env captured, so the *other* instance may receive
// it. Logging the mismatch lets a user know their terminals are wired to the
// wrong Orca.
const clientEnv = readStringField(record, 'env')
if (clientEnv && clientEnv !== expectedEnv) {
const key = `${clientEnv}->${expectedEnv}`
if (!warnedEnvs.has(key)) {
warnedEnvs.add(key)
console.warn(
`[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` +
'Likely a stale terminal from another Orca install.'
)
}
}
const tabId = readStringField(record, 'tabId')
const worktreeId = readStringField(record, 'worktreeId')
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
console.log('[agent-hooks:server] incoming', {
source,
paneKey,
tabId,
worktreeId,
eventName,
promptTextLen: promptText.length,
promptPreview: promptText.slice(0, 80),
cachedPrompt: lastPromptByPaneKey.get(paneKey)?.slice(0, 80) ?? null,
hookPayloadKeys: Object.keys(hookPayload as Record<string, unknown>)
})
const payload =
source === 'claude'
? normalizeClaudeEvent(eventName, promptText, paneKey)
: source === 'codex'
? normalizeCodexEvent(eventName, promptText, paneKey)
: normalizeGeminiEvent(eventName, promptText, paneKey)
console.log('[agent-hooks:server] normalized', {
paneKey,
payload: payload
? {
state: payload.state,
promptLen: payload.prompt.length,
prompt: payload.prompt.slice(0, 80)
}
: null
})
return payload ? { paneKey, tabId, worktreeId, payload } : null
}
export class AgentHookServer {
private server: ReturnType<typeof createServer> | null = null
private port = 0
private token = ''
// Why: identifies this Orca instance so hook scripts can stamp requests and
// the server can detect dev vs. prod cross-talk. Set at start() from the
// caller's knowledge of whether this is a packaged build.
private env = 'production'
private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null
setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void {
this.onAgentStatus = listener
}
async start(): Promise<void> {
async start(options?: { env?: string }): Promise<void> {
if (this.server) {
return
}
if (options?.env) {
this.env = options.env
}
this.token = randomUUID()
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== 'POST') {
@ -142,17 +285,30 @@ export class AgentHookServer {
return
}
// Why: bound request time so a slow/stalled client cannot hold a socket
// open indefinitely (slowloris-style). The hook endpoints are local and
// should complete in well under a second.
req.setTimeout(5000, () => {
req.destroy()
})
try {
const body = await readJsonBody(req)
const source =
req.url === '/hook/claude' ? 'claude' : req.url === '/hook/codex' ? 'codex' : null
const source: AgentHookSource | null =
req.url === '/hook/claude'
? 'claude'
: req.url === '/hook/codex'
? 'codex'
: req.url === '/hook/gemini'
? 'gemini'
: null
if (!source) {
res.writeHead(404)
res.end()
return
}
const payload = normalizeHookPayload(source, body)
const payload = normalizeHookPayload(source, body, this.env)
if (payload) {
this.onAgentStatus?.(payload)
}
@ -168,14 +324,29 @@ export class AgentHookServer {
})
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject)
this.server!.listen(0, '127.0.0.1', () => {
// Why: the startup error handler must only reject the start() promise for
// errors that happen before 'listening'. Without swapping it out on
// success, any later runtime error (e.g. EADDRINUSE during rebind,
// socket errors) would call reject() on an already-settled promise and,
// more importantly, leaving it as the only 'error' listener means node
// treats runtime errors as unhandled and crashes the main process.
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
reject(err)
}
const onListening = (): void => {
this.server?.off('error', onStartupError)
this.server?.on('error', (err) => {
console.error('[agent-hooks] server error', err)
})
const address = this.server!.address()
if (address && typeof address === 'object') {
this.port = address.port
}
resolve()
})
}
this.server!.once('error', onStartupError)
this.server!.listen(0, '127.0.0.1', onListening)
})
}
@ -184,6 +355,7 @@ export class AgentHookServer {
this.server = null
this.port = 0
this.token = ''
this.env = 'production'
this.onAgentStatus = null
}
@ -194,7 +366,9 @@ export class AgentHookServer {
return {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
}
}

View file

@ -1,7 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
import { app } from 'electron'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
readHooksJson,
removeManagedCommands,
@ -51,7 +51,7 @@ function getManagedScript(): string {
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/claude') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/claude') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
'exit /b 0',
''
].join('\r\n')
@ -66,7 +66,11 @@ function getManagedScript(): string {
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
`body=$(printf '{"paneKey":"%s","payload":%s}' "$ORCA_PANE_KEY" "$payload")`,
// Why: routing/version metadata is included alongside the raw hook payload
// so the receiver can (a) group panes by tab/worktree without round-tripping
// through paneKey parsing, (b) warn on dev/prod cross-talk, and (c) detect
// stale managed scripts installed by an older app build.
`body=$(printf '{"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","payload":%s}' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" "$payload")`,
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' -H "Content-Type: application/json" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@ -91,19 +95,40 @@ export class ClaudeHookService {
}
}
const managedHooksPresent = Object.values(config.hooks ?? {}).some((definitions) =>
definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === getManagedCommand(scriptPath))
// Why: Report `partial` when only some managed events are registered so the
// sidebar surfaces a degraded install rather than a false-positive
// `installed`. Each CLAUDE_EVENTS entry must contain the managed command for
// the integration to function end-to-end.
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
for (const event of CLAUDE_EVENTS) {
const definitions = Array.isArray(config.hooks?.[event.eventName])
? config.hooks![event.eventName]!
: []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
)
return {
agent: 'claude',
state: managedHooksPresent ? 'installed' : 'not_installed',
configPath,
managedHooksPresent,
detail: null
if (hasCommand) {
presentCount += 1
} else {
missing.push(event.eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: 'claude', state, configPath, managedHooksPresent, detail }
}
install(): AgentHookInstallStatus {

View file

@ -1,7 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
import { app } from 'electron'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
readHooksJson,
removeManagedCommands,
@ -40,7 +40,7 @@ function getManagedScript(): string {
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/codex') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/codex') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
'exit /b 0',
''
].join('\r\n')
@ -55,7 +55,11 @@ function getManagedScript(): string {
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
`body=$(printf '{"paneKey":"%s","payload":%s}' "$ORCA_PANE_KEY" "$payload")`,
// Why: routing/version metadata is included alongside the raw hook payload
// so the receiver can (a) group panes by tab/worktree without round-tripping
// through paneKey parsing, (b) warn on dev/prod cross-talk, and (c) detect
// stale managed scripts installed by an older app build.
`body=$(printf '{"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","payload":%s}' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" "$payload")`,
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' -H "Content-Type: application/json" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@ -80,19 +84,39 @@ export class CodexHookService {
}
}
const managedHooksPresent = Object.values(config.hooks ?? {}).some((definitions) =>
definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === getManagedCommand(scriptPath))
// Why: Report `partial` when only some managed events are registered so the
// sidebar surfaces a degraded install rather than a false-positive
// `installed`. Each CODEX_EVENTS entry must contain the managed command for
// the integration to function end-to-end (e.g. PreToolUse is required for
// permission-prompt detection per the comment above).
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
for (const eventName of CODEX_EVENTS) {
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
)
return {
agent: 'codex',
state: managedHooksPresent ? 'installed' : 'not_installed',
configPath,
managedHooksPresent,
detail: null
if (hasCommand) {
presentCount += 1
} else {
missing.push(eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: 'codex', state, configPath, managedHooksPresent, detail }
}
install(): AgentHookInstallStatus {

View file

@ -0,0 +1,191 @@
import { homedir } from 'os'
import { join } from 'path'
import { app } from 'electron'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
readHooksJson,
removeManagedCommands,
writeHooksJson,
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when
// it completes. `AfterTool` marks the resumption of model work after a tool
// call, which maps back to `working`. Gemini has no permission-prompt hook
// (approvals flow through inline UI), so Orca cannot surface a waiting state
// for Gemini — that is an upstream limitation, not an Orca bug.
const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool'] as const
function getConfigPath(): string {
return join(homedir(), '.gemini', 'settings.json')
}
function getManagedScriptPath(): string {
return join(
app.getPath('userData'),
'agent-hooks',
process.platform === 'win32' ? 'gemini-hook.cmd' : 'gemini-hook.sh'
)
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : `/bin/sh "${scriptPath}"`
}
function getManagedScript(): string {
if (process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: Gemini expects valid JSON on stdout even when the hook has nothing
// to return. Emit `{}` first so the agent never stalls parsing our
// output, even if the env-var guards below cause an early exit.
'echo {}',
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/gemini') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
'exit /b 0',
''
].join('\r\n')
}
return [
'#!/bin/sh',
// Why: Gemini expects valid JSON on stdout even when the hook has nothing
// to return. Emit `{}` first so the agent never stalls parsing our output,
// even if the env-var guards below cause an early exit.
'printf "{}\\n"',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
'payload=$(cat)',
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
// Why: routing/version metadata is included alongside the raw hook payload
// so the receiver can (a) group panes by tab/worktree without round-tripping
// through paneKey parsing, (b) warn on dev/prod cross-talk, and (c) detect
// stale managed scripts installed by an older app build.
`body=$(printf '{"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","payload":%s}' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" "$payload")`,
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
' -H "Content-Type: application/json" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-binary "$body" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
}
export class GeminiHookService {
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'gemini',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Gemini settings.json'
}
}
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
for (const eventName of GEMINI_EVENTS) {
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
if (hasCommand) {
presentCount += 1
} else {
missing.push(eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: 'gemini', state, configPath, managedHooksPresent, detail }
}
install(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'gemini',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Gemini settings.json'
}
}
const command = getManagedCommand(scriptPath)
const nextHooks = { ...config.hooks }
for (const eventName of GEMINI_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, (currentCommand) => currentCommand === command)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
}
nextHooks[eventName] = [...cleaned, definition]
}
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
return this.getStatus()
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'gemini',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Gemini settings.json'
}
}
const command = getManagedCommand(scriptPath)
const nextHooks = { ...config.hooks }
for (const [eventName, definitions] of Object.entries(nextHooks)) {
const cleaned = removeManagedCommands(
definitions,
(currentCommand) => currentCommand === command
)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
config.hooks = nextHooks
writeHooksJson(configPath, config)
return this.getStatus()
}
}
export const geminiHookService = new GeminiHookService()

View file

@ -38,6 +38,7 @@ import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { claudeHookService } from './claude/hook-service'
import { codexHookService } from './codex/hook-service'
import { geminiHookService } from './gemini/hook-service'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@ -137,11 +138,25 @@ function openMainWindow(): BrowserWindow {
}
})
mainWindow = window
agentHookServer.setListener(({ paneKey, payload }) => {
agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => {
if (mainWindow?.isDestroyed()) {
console.log('[agent-hooks:main] drop (window destroyed)', { paneKey })
return
}
mainWindow?.webContents.send('agentStatus:set', { paneKey, ...payload })
console.log('[agent-hooks:main] forward to renderer', {
paneKey,
tabId,
worktreeId,
state: payload.state,
promptLen: payload.prompt.length,
promptPreview: payload.prompt.slice(0, 80)
})
mainWindow?.webContents.send('agentStatus:set', {
paneKey,
tabId,
worktreeId,
...payload
})
})
return window
}
@ -172,7 +187,8 @@ app.whenReady().then(async () => {
// Startup must fail open so a malformed local config never bricks Orca.
for (const installManagedHooks of [
() => claudeHookService.install(),
() => codexHookService.install()
() => codexHookService.install(),
() => geminiHookService.install()
]) {
try {
installManagedHooks()
@ -245,7 +261,7 @@ app.whenReady().then(async () => {
openCodeHookService.start().catch((error) => {
console.error('[opencode] Failed to start local hook server:', error)
}),
agentHookServer.start().catch((error) => {
agentHookServer.start({ env: app.isPackaged ? 'production' : 'development' }).catch((error) => {
// Why: Claude/Codex hook callbacks are sidebar enrichment only. Orca must
// still boot even if the local loopback receiver cannot bind on this launch.
console.error('[agent-hooks] Failed to start local hook server:', error)

View file

@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
// Why: install/remove are intentionally not exposed to the renderer. Orca
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
@ -16,4 +17,8 @@ export function registerAgentHookHandlers(): void {
'agentHooks:codexStatus',
(): AgentHookInstallStatus => codexHookService.getStatus()
)
ipcMain.handle(
'agentHooks:geminiStatus',
(): AgentHookInstallStatus => geminiHookService.getStatus()
)
}

View file

@ -3,7 +3,7 @@ main-process module so spawn-time environment scoping, lifecycle cleanup,
foreground-process inspection, and renderer IPC stay behind a single audited
boundary. Splitting it by line count would scatter tightly coupled terminal
process behavior across files without a cleaner ownership seam. */
import { join } from 'path'
import { join, delimiter } from 'path'
import { type BrowserWindow, ipcMain, app } from 'electron'
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@ -192,7 +192,7 @@ export function registerPtyHandlers(
const devUserData = app.getPath('userData')
baseEnv.ORCA_USER_DATA_PATH ??= devUserData
const devCliBin = join(devUserData, 'cli', 'bin')
baseEnv.PATH = `${devCliBin}:${baseEnv.PATH ?? ''}`
baseEnv.PATH = `${devCliBin}${delimiter}${baseEnv.PATH ?? ''}`
}
return baseEnv
@ -325,11 +325,18 @@ export function registerPtyHandlers(
}
) => {
const provider = getProvider(args.connectionId)
// Why: agent hook env (ORCA_AGENT_HOOK_PORT/TOKEN) is normally injected by
// the LocalPtyProvider's buildSpawnEnv. When the daemon is active, the
// local provider is replaced by DaemonPtyAdapter and buildSpawnEnv never
// runs — so hook receivers can't find the loopback server. Inject here
// as well so both provider paths get the env.
const hookEnv = agentHookServer.buildPtyEnv()
const effectiveEnv = Object.keys(hookEnv).length > 0 ? { ...args.env, ...hookEnv } : args.env
const result = await provider.spawn({
cols: args.cols,
rows: args.rows,
cwd: args.cwd,
env: args.env,
env: effectiveEnv,
command: args.command,
worktreeId: args.worktreeId,
sessionId: args.sessionId

View file

@ -156,6 +156,7 @@ type CliApi = {
type AgentHooksApi = {
claudeStatus: () => Promise<AgentHookInstallStatus>
codexStatus: () => Promise<AgentHookInstallStatus>
geminiStatus: () => Promise<AgentHookInstallStatus>
}
type NotificationsApi = {
@ -202,14 +203,19 @@ type AgentStatusApi = {
onSet: (
callback: (data: {
paneKey: string
tabId?: string
worktreeId?: string
state: string
summary?: string
next?: string
prompt?: string
agentType?: string
}) => void
) => () => void
}
// Why: Only locally-defined *Api types are listed here. Keys like preflight,
// hooks, cache, session, updater, fs, git, ui, and runtime are inherited via
// the PreloadApi intersection (see ./api-types), so re-declaring them would
// reference undefined type names and risk drifting from the canonical surface.
type Api = PreloadApi & {
repos: ReposApi
worktrees: WorktreesApi
@ -219,17 +225,8 @@ type Api = PreloadApi & {
settings: SettingsApi
cli: CliApi
agentHooks: AgentHooksApi
preflight: PreflightApi
notifications: NotificationsApi
shell: ShellApi
hooks: HooksApi
cache: CacheApi
session: SessionApi
updater: UpdaterApi
fs: FsApi
git: GitApi
ui: UIApi
runtime: RuntimeApi
agentStatus: AgentStatusApi
}

View file

@ -457,7 +457,10 @@ const api = {
agentHooks: {
claudeStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:claudeStatus'),
codexStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:codexStatus')
codexStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:codexStatus'),
geminiStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:geminiStatus')
},
preflight: {
@ -1344,9 +1347,10 @@ const api = {
onSet: (
callback: (data: {
paneKey: string
tabId?: string
worktreeId?: string
state: string
summary?: string
next?: string
prompt?: string
agentType?: string
}) => void
): (() => void) => {
@ -1354,9 +1358,10 @@ const api = {
_event: Electron.IpcRendererEvent,
data: {
paneKey: string
tabId?: string
worktreeId?: string
state: string
summary?: string
next?: string
prompt?: string
agentType?: string
}
) => callback(data)

View file

@ -564,10 +564,10 @@ function App(): React.JSX.Element {
return
}
// Cmd/Ctrl+Shift+D — toggle right sidebar / agent dashboard tab
// Cmd/Ctrl+Shift+D — open right sidebar (agent dashboard is now
// docked at the sidebar bottom, so only toggle visibility).
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') {
e.preventDefault()
actions.setRightSidebarTab('dashboard')
actions.setRightSidebarOpen(true)
}
}

View file

@ -0,0 +1,112 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import type { AgentType } from '../../../shared/agent-status-types'
// Why: single shared primitive for rendering an agent in a list. The agent
// icon *is* the status indicator — state is conveyed by a colored ring and
// optional animation around the icon, not by a separate dot. The tooltip
// surfaces both the agent name and its current state so hover gives full
// attribution without consuming horizontal space in the row.
export type AgentStatusBadgeState =
| 'working'
| 'blocked'
| 'waiting'
| 'done'
| 'idle'
// Why: heuristic rows derived from terminal titles only distinguish "needs
// attention" as a single state; render it the same as `blocked`.
| 'permission'
function stateLabel(state: AgentStatusBadgeState): string {
switch (state) {
case 'working':
return 'Working'
case 'blocked':
return 'Blocked'
case 'waiting':
return 'Waiting for input'
case 'done':
return 'Done'
case 'idle':
return 'Idle'
case 'permission':
return 'Needs attention'
}
}
// Why: for non-spinning states the ring is just a solid outline on the
// wrapper. Working is handled separately below (spinning arc overlay) because
// a full solid ring with `animate-spin` would look static — there's no visible
// rotation without an asymmetry in the stroke.
function staticRingClasses(state: AgentStatusBadgeState): string {
switch (state) {
case 'blocked':
case 'waiting':
case 'permission':
return 'ring-2 ring-amber-500 ring-inset animate-pulse'
case 'done':
return 'ring-2 ring-sky-500/70 ring-inset'
case 'idle':
return 'ring-1 ring-zinc-400/30 ring-inset'
case 'working':
return '' // handled by overlay
}
}
type Props = {
agentType: AgentType | null | undefined
state: AgentStatusBadgeState
size?: number
className?: string
}
export const AgentStatusBadge = React.memo(function AgentStatusBadge({
agentType,
state,
size = 12,
className
}: Props): React.JSX.Element {
const iconAgent = agentTypeToIconAgent(agentType)
const agentLabel = formatAgentTypeLabel(agentType)
const tooltip = `${agentLabel}${stateLabel(state)}`
// Why: the ring sits on a padded wrapper (not the icon itself) so the
// visible gap between icon and ring is consistent regardless of icon size.
// Total box is size + 2*padding; the working overlay matches that box.
const padding = Math.max(2, Math.round(size / 6))
const box = size + padding * 2
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'relative inline-flex shrink-0 items-center justify-center rounded-full',
staticRingClasses(state),
className
)}
style={{ padding, width: box, height: box }}
aria-label={tooltip}
>
<AgentIcon agent={iconAgent} size={size} />
{state === 'working' && (
// Why: a spinning arc around the icon. The ring has a colored top
// border and transparent bottom so rotation is visible; inset keeps
// the arc aligned with the outline used by the other states.
<span
className="pointer-events-none absolute inset-0 rounded-full border-2 border-transparent border-t-emerald-500 animate-spin"
aria-hidden
/>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{tooltip}
</TooltipContent>
</Tooltip>
)
})

View file

@ -1,12 +1,43 @@
import React, { useState, useCallback, useMemo } from 'react'
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import { useAppStore } from '@/store'
import { useDashboardData } from './useDashboardData'
import { useDashboardFilter } from './useDashboardFilter'
import { useDashboardKeyboard } from './useDashboardKeyboard'
import { useRetainedAgents } from './useRetainedAgents'
import DashboardFilterBar from './DashboardFilterBar'
import DashboardRepoGroup from './DashboardRepoGroup'
const AgentDashboard = React.memo(function AgentDashboard() {
const groups = useDashboardData()
const liveGroups = useDashboardData()
// Why: useRetainedAgents keeps a "done" row visible after the terminal/pane
// is closed and the explicit status entry is evicted from the store. Without
// this, a completed agent flips to 'idle' (from the heuristic title scan) or
// vanishes entirely — and the user loses the signal that the agent finished.
// Retained rows are dismissed when the user clicks through to the worktree.
const {
enrichedGroups: groups,
dismissWorktreeAgents,
dismissAgent
} = useRetainedAgents(liveGroups)
const removeAgentStatus = useAppStore((s) => s.removeAgentStatus)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const setActiveView = useAppStore((s) => s.setActiveView)
// Why: the store's explicit status entry persists after an agent reports
// `done` until the pane actually exits — which may be much later, since the
// user often leaves the Claude/Codex session alive to review output. The
// per-row dismiss removes both the live store entry and any retained entry
// so done agents don't pile up indefinitely in the dashboard.
const handleDismissAgent = useCallback(
(paneKey: string) => {
if (!paneKey.startsWith('heuristic:')) {
removeAgentStatus(paneKey)
}
dismissAgent(paneKey)
},
[removeAgentStatus, dismissAgent]
)
// Why: checkedWorktreeIds tracks worktrees the user has navigated into. These
// are hidden from the 'active' filter so done agents disappear once the user
@ -21,9 +52,36 @@ const AgentDashboard = React.memo(function AgentDashboard() {
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
const [focusedWorktreeId, setFocusedWorktreeId] = useState<string | null>(null)
const handleCheckWorktree = useCallback((worktreeId: string) => {
setCheckedWorktreeIds((prev) => new Set(prev).add(worktreeId))
}, [])
// Why: the keyboard hook scopes its listener to this container (not window)
// so dashboard shortcuts (1-5, arrows, Enter, Escape) don't hijack the
// terminal or other focused inputs when the dashboard pane is merely open.
const containerRef = useRef<HTMLDivElement>(null)
const handleCheckWorktree = useCallback(
(worktreeId: string) => {
setCheckedWorktreeIds((prev) => new Set(prev).add(worktreeId))
// Why: when the user clicks a done worktree, they've acknowledged it.
// Dismiss retained rows so they stop lingering in the list.
dismissWorktreeAgents(worktreeId)
},
[dismissWorktreeAgents]
)
// Why: clicking an agent row takes the user to the specific tab the agent
// ran in, not just the worktree's last-active tab. Retained rows can outlive
// their pane — fall back to worktree-only activation when the tab is no
// longer present so the click still lands somewhere useful.
const handleActivateAgentTab = useCallback(
(worktreeId: string, tabId: string) => {
setActiveWorktree(worktreeId)
setActiveView('terminal')
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.some((t) => t.id === tabId)) {
setActiveTab(tabId)
}
},
[setActiveWorktree, setActiveTab, setActiveView]
)
const toggleCollapse = useCallback((repoId: string) => {
setCollapsedRepos((prev) => {
@ -43,9 +101,18 @@ const AgentDashboard = React.memo(function AgentDashboard() {
focusedWorktreeId,
setFocusedWorktreeId,
filter,
setFilter
setFilter,
containerRef
})
// Why: focus the container on mount so keyboard shortcuts work immediately
// without requiring an initial click inside the dashboard. tabIndex={-1}
// on the container makes it programmatically focusable without inserting
// it into the tab order.
useEffect(() => {
containerRef.current?.focus()
}, [])
// Summary stats across all repos (unfiltered)
const stats = useMemo(() => {
let running = 0
@ -80,29 +147,35 @@ const AgentDashboard = React.memo(function AgentDashboard() {
}
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border/40 px-3 py-2">
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
{stats.running > 0 && (
<span>
<span className="font-semibold text-emerald-500">{stats.running}</span> running
</span>
)}
{stats.blocked > 0 && (
<span>
<span className="font-semibold text-amber-500">{stats.blocked}</span> blocked
</span>
)}
{stats.done > 0 && (
<span>
<span className="font-semibold text-sky-500/80">{stats.done}</span> done
</span>
)}
{stats.running === 0 && stats.blocked === 0 && stats.done === 0 && (
<span className="text-muted-foreground/50">No active agents</span>
)}
<div
ref={containerRef}
tabIndex={-1}
className="flex h-full w-full flex-col overflow-hidden outline-none"
>
{/* Why: hide the stats strip entirely when there's nothing to count
the empty-state message in the main panel already tells the user
there's no activity, and showing both reads as duplicated chrome. */}
{(stats.running > 0 || stats.blocked > 0 || stats.done > 0) && (
<div className="shrink-0 border-b border-border/40 px-3 py-2">
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
{stats.running > 0 && (
<span>
<span className="font-semibold text-emerald-500">{stats.running}</span> running
</span>
)}
{stats.blocked > 0 && (
<span>
<span className="font-semibold text-amber-500">{stats.blocked}</span> blocked
</span>
)}
{stats.done > 0 && (
<span>
<span className="font-semibold text-sky-500/80">{stats.done}</span> done
</span>
)}
</div>
</div>
</div>
)}
<div className="flex shrink-0 items-center justify-center border-b border-border/40 px-2 py-1.5">
<DashboardFilterBar value={filter} onChange={setFilter} />
@ -120,12 +193,20 @@ const AgentDashboard = React.memo(function AgentDashboard() {
focusedWorktreeId={focusedWorktreeId}
onFocusWorktree={setFocusedWorktreeId}
onCheckWorktree={handleCheckWorktree}
onDismissAgent={handleDismissAgent}
onActivateAgentTab={handleActivateAgentTab}
/>
))
) : (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<div className="text-[11px] text-muted-foreground/60">
{filter === 'active' ? 'All agents are idle.' : 'No worktrees match this filter.'}
{filter === 'active'
? 'No agents need your attention.'
: filter === 'blocked'
? 'No agents are blocked.'
: filter === 'done'
? 'No completed agents to show.'
: 'No agent activity yet.'}
</div>
{filter !== 'all' && (
<button

View file

@ -1,79 +1,207 @@
import React from 'react'
import React, { useEffect, useState, useCallback } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { AgentStatusBadge, type AgentStatusBadgeState } from '@/components/AgentStatusBadge'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
function currentDotClasses(state: string): string {
// Why: the dashboard tracks its own rollup states (incl. 'idle'); narrow to the
// shared badge states for rendering, falling back to 'idle' for any unknown
// value so an unexpected state never crashes a row.
function asBadgeState(state: string): AgentStatusBadgeState {
switch (state) {
case 'working':
return 'bg-emerald-500'
case 'blocked':
case 'waiting':
return 'bg-amber-500 animate-pulse'
case 'done':
return 'bg-sky-500/70'
case 'idle':
default:
return 'bg-zinc-400/40'
}
}
function stateLabel(state: string): string {
switch (state) {
case 'working':
return 'Working'
case 'blocked':
return 'Blocked'
case 'waiting':
return 'Waiting'
case 'done':
return 'Done'
case 'idle':
return 'Idle'
default:
return state
default:
return 'idle'
}
}
function stateLabelColor(state: string): string {
switch (state) {
case 'working':
return 'text-emerald-500'
case 'blocked':
case 'waiting':
return 'text-amber-500'
case 'done':
return 'text-sky-500/80'
default:
return 'text-zinc-500'
function formatTimeAgo(ts: number, now: number): string {
const delta = now - ts
if (delta < 60_000) {
return 'just now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
return `${days}d ago`
}
// Why: surface the moment the agent most recently transitioned *into* done.
// History entries are stamped with the state's own startedAt on push, so a
// past done sits at `history[i].startedAt`. When the current live state is
// done, the best approximation we have is `updatedAt` (exact on first report,
// drifts by at most one re-report interval thereafter).
function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null {
const entry = agent.entry
if (!entry) {
return null
}
if (entry.state === 'done') {
return entry.updatedAt
}
for (let i = entry.stateHistory.length - 1; i >= 0; i--) {
if (entry.stateHistory[i].state === 'done') {
return entry.stateHistory[i].startedAt
}
}
return null
}
type Props = {
agent: DashboardAgentRowData
onDismiss: (paneKey: string) => void
/** Navigate directly to the tab this agent lives in. */
onActivate: (tabId: string) => void
}
const DashboardAgentRow = React.memo(function DashboardAgentRow({ agent }: Props) {
const agentLabel = formatAgentTypeLabel(agent.agentType)
function useNow(intervalMs: number): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
// Why: relative timestamps drift once mounted. A 30s tick keeps the "Xm
// ago" labels honest without burning a render every second.
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
return now
}
const DashboardAgentRow = React.memo(function DashboardAgentRow({
agent,
onDismiss,
onActivate
}: Props) {
const now = useNow(30_000)
// Why: stop propagation so clicking the X doesn't also fire the worktree
// card's click handler (which navigates away from the dashboard).
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onDismiss(agent.paneKey)
},
[onDismiss, agent.paneKey]
)
// Why: agent rows navigate directly to the agent's own tab, while the
// surrounding worktree card navigates to whatever tab the worktree last had
// focused. Stop propagation so the card click handler does not run second
// and override our tab activation.
const handleActivate = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onActivate(agent.tab.id)
},
[onActivate, agent.tab.id]
)
const handleActivateKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onActivate(agent.tab.id)
}
},
[onActivate, agent.tab.id]
)
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
const doneAt = lastEnteredDoneAt(agent)
console.log('[agent-hooks:DashboardAgentRow] render', {
paneKey: agent.paneKey,
source: agent.source,
state: agent.state,
agentType: agent.agentType,
hasEntry: agent.entry !== null,
entryPromptLen: agent.entry?.prompt.length ?? 0,
entryPromptPreview: agent.entry?.prompt.slice(0, 80) ?? null
})
const prompt = agent.entry?.prompt.trim() ?? ''
const tsParts: string[] = []
if (startedAt !== null) {
tsParts.push(`started ${formatTimeAgo(startedAt, now)}`)
}
if (doneAt !== null) {
tsParts.push(`done ${formatTimeAgo(doneAt, now)}`)
}
return (
<div
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={handleActivateKeyDown}
className={cn(
'rounded px-1.5 py-1 bg-background/30',
'group flex items-center gap-1.5 px-1.5 py-0.5',
'cursor-pointer rounded-sm hover:bg-accent/30',
'focus-visible:outline-none focus-visible:bg-accent/40',
agent.source === 'heuristic' && 'opacity-70'
)}
title={tsParts.length > 0 ? tsParts.join(' • ') : undefined}
>
{/* Top line: agent label + current state */}
<div className="flex items-center gap-1.5">
{/* Status dot */}
<span className={cn('size-[6px] shrink-0 rounded-full', currentDotClasses(agent.state))} />
{/* Agent type */}
<span className="text-[10px] font-medium text-foreground/80">{agentLabel}</span>
{/* State label */}
<span className={cn('text-[10px] font-medium', stateLabelColor(agent.state))}>
{stateLabel(agent.state)}
<AgentStatusBadge agentType={agent.agentType} state={asBadgeState(agent.state)} />
{prompt && (
<span
className="min-w-0 flex-1 truncate text-[10px] leading-tight text-foreground/80"
title={prompt}
>
{prompt}
</span>
</div>
)}
{/* Why: the timestamp and dismiss-X share a single slot so the row width
never changes on hover AND the X's hitbox never extends into the rest
of the row (which should stay clickable to navigate to the worktree).
The timestamp holds the slot in normal flow; the X overlays it on
hover with identical dimensions. */}
{(startedAt !== null || doneAt !== null || agent.state === 'done') && (
<span className="relative ml-auto flex shrink-0 items-center">
{(startedAt !== null || doneAt !== null) && (
<span
className={cn(
'text-[9px] leading-none text-muted-foreground/60',
agent.state === 'done' && 'group-hover:invisible'
)}
>
{doneAt !== null
? formatTimeAgo(doneAt, now)
: startedAt !== null
? formatTimeAgo(startedAt, now)
: null}
</span>
)}
{agent.state === 'done' && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleDismiss}
className={cn(
'absolute inset-0 inline-flex items-center justify-center',
'text-muted-foreground/70 opacity-0 transition-opacity',
'hover:text-foreground',
'group-hover:opacity-100'
)}
aria-label="Dismiss done agent"
>
<X className="size-2.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Dismiss
</TooltipContent>
</Tooltip>
)}
</span>
)}
</div>
)
})

View file

@ -3,9 +3,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { DashboardFilter } from './useDashboardFilter'
const FILTERS: { value: DashboardFilter; label: string }[] = [
{ value: 'active', label: 'Active' },
{ value: 'all', label: 'All' },
{ value: 'working', label: 'Working' },
{ value: 'active', label: 'Active' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' }
]

View file

@ -11,6 +11,8 @@ type Props = {
focusedWorktreeId: string | null
onFocusWorktree: (worktreeId: string) => void
onCheckWorktree: (worktreeId: string) => void
onDismissAgent: (paneKey: string) => void
onActivateAgentTab: (worktreeId: string, tabId: string) => void
}
const DashboardRepoGroup = React.memo(function DashboardRepoGroup({
@ -19,13 +21,15 @@ const DashboardRepoGroup = React.memo(function DashboardRepoGroup({
onToggleCollapse,
focusedWorktreeId,
onFocusWorktree,
onCheckWorktree
onCheckWorktree,
onDismissAgent,
onActivateAgentTab
}: Props) {
const totalAgents = group.worktrees.reduce((sum, wt) => sum + wt.agents.length, 0)
const Icon = isCollapsed ? ChevronRight : ChevronDown
return (
<div className="rounded-lg bg-accent/20 border border-border/30 overflow-hidden">
<div className="rounded-lg bg-accent/20 border border-border overflow-hidden">
{/* Repo header */}
<button
type="button"
@ -55,7 +59,7 @@ const DashboardRepoGroup = React.memo(function DashboardRepoGroup({
{/* Worktree rows */}
{!isCollapsed && group.worktrees.length > 0 && (
<div className="border-t border-border/20">
<div className="border-t border-border">
{group.worktrees.map((card, i) => (
<DashboardWorktreeCard
key={card.worktree.id}
@ -63,6 +67,8 @@ const DashboardRepoGroup = React.memo(function DashboardRepoGroup({
isFocused={focusedWorktreeId === card.worktree.id}
onFocus={() => onFocusWorktree(card.worktree.id)}
onCheck={() => onCheckWorktree(card.worktree.id)}
onDismissAgent={onDismissAgent}
onActivateAgentTab={onActivateAgentTab}
isLast={i === group.worktrees.length - 1}
/>
))}
@ -70,7 +76,7 @@ const DashboardRepoGroup = React.memo(function DashboardRepoGroup({
)}
{!isCollapsed && group.worktrees.length === 0 && (
<div className="border-t border-border/20 px-2.5 py-2 text-[10px] text-muted-foreground/50 italic">
<div className="border-t border-border px-2.5 py-2 text-[10px] text-muted-foreground/50 italic">
(0 worktrees)
</div>
)}

View file

@ -4,16 +4,20 @@ import { useAppStore } from '@/store'
import DashboardAgentRow from './DashboardAgentRow'
import type { DashboardWorktreeCard as DashboardWorktreeCardData } from './useDashboardData'
function dominantStateBadge(state: string): { label: string; className: string } {
// Why: the worktree badge collapses to a single status dot — avoids the visual
// triple-up of Done/Done/Done (agent row + badge + agent column). Tooltip
// preserves accessibility. Blocked/waiting roll up into "Working" since the
// agent is mid-turn; idle worktrees show no dot at all.
function dominantStateDot(state: string): { label: string; className: string } | null {
switch (state) {
case 'working':
return { label: 'Active', className: 'bg-emerald-500/15 text-emerald-500' }
case 'blocked':
return { label: 'Blocked', className: 'bg-amber-500/15 text-amber-500' }
case 'waiting':
return { label: 'Working', className: 'bg-emerald-500' }
case 'done':
return { label: 'Done', className: 'bg-sky-500/15 text-sky-500/80' }
return { label: 'Done', className: 'bg-sky-500/70' }
default:
return { label: 'Idle', className: 'bg-zinc-500/10 text-zinc-500' }
return null
}
}
@ -22,6 +26,9 @@ type Props = {
isFocused: boolean
onFocus: () => void
onCheck: () => void
onDismissAgent: (paneKey: string) => void
/** Navigate to a specific tab inside this card's worktree. */
onActivateAgentTab: (worktreeId: string, tabId: string) => void
isLast: boolean
}
@ -30,6 +37,8 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
isFocused,
onFocus,
onCheck,
onDismissAgent,
onActivateAgentTab,
isLast
}: Props) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
@ -44,6 +53,14 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
onCheck()
}, [card.worktree.id, setActiveWorktree, setActiveView, onCheck])
const handleActivateAgent = useCallback(
(tabId: string) => {
onActivateAgentTab(card.worktree.id, tabId)
onCheck()
},
[card.worktree.id, onActivateAgentTab, onCheck]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
@ -55,7 +72,7 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
)
const branchName = card.worktree.branch?.replace(/^refs\/heads\//, '') ?? ''
const badge = dominantStateBadge(card.dominantState)
const dot = dominantStateDot(card.dominantState)
return (
<div
@ -70,7 +87,7 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
'hover:bg-accent/20',
'focus-visible:outline-none focus-visible:bg-accent/30',
isFocused && 'bg-accent/25',
!isLast && 'border-b border-border/15'
!isLast && 'border-b border-border'
)}
>
{/* Worktree header row */}
@ -78,14 +95,13 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
<span className="text-[11px] font-semibold text-foreground truncate leading-tight">
{card.worktree.displayName}
</span>
<span
className={cn(
'ml-auto shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium',
badge.className
)}
>
{badge.label}
</span>
{dot && (
<span
className={cn('ml-auto size-2 shrink-0 rounded-full', dot.className)}
title={dot.label}
aria-label={dot.label}
/>
)}
</div>
{/* Branch name */}
@ -95,9 +111,15 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
{/* Agent rows with activity blocks */}
{card.agents.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1">
{card.agents.map((agent) => (
<DashboardAgentRow key={agent.paneKey} agent={agent} />
<div className="mt-1.5 flex flex-col divide-y divide-border/60">
{card.agents.map((agent, index) => (
<div key={agent.paneKey} className={cn(index === 0 ? 'pb-1' : 'py-1')}>
<DashboardAgentRow
agent={agent}
onDismiss={onDismissAgent}
onActivate={handleActivateAgent}
/>
</div>
))}
</div>
)}

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { detectAgentStatusFromTitle, inferAgentTypeFromTitle } from '@/lib/agent-status'
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { Repo, Worktree, TerminalTab } from '../../../../shared/types'
@ -90,7 +90,7 @@ function buildAgentRowsForWorktree(
paneKey: entry.paneKey,
entry,
tab,
agentType: entry.agentType ?? inferAgentTypeFromTitle(entry.terminalTitle ?? tab.title),
agentType: entry.agentType ?? 'unknown',
state: entry.state,
source: 'agent',
// Why: the oldest stateHistory entry's startedAt is the agent's original
@ -107,7 +107,12 @@ function buildAgentRowsForWorktree(
paneKey: `heuristic:${tab.id}`,
entry: null,
tab,
agentType: inferAgentTypeFromTitle(tab.title),
// Why: heuristic rows are the title-based fallback when the hook
// hasn't reported yet. We don't want to guess the agent family
// from the title — titles are noisy and mismatches (e.g. Codex
// spinner → Claude) show the wrong icon. The hook will provide
// the real agentType as soon as it fires.
agentType: 'unknown',
// Map heuristic 'permission' to 'blocked' for dashboard consistency
state: heuristicStatus === 'permission' ? 'blocked' : heuristicStatus,
source: 'heuristic',

View file

@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'
import type { DashboardRepoGroup, DashboardWorktreeCard } from './useDashboardData'
export type DashboardFilter = 'active' | 'all' | 'working' | 'blocked' | 'done'
export type DashboardFilter = 'active' | 'all' | 'blocked' | 'done'
// Why: every filter requires agents — the dashboard is an agent activity view,
// not a worktree list. Worktrees with zero agents are never shown regardless
@ -33,8 +33,6 @@ function matchesFilter(
return true
}
return false
case 'working':
return card.dominantState === 'working'
case 'blocked':
return card.dominantState === 'blocked'
case 'done':
@ -56,7 +54,7 @@ export function useDashboardFilter(
filteredGroups: DashboardRepoGroup[]
hasResults: boolean
} {
const [filter, setFilter] = useState<DashboardFilter>('active')
const [filter, setFilter] = useState<DashboardFilter>('all')
const filteredGroups = useMemo(() => {
const filtered = groups

View file

@ -1,4 +1,5 @@
import { useEffect, useCallback } from 'react'
import type React from 'react'
import { useAppStore } from '@/store'
import type { DashboardRepoGroup } from './useDashboardData'
import type { DashboardFilter } from './useDashboardFilter'
@ -10,14 +11,19 @@ type UseDashboardKeyboardParams = {
setFocusedWorktreeId: (id: string | null) => void
filter: DashboardFilter
setFilter: (f: DashboardFilter) => void
// Why: the listener must be scoped to the dashboard container so keystrokes
// (Arrow keys, digits 1-4, Enter, Escape) only fire when focus is inside the
// dashboard. Attaching to window intercepts terminal/xterm navigation (arrow
// keys for command history) and shell digit entry while the dashboard pane
// is merely open, which breaks those unrelated inputs.
containerRef: React.RefObject<HTMLElement | null>
}
const FILTER_KEYS: Record<string, DashboardFilter> = {
'1': 'active',
'2': 'all',
'3': 'working',
'4': 'blocked',
'5': 'done'
'1': 'all',
'2': 'active',
'3': 'blocked',
'4': 'done'
}
/** Collect all visible (non-collapsed) worktree IDs in display order. */
@ -43,17 +49,20 @@ export function useDashboardKeyboard({
focusedWorktreeId,
setFocusedWorktreeId,
filter,
setFilter
setFilter,
containerRef
}: UseDashboardKeyboardParams): void {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Only active when the dashboard pane is open and visible
if (!rightSidebarOpen || rightSidebarTab !== 'dashboard') {
// Why: the dashboard now docks at the sidebar bottom regardless of
// active tab, so gate only on whether the sidebar is visible. The
// listener is already scoped to the dashboard container's element,
// so focus-based scoping still isolates these shortcuts.
if (!rightSidebarOpen) {
return
}
@ -80,11 +89,11 @@ export function useDashboardKeyboard({
return
}
// Escape: reset filter to 'active' (the smart default)
// Escape: reset filter to 'all' (the default)
if (e.key === 'Escape') {
if (filter !== 'active') {
if (filter !== 'all') {
e.preventDefault()
setFilter('active')
setFilter('all')
}
return
}
@ -109,8 +118,13 @@ export function useDashboardKeyboard({
const nextId = ids[nextIndex]
setFocusedWorktreeId(nextId)
// Focus the corresponding DOM card
const cardEl = document.querySelector(`[data-worktree-id="${nextId}"]`) as HTMLElement
// Focus the corresponding DOM card. Why: scope the lookup to the
// dashboard container so we don't accidentally match a card rendered
// elsewhere in the app (and so the query fails closed when the
// container is unmounted).
const cardEl = containerRef.current?.querySelector(
`[data-worktree-id="${nextId}"]`
) as HTMLElement | null
cardEl?.focus()
return
}
@ -124,7 +138,6 @@ export function useDashboardKeyboard({
},
[
rightSidebarOpen,
rightSidebarTab,
filteredGroups,
collapsedRepos,
focusedWorktreeId,
@ -132,12 +145,21 @@ export function useDashboardKeyboard({
filter,
setFilter,
setActiveWorktree,
setActiveView
setActiveView,
containerRef
]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// Why: attach to the dashboard container rather than window so these
// shortcuts only fire when focus is inside the dashboard. This prevents
// Arrow keys and digits 1-4 from hijacking the terminal (xterm history
// navigation) and shell input while the dashboard pane is open.
const el = containerRef.current
if (!el) {
return
}
el.addEventListener('keydown', handleKeyDown)
return () => el.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown, containerRef])
}

View file

@ -0,0 +1,237 @@
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
import type {
DashboardRepoGroup,
DashboardAgentRow,
DashboardWorktreeCard
} from './useDashboardData'
// Why: when an agent finishes or its terminal closes, the store cleans up the
// status entry and the agent vanishes from useDashboardData. This hook captures
// those disappearances and retains the agents as "done" so the user can see
// what finished. The retained entry persists until the user clicks the worktree
// (which navigates to the terminal and dismisses the retained agents).
type RetainedAgent = DashboardAgentRow & {
worktreeId: string
}
export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): {
enrichedGroups: DashboardRepoGroup[]
dismissWorktreeAgents: (worktreeId: string) => void
dismissAgent: (paneKey: string) => void
} {
const [retained, setRetained] = useState<Map<string, RetainedAgent>>(new Map())
const prevAgentsRef = useRef<Map<string, { row: DashboardAgentRow; worktreeId: string }>>(
new Map()
)
// Why: retainedRef avoids including `retained` in the effect's dependency
// array, which would cause an infinite loop (effect updates retained →
// retained changes → effect runs again).
const retainedRef = useRef(retained)
retainedRef.current = retained
useEffect(() => {
// Build the current set of live agents from the fresh store data
const current = new Map<string, { row: DashboardAgentRow; worktreeId: string }>()
const existingWorktreeIds = new Set<string>()
for (const group of liveGroups) {
for (const wt of group.worktrees) {
existingWorktreeIds.add(wt.worktree.id)
for (const agent of wt.agents) {
current.set(agent.paneKey, { row: agent, worktreeId: wt.worktree.id })
}
}
}
// Build a set of tab IDs that have live explicit (non-heuristic) agents.
// Why: heuristic paneKeys are "heuristic:{tabId}"; explicit ones are
// "{tabId}:{paneId}". When an agent starts reporting explicit status, the
// heuristic row vanishes — but that's a promotion, not a completion. We
// must not retain the heuristic entry as "done" when its tab still has a
// live explicit agent.
const tabsWithExplicitAgent = new Set<string>()
for (const paneKey of current.keys()) {
if (!paneKey.startsWith('heuristic:')) {
tabsWithExplicitAgent.add(paneKey.split(':')[0])
}
}
// Detect agents that were present last render but are gone now
const disappeared: RetainedAgent[] = []
for (const [paneKey, prev] of prevAgentsRef.current) {
if (
!current.has(paneKey) &&
!retainedRef.current.has(paneKey) &&
// Why: don't retain heuristic "idle" agents — they weren't doing
// meaningful work, so showing them as "done" would be misleading.
prev.row.state !== 'idle'
) {
// Why: if a heuristic agent vanished because an explicit agent for the
// same tab took over, that's not a completion — skip retention.
if (paneKey.startsWith('heuristic:')) {
const tabId = paneKey.slice('heuristic:'.length)
if (tabsWithExplicitAgent.has(tabId)) {
continue
}
}
disappeared.push({ ...prev.row, worktreeId: prev.worktreeId })
}
}
prevAgentsRef.current = current
// Batch: add newly disappeared agents + prune stale retained entries.
setRetained((prev) => {
const next = new Map(prev)
let changed = false
for (const ra of disappeared) {
next.set(ra.paneKey, ra)
changed = true
}
for (const [key, ra] of next) {
// Prune retained agents whose worktree was deleted
if (!existingWorktreeIds.has(ra.worktreeId)) {
next.delete(key)
changed = true
continue
}
// Why: if a retained heuristic agent's tab now has an explicit agent,
// the heuristic was superseded — evict it. This handles the case where
// the heuristic was retained before the explicit status arrived.
if (key.startsWith('heuristic:')) {
const tabId = key.slice('heuristic:'.length)
if (tabsWithExplicitAgent.has(tabId)) {
next.delete(key)
changed = true
}
}
}
return changed ? next : prev
})
}, [liveGroups])
// Merge retained agents back into the group hierarchy so both views
// (list and concentric) see them as regular "done" agent rows.
const enrichedGroups = useMemo((): DashboardRepoGroup[] => {
if (retained.size === 0) {
return liveGroups
}
// Index retained agents by worktree for fast lookup
const byWorktree = new Map<string, RetainedAgent[]>()
for (const ra of retained.values()) {
const list = byWorktree.get(ra.worktreeId) ?? []
list.push(ra)
byWorktree.set(ra.worktreeId, list)
}
// Avoid duplicates: if a paneKey is both live and retained (transient
// overlap during the effect → re-render cycle), the live version wins.
const livePaneKeys = new Set<string>()
for (const group of liveGroups) {
for (const wt of group.worktrees) {
for (const agent of wt.agents) {
livePaneKeys.add(agent.paneKey)
}
}
}
return liveGroups.map((group) => {
const worktrees = group.worktrees.map((wt) => {
const retainedForWt = byWorktree
.get(wt.worktree.id)
?.filter((ra) => !livePaneKeys.has(ra.paneKey))
if (!retainedForWt?.length) {
return wt
}
const retainedRows: DashboardAgentRow[] = retainedForWt.map((ra) => ({
...ra,
state: 'done'
}))
// Why: re-sort after merging retained rows so the newest-started agent
// (live or retained) stays at the top — matches useDashboardData's sort.
const mergedAgents = [...wt.agents, ...retainedRows].sort(
(a, b) => b.startedAt - a.startedAt
)
return {
...wt,
agents: mergedAgents,
dominantState: computeDominant(mergedAgents),
latestStartedAt: Math.max(wt.latestStartedAt, ...retainedForWt.map((ra) => ra.startedAt))
} satisfies DashboardWorktreeCard
})
const attentionCount = worktrees.reduce(
(count, wt) =>
count + wt.agents.filter((a) => a.state === 'blocked' || a.state === 'waiting').length,
0
)
return { ...group, worktrees, attentionCount } satisfies DashboardRepoGroup
})
}, [liveGroups, retained])
// Why: when the user clicks the row's X, we also want to evict the matching
// retained entry (if any) so it doesn't re-appear on next render because the
// live store entry was removed a beat earlier by the caller.
const dismissAgent = useCallback((paneKey: string) => {
setRetained((prev) => {
if (!prev.has(paneKey)) {
return prev
}
const next = new Map(prev)
next.delete(paneKey)
return next
})
// Why: also forget this pane on the prev-render ref so the disappearance
// logic doesn't immediately re-retain it on the next render.
prevAgentsRef.current.delete(paneKey)
}, [])
const dismissWorktreeAgents = useCallback((worktreeId: string) => {
setRetained((prev) => {
const next = new Map(prev)
let changed = false
for (const [key, ra] of next) {
if (ra.worktreeId === worktreeId) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
}, [])
return { enrichedGroups, dismissWorktreeAgents, dismissAgent }
}
function computeDominant(agents: DashboardAgentRow[]): DashboardWorktreeCard['dominantState'] {
if (agents.length === 0) {
return 'idle'
}
let hasWorking = false
let hasDone = false
for (const agent of agents) {
if (agent.state === 'blocked' || agent.state === 'waiting') {
return 'blocked'
}
if (agent.state === 'working') {
hasWorking = true
}
if (agent.state === 'done') {
hasDone = true
}
}
if (hasWorking) {
return 'working'
}
if (hasDone) {
return 'done'
}
return 'idle'
}

View file

@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import AgentDashboard from '../dashboard/AgentDashboard'
const MIN_HEIGHT = 140
const DEFAULT_HEIGHT = 300
const HEADER_HEIGHT = 28
const STORAGE_KEY = 'orca.dashboardSidebarPanel'
type PersistedState = {
height: number
collapsed: boolean
}
function loadPersistedState(): PersistedState {
if (typeof window === 'undefined') {
return { height: DEFAULT_HEIGHT, collapsed: false }
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return { height: DEFAULT_HEIGHT, collapsed: false }
}
const parsed = JSON.parse(raw) as Partial<PersistedState>
return {
height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_HEIGHT,
collapsed: typeof parsed.collapsed === 'boolean' ? parsed.collapsed : false
}
} catch {
return { height: DEFAULT_HEIGHT, collapsed: false }
}
}
// Why: a persistent bottom section of the right sidebar that always shows the
// agent dashboard, independent of which activity tab the user has open. The
// user drags the top edge to resize upward and can fully collapse to a
// single header row.
export default function DashboardBottomPanel(): React.JSX.Element {
const initial = useMemo(loadPersistedState, [])
const [height, setHeight] = useState<number>(initial.height)
const [collapsed, setCollapsed] = useState<boolean>(initial.collapsed)
const containerRef = useRef<HTMLDivElement>(null)
const resizeStateRef = useRef<{
startY: number
startHeight: number
maxHeight: number
} | null>(null)
// Why: persist height + collapsed via localStorage (renderer-only) so the
// layout survives reloads. Debounce writes so continuous drag doesn't spam.
useEffect(() => {
const timer = window.setTimeout(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ height, collapsed }))
} catch {
// ignore quota / privacy-mode errors
}
}, 150)
return () => window.clearTimeout(timer)
}, [height, collapsed])
const onResizeMove = useCallback((event: MouseEvent) => {
const state = resizeStateRef.current
if (!state) {
return
}
const deltaY = state.startY - event.clientY
const next = Math.max(MIN_HEIGHT, Math.min(state.maxHeight, state.startHeight + deltaY))
setHeight(next)
}, [])
const onResizeEnd = useCallback(() => {
resizeStateRef.current = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onResizeMove)
window.removeEventListener('mouseup', onResizeEnd)
}, [onResizeMove])
const onResizeStart = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
if (collapsed) {
setCollapsed(false)
}
// Why: cap expansion so the dashboard can't push the active panel
// content to a zero-height strip. Leave 160px for the panel above.
const sidebarEl = containerRef.current?.parentElement
const sidebarHeight = sidebarEl?.getBoundingClientRect().height ?? 800
const maxHeight = Math.max(MIN_HEIGHT, sidebarHeight - 160)
resizeStateRef.current = {
startY: event.clientY,
startHeight: height,
maxHeight
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onResizeMove)
window.addEventListener('mouseup', onResizeEnd)
},
[collapsed, height, onResizeMove, onResizeEnd]
)
useEffect(() => {
return () => {
window.removeEventListener('mousemove', onResizeMove)
window.removeEventListener('mouseup', onResizeEnd)
}
}, [onResizeMove, onResizeEnd])
const effectiveHeight = collapsed ? HEADER_HEIGHT : height
return (
<div
ref={containerRef}
className="relative flex shrink-0 flex-col border-t border-border bg-sidebar"
style={{ height: effectiveHeight }}
>
{/* Resize handle — hidden while collapsed so the user must expand first. */}
{!collapsed && (
<div
className="absolute left-0 right-0 z-10 -mt-[3px] h-[6px] cursor-row-resize transition-colors hover:bg-ring/20 active:bg-ring/30"
onMouseDown={onResizeStart}
aria-label="Resize dashboard panel"
/>
)}
{/* Header: title + collapse toggle (click anywhere to toggle) */}
<div
className="flex shrink-0 cursor-pointer select-none items-center gap-1 px-2"
style={{ height: HEADER_HEIGHT }}
onClick={() => setCollapsed((prev) => !prev)}
>
<button
type="button"
className="flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={collapsed ? 'Expand dashboard' : 'Collapse dashboard'}
>
{collapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Agents
</span>
</div>
{/* Body: full AgentDashboard */}
{!collapsed && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<AgentDashboard />
</div>
)}
</div>
)
}

View file

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Files, Search, GitBranch, ListChecks, LayoutDashboard, PanelRight } from 'lucide-react'
import { Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { useSidebarResize } from '@/hooks/useSidebarResize'
@ -19,7 +19,7 @@ import FileExplorer from './FileExplorer'
import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
import AgentDashboard from '../dashboard/AgentDashboard'
import DashboardBottomPanel from './DashboardBottomPanel'
const MIN_WIDTH = 220
// Why: long file names (e.g. construction drawing sheets, multi-part document
@ -112,12 +112,6 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
title: 'Checks',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`,
gitOnly: true
},
{
id: 'dashboard',
icon: LayoutDashboard,
title: 'Agent Dashboard',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}D`
}
]
@ -170,11 +164,17 @@ function RightSidebarInner(): React.JSX.Element {
that froze the app for seconds on Windows. Each panel now reacts
to activeWorktreeId changes via store subscriptions and reset
effects, keeping the component instance alive across switches. */}
{effectiveTab === 'explorer' && <FileExplorer />}
{effectiveTab === 'search' && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
{effectiveTab === 'dashboard' && <AgentDashboard />}
{/* Why: the active tab content takes the top of the sidebar. The agent
dashboard docks at the bottom regardless of which tab is selected,
so users keep a glanceable view of agent status while they browse
files, search, etc. */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{effectiveTab === 'explorer' && <FileExplorer />}
{effectiveTab === 'search' && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
</div>
<DashboardBottomPanel />
</div>
)

View file

@ -53,21 +53,24 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
const [hookStatuses, setHookStatuses] = useState<{
claude: AgentHookInstallStatus | null
codex: AgentHookInstallStatus | null
gemini: AgentHookInstallStatus | null
loading: boolean
}>({
claude: null,
codex: null,
gemini: null,
loading: true
})
const refreshHookStatus = useCallback(async (): Promise<void> => {
setHookStatuses((prev) => ({ ...prev, loading: true }))
try {
const [claude, codex] = await Promise.all([
const [claude, codex, gemini] = await Promise.all([
window.api.agentHooks.claudeStatus(),
window.api.agentHooks.codexStatus()
window.api.agentHooks.codexStatus(),
window.api.agentHooks.geminiStatus()
])
setHookStatuses({ claude, codex, loading: false })
setHookStatuses({ claude, codex, gemini, loading: false })
} catch {
setHookStatuses((prev) => ({ ...prev, loading: false }))
}
@ -281,14 +284,15 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
{/* Why: hooks are auto-installed at app startup. Surfacing the
result as read-only status avoids a toggle whose "Remove" would
be silently reverted on next launch. */}
Orca installs Claude and Codex global hooks at startup so agent lifecycle updates
flow into the sidebar automatically.
Orca installs Claude, Codex, and Gemini global hooks at startup so agent lifecycle
updates flow into the sidebar automatically.
</p>
<div className="mt-2 space-y-2">
{(
[
['claude', hookStatuses.claude],
['codex', hookStatuses.codex]
['codex', hookStatuses.codex],
['gemini', hookStatuses.gemini]
] as const
).map(([agent, status]) => {
const installed = status?.managedHooksPresent === true

View file

@ -21,8 +21,7 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
return {
state: overrides.state ?? 'working',
summary: overrides.summary ?? '',
next: overrides.next ?? '',
prompt: overrides.prompt ?? '',
updatedAt: overrides.updatedAt ?? NOW - 30_000,
source: overrides.source ?? 'agent',
agentType: overrides.agentType ?? 'codex',
@ -37,11 +36,11 @@ describe('buildAgentStatusHoverRows', () => {
const rows = buildAgentStatusHoverRows(
[makeTab({ id: 'tab-1', title: 'codex working' })],
{
'tab-1:1': makeEntry({ paneKey: 'tab-1:1', summary: 'Fix login bug' }),
'tab-1:1': makeEntry({ paneKey: 'tab-1:1', prompt: 'Fix login bug' }),
'tab-1:2': makeEntry({
paneKey: 'tab-1:2',
state: 'blocked',
summary: 'Waiting on failing test'
prompt: 'Waiting on failing test'
})
},
NOW
@ -58,7 +57,7 @@ describe('buildAgentStatusHoverRows', () => {
expect(rows[0]?.kind).toBe('heuristic')
})
it('keeps stale explicit summaries but orders by heuristic urgency', () => {
it('keeps stale explicit prompts but orders by heuristic urgency', () => {
const rows = buildAgentStatusHoverRows(
[
makeTab({ id: 'tab-a', title: 'codex permission needed' }),

View file

@ -3,16 +3,12 @@ import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/h
import { useAppStore } from '@/store'
import {
detectAgentStatusFromTitle,
formatAgentTypeLabel,
inferAgentTypeFromTitle,
getAgentLabel,
isExplicitAgentStatusFresh
} from '@/lib/agent-status'
import { cn } from '@/lib/utils'
import type {
AgentStatusEntry,
AgentStatusState,
AgentType
} from '../../../../shared/agent-status-types'
import { AgentStatusBadge, type AgentStatusBadgeState } from '@/components/AgentStatusBadge'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import { AGENT_STATUS_STALE_AFTER_MS as STALE_THRESHOLD_MS } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
@ -41,31 +37,6 @@ type HoverRow =
sortTimestamp: number
}
function stateLabel(state: AgentStatusState): string {
switch (state) {
case 'working':
return 'Working'
case 'blocked':
return 'Blocked'
case 'waiting':
return 'Waiting for input'
case 'done':
return 'Done'
}
}
function stateColor(state: AgentStatusState): string {
switch (state) {
case 'working':
return 'text-emerald-500'
case 'blocked':
case 'waiting':
return 'text-red-500'
case 'done':
return 'text-emerald-500'
}
}
function sortKeyForExplicit(
explicit: AgentStatusEntry,
heuristicState: 'working' | 'permission' | 'idle' | null,
@ -128,14 +99,22 @@ export function buildAgentStatusHoverRows(
explicit,
heuristicState,
tabTitle,
agentType:
explicit.agentType ?? inferAgentTypeFromTitle(explicit.terminalTitle ?? tab.title),
agentType: explicit.agentType ?? 'unknown',
sortTimestamp: explicit.updatedAt
})
}
continue
}
// Why: a live PTY tab is not necessarily running an agent — the user may
// have opened a plain shell ("Terminal 1"). Only surface a heuristic row
// when the title actually looks like an agent (detectable status or a
// recognizable agent name); otherwise the hover falsely reports shells as
// "Idle Agent / No task details reported" and inflates "Running agents (N)".
if (heuristicState === null && getAgentLabel(tab.title) === null) {
continue
}
rows.push({
kind: 'heuristic',
key: `heuristic:${tab.id}`,
@ -143,7 +122,10 @@ export function buildAgentStatusHoverRows(
paneKey: null,
heuristicState,
tabTitle,
agentType: inferAgentTypeFromTitle(tab.title),
// Why: we no longer guess agent family from titles — the explicit
// agentType from the hook is the source of truth. Heuristic rows
// render with a neutral icon until the hook reports.
agentType: 'unknown',
sortTimestamp: tab.createdAt
})
}
@ -166,24 +148,19 @@ export function buildAgentStatusHoverRows(
return rows
}
function StateDot({ state }: { state: AgentStatusState }): React.JSX.Element {
// Why: the heuristic rollup only distinguishes 'working' / 'permission' /
// 'idle'. Map it into the shared badge state vocabulary so both the hover and
// dashboard render identical badges for "agent needs attention".
function heuristicToBadgeState(
state: 'working' | 'permission' | 'idle' | null
): AgentStatusBadgeState {
if (state === 'working') {
return (
<span className="inline-flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<span className="block size-1.5 animate-spin rounded-full border-[1.5px] border-emerald-500 border-t-transparent" />
</span>
)
return 'working'
}
return (
<span className="inline-flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<span
className={cn(
'block size-1.5 rounded-full',
state === 'done' ? 'bg-emerald-500' : 'bg-red-500'
)}
/>
</span>
)
if (state === 'permission') {
return 'permission'
}
return 'idle'
}
function formatTimeAgo(updatedAt: number, now: number): string {
@ -203,64 +180,25 @@ function AgentRow({ row, now }: { row: HoverRow; now: number }): React.JSX.Eleme
if (row.kind === 'explicit') {
const isFresh = isExplicitAgentStatusFresh(row.explicit, now, STALE_THRESHOLD_MS)
const shouldUseHeuristic = !isFresh && row.heuristicState !== null
const badgeState: AgentStatusBadgeState = shouldUseHeuristic
? heuristicToBadgeState(row.heuristicState)
: row.explicit.state
return (
<div className="flex flex-col gap-1 border-b border-border/30 py-1.5 last:border-0">
<div className="flex items-center gap-1.5">
{shouldUseHeuristic ? (
<span className="inline-flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<span
className={cn(
'block size-1.5 rounded-full',
row.heuristicState === 'working'
? 'bg-emerald-500'
: row.heuristicState === 'permission'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
/>
</span>
) : (
<StateDot state={row.explicit.state} />
)}
<span
className={cn(
'text-[11px] font-medium',
shouldUseHeuristic ? 'text-muted-foreground' : stateColor(row.explicit.state)
)}
>
{shouldUseHeuristic
? row.heuristicState === 'permission'
? 'Needs attention'
: row.heuristicState === 'working'
? 'Working'
: 'Idle'
: stateLabel(row.explicit.state)}
</span>
<span className="truncate text-[10px] text-muted-foreground/70">
{formatAgentTypeLabel(row.agentType)}
</span>
<AgentStatusBadge agentType={row.agentType} state={badgeState} />
<span className="ml-auto text-[10px] text-muted-foreground/50">
{formatTimeAgo(row.explicit.updatedAt, now)}
</span>
</div>
{row.explicit.summary && (
<div className={cn('pl-4 text-[11px] leading-snug', !isFresh && 'opacity-60')}>
{row.explicit.summary}
</div>
)}
{row.explicit.next && (
<div
className={cn(
'pl-4 text-[10.5px] leading-snug text-muted-foreground',
!isFresh && 'opacity-60'
)}
>
Next: {row.explicit.next}
{row.explicit.prompt && (
<div className={cn('pl-5 text-[11px] leading-snug', !isFresh && 'opacity-60')}>
{row.explicit.prompt}
</div>
)}
{!isFresh && (
<div className="pl-4 text-[10px] italic text-muted-foreground/60">
<div className="pl-5 text-[10px] italic text-muted-foreground/60">
Showing last reported task details; live terminal state has taken precedence.
</div>
)}
@ -268,35 +206,16 @@ function AgentRow({ row, now }: { row: HoverRow; now: number }): React.JSX.Eleme
)
}
const heuristicLabel =
row.heuristicState === 'working'
? 'Working'
: row.heuristicState === 'permission'
? 'Needs attention'
: 'Idle'
return (
<div className="flex flex-col gap-1 border-b border-border/30 py-1.5 last:border-0">
<div className="flex items-center gap-1.5">
<span className="inline-flex h-2.5 w-2.5 shrink-0 items-center justify-center">
<span
className={cn(
'block size-1.5 rounded-full',
row.heuristicState === 'working'
? 'bg-emerald-500'
: row.heuristicState === 'permission'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
/>
</span>
<span className="text-[11px] font-medium text-muted-foreground">{heuristicLabel}</span>
<span className="truncate text-[10px] text-muted-foreground/70">
{formatAgentTypeLabel(row.agentType)}
</span>
<AgentStatusBadge
agentType={row.agentType}
state={heuristicToBadgeState(row.heuristicState)}
/>
</div>
<div className="truncate pl-4 text-[10.5px] text-muted-foreground/60">{row.tabTitle}</div>
<div className="pl-4 text-[10px] italic text-muted-foreground/40">
<div className="truncate pl-5 text-[10.5px] text-muted-foreground/60">{row.tabTitle}</div>
<div className="pl-5 text-[10px] italic text-muted-foreground/40">
No task details reported
</div>
</div>

View file

@ -58,8 +58,7 @@ function makeAgentStatusEntry(
): AgentStatusEntry {
return {
state: overrides.state ?? 'working',
summary: overrides.summary ?? '',
next: overrides.next ?? '',
prompt: overrides.prompt ?? '',
updatedAt: overrides.updatedAt ?? NOW - 30_000,
source: overrides.source ?? 'agent',
agentType: overrides.agentType ?? 'codex',

View file

@ -161,9 +161,13 @@ export function connectPanePty(
// Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their
// callbacks to the correct Orca pane without resolving worktrees from cwd.
// The key matches the `${tabId}:${paneId}` composite used for cacheTimerByKey.
// ORCA_TAB_ID / ORCA_WORKTREE_ID are exposed separately so the receiver has
// routing context without having to split paneKey back into its parts.
const paneEnv = {
...paneStartup?.env,
ORCA_PANE_KEY: cacheKey
ORCA_PANE_KEY: cacheKey,
ORCA_TAB_ID: deps.tabId,
ORCA_WORKTREE_ID: deps.worktreeId
}
// Why: remote repos route PTY spawn through the SSH provider. Resolve the
@ -186,7 +190,14 @@ export function connectPanePty(
onBell,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited
onAgentExited,
// Why: forward OSC 9999 payloads from the PTY stream to the agent-status slice.
// Without this, the OSC parser in pty-transport strips sequences from xterm
// output but the status never reaches the store or dashboard/hover UI.
onAgentStatus: (payload) => {
const title = useAppStore.getState().runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
useAppStore.getState().setAgentStatus(cacheKey, payload, title)
}
})
const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0
deps.paneTransportsRef.current.set(pane.id, transport)

View file

@ -41,7 +41,7 @@ export { extractLastOscTitle } from '../../../../shared/agent-detection'
// ─── OSC 9999: agent status reporting ──────────────────────────────────────
// Why OSC 9999: avoids known-used codes (7=cwd, 133=VS Code, 777=Superset,
// 1337=iTerm2, 9001=Warp). Agents report structured status by printing
// printf '\x1b]9999;{"state":"working","summary":"...","next":"..."}\x07'
// printf '\x1b]9999;{"state":"working","prompt":"..."}\x07'
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
const OSC_AGENT_STATUS_RE = /\x1b\]9999;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;'
@ -104,6 +104,9 @@ function findAgentStatusTerminator(
* escape sequence mid-payload and can leak raw control bytes into xterm.
*/
export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk {
// Why: cap the pending buffer so a malformed or binary stream containing our
// OSC 9999 prefix without a valid terminator cannot grow memory unbounded.
const MAX_PENDING = 64 * 1024
let pending = ''
return (data: string): ProcessedAgentStatusChunk => {
@ -126,7 +129,15 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen
const terminator = findAgentStatusTerminator(combined, payloadStart)
if (terminator === null) {
pending = combined.slice(start)
const candidate = combined.slice(start)
if (candidate.length > MAX_PENDING) {
// Why: drop the partial and treat what we held as plain output so a
// stream that never terminates the escape cannot leak memory.
cleanData += candidate
pending = ''
} else {
pending = candidate
}
break
}

View file

@ -404,16 +404,22 @@ export function useIpcEvents(): void {
// hook callback or an OSC fallback path.
unsubs.push(
window.api.agentStatus.onSet((data) => {
console.log('[agentStatus:set] Renderer received IPC:', data)
console.log('[agent-hooks:renderer] received IPC', {
paneKey: data.paneKey,
state: data.state,
promptLen: data.prompt?.length ?? 0,
promptPreview: data.prompt?.slice(0, 80) ?? null,
agentType: data.agentType
})
const payload = parseAgentStatusPayload(
JSON.stringify({
state: data.state,
summary: data.summary,
next: data.next,
prompt: data.prompt,
agentType: data.agentType
})
)
if (!payload) {
console.log('[agent-hooks:renderer] drop (parse failed)', data)
return
}
const store = useAppStore.getState()
@ -421,11 +427,29 @@ export function useIpcEvents(): void {
// payloads do not include the current title, but the store may already
// know it from OSC title tracking.
const currentTitle = findTerminalTitleForPaneKey(store, data.paneKey)
console.log('[agentStatus:set] Storing:', {
// Why: a paneKey that no longer resolves to a live tab belongs to a pane
// that has already been torn down. Dropping here prevents orphan entries
// from accumulating in agentStatusByPaneKey.
const [tabId] = data.paneKey.split(':')
if (!tabId) {
console.log('[agent-hooks:renderer] drop (no tabId)', data)
return
}
const tabExists = Object.values(store.tabsByWorktree).some((tabs) =>
tabs.some((t) => t.id === tabId)
)
if (!tabExists) {
console.log('[agent-hooks:renderer] drop (tab not found)', {
paneKey: data.paneKey,
tabId
})
return
}
console.log('[agent-hooks:renderer] setAgentStatus', {
paneKey: data.paneKey,
state: payload.state,
summary: payload.summary,
currentTitle
promptLen: payload.prompt.length,
promptPreview: payload.prompt.slice(0, 80)
})
store.setAgentStatus(data.paneKey, payload, currentTitle)
})
@ -441,7 +465,18 @@ function findTerminalTitleForPaneKey(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string
): string | undefined {
const tabId = paneKey.split(':')[0]
const [tabId, paneIdRaw] = paneKey.split(':')
if (!tabId) {
return undefined
}
// Why: split panes track per-pane titles in runtimePaneTitlesByTabId; prefer
// the pane's own title over the tab-level (last-winning) title so agent type
// inference attributes status to the correct pane.
const paneTitles = store.runtimePaneTitlesByTabId?.[tabId]
const paneIdNum = paneIdRaw !== undefined ? Number(paneIdRaw) : NaN
if (paneTitles && !Number.isNaN(paneIdNum) && paneTitles[paneIdNum]) {
return paneTitles[paneIdNum]
}
for (const tabs of Object.values(store.tabsByWorktree)) {
const tab = tabs.find((t) => t.id === tabId)
if (tab?.title) {

View file

@ -8,7 +8,6 @@ import {
clearWorkingIndicators,
createAgentStatusTracker,
getAgentLabel,
inferAgentTypeFromTitle,
isGeminiTerminalTitle,
normalizeTerminalTitle
} from './agent-status'
@ -248,21 +247,6 @@ describe('getAgentLabel', () => {
})
})
describe('inferAgentTypeFromTitle', () => {
it('detects known agent types from terminal titles', () => {
expect(inferAgentTypeFromTitle('✦ Gemini CLI')).toBe('gemini')
expect(inferAgentTypeFromTitle('⠂ Claude Code')).toBe('claude')
expect(inferAgentTypeFromTitle('codex - permission needed')).toBe('codex')
expect(inferAgentTypeFromTitle('opencode running tests')).toBe('opencode')
expect(inferAgentTypeFromTitle('aider done')).toBe('aider')
})
it('returns unknown for plain shell titles', () => {
expect(inferAgentTypeFromTitle('bash')).toBe('unknown')
expect(inferAgentTypeFromTitle('')).toBe('unknown')
})
})
describe('createAgentStatusTracker', () => {
// --- Claude Code: real captured OSC title sequence (v2.1.86) ---
// CRITICAL: Claude Code changes the title to the TASK DESCRIPTION,

View file

@ -1,4 +1,4 @@
import type { TerminalTab, Worktree } from '../../../shared/types'
import type { TerminalTab, TuiAgent, Worktree } from '../../../shared/types'
import type {
AgentStatusEntry,
AgentStatusState,
@ -22,8 +22,6 @@ export {
import {
type AgentStatus,
detectAgentStatusFromTitle,
isGeminiTerminalTitle,
isClaudeAgent,
getAgentLabel
} from '../../../shared/agent-detection'
@ -94,36 +92,6 @@ export function getWorkingAgentsPerWorktree({
return result
}
function includesAgentName(title: string, name: string): boolean {
return title.toLowerCase().includes(name)
}
// Why: inferAgentTypeFromTitle classifies a terminal title into a known agent
// family so the dashboard can display the correct agent icon/label without
// relying on the explicit hook-based status report (which may not be available
// for all agent types).
export function inferAgentTypeFromTitle(title: string | null | undefined): AgentType {
if (!title) {
return 'unknown'
}
if (isGeminiTerminalTitle(title)) {
return 'gemini'
}
if (isClaudeAgent(title)) {
return 'claude'
}
if (includesAgentName(title, 'codex')) {
return 'codex'
}
if (includesAgentName(title, 'opencode')) {
return 'opencode'
}
if (includesAgentName(title, 'aider')) {
return 'aider'
}
return 'unknown'
}
const WELL_KNOWN_LABELS: Record<string, string> = {
claude: 'Claude',
codex: 'Codex',
@ -140,6 +108,18 @@ export function formatAgentTypeLabel(agentType: AgentType | null | undefined): s
return WELL_KNOWN_LABELS[agentType] ?? agentType
}
// Why: AgentIcon expects a TuiAgent, but AgentType is a broader union that
// includes 'unknown' and arbitrary strings. Normalize here so both the
// dashboard row and the sidebar hover pass the same value through to the icon
// — 'claude' is chosen as the neutral fallback because AgentIcon renders a
// letter glyph for unknown catalog entries anyway.
export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent {
if (!agentType || agentType === 'unknown') {
return 'claude'
}
return agentType as TuiAgent
}
// Why: explicit agent status entries (from hook-based reports) can go stale if
// the agent process exits without sending a final update. This helper lets
// callers decide whether to trust the entry based on a configurable TTL.

View file

@ -19,9 +19,7 @@ describe('agent status freshness expiry', () => {
vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z'))
const store = createTestStore()
store
.getState()
.setAgentStatus('tab-1:1', { state: 'working', summary: 'Fix tests', next: '' }, 'codex')
store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'Fix tests' }, 'codex')
// setAgentStatus bumps epoch once synchronously
expect(store.getState().agentStatusEpoch).toBe(1)
@ -40,9 +38,7 @@ describe('agent status freshness expiry', () => {
vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z'))
const store = createTestStore()
store
.getState()
.setAgentStatus('tab-1:1', { state: 'working', summary: 'Fix tests', next: '' }, 'codex')
store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'Fix tests' }, 'codex')
// set bumps to 1, remove bumps to 2
store.getState().removeAgentStatus('tab-1:1')
expect(store.getState().agentStatusEpoch).toBe(2)

View file

@ -7,7 +7,6 @@ import {
type AgentStatusEntry,
type ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import { inferAgentTypeFromTitle } from '@/lib/agent-status'
export type AgentStatusSlice = {
/** Explicit agent status entries keyed by `${tabId}:${paneId}` composite.
@ -83,13 +82,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// Why: build up a rolling log of state transitions so the dashboard can
// render activity blocks showing what the agent has been doing. Only push
// when the state actually changes to avoid duplicate entries from summary-
// when the state actually changes to avoid duplicate entries from prompt-
// only updates within the same state.
let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? []
if (existing && existing.state !== payload.state) {
history = [
...history,
{ state: existing.state, summary: existing.summary, startedAt: existing.updatedAt }
{ state: existing.state, prompt: existing.prompt, startedAt: existing.updatedAt }
]
if (history.length > AGENT_STATE_HISTORY_MAX) {
history = history.slice(history.length - AGENT_STATE_HISTORY_MAX)
@ -98,15 +97,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const entry: AgentStatusEntry = {
state: payload.state,
summary: payload.summary,
next: payload.next,
prompt: payload.prompt,
updatedAt: Date.now(),
source: 'agent',
// Why: the design doc requires agentType in the hover, but the OSC
// payload may omit it. Fall back to title inference so older injected
// prompts still populate the hover without requiring a coordinated
// rollout across every agent integration.
agentType: payload.agentType ?? inferAgentTypeFromTitle(effectiveTitle),
agentType: payload.agentType,
paneKey,
terminalTitle: effectiveTitle,
stateHistory: history

View file

@ -109,7 +109,7 @@ export type OpenFile = {
mode: 'edit' | 'diff' | 'conflict-review'
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'dashboard'
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks'
export type ActivityBarPosition = 'top' | 'side'
export type MarkdownViewMode = 'source' | 'rich'

View file

@ -338,15 +338,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
delete nextCacheTimer[key]
}
}
// Why: agent status keys use the same `${tabId}:${paneId}` composite.
// Sweep all entries for the closing tab so the hover UI doesn't show
// status for terminals that no longer exist.
const nextAgentStatus = { ...s.agentStatusByPaneKey }
for (const key of Object.keys(nextAgentStatus)) {
if (key.startsWith(`${tabId}:`)) {
delete nextAgentStatus[key]
}
}
// Why: keep activeTabIdByWorktree in sync when a tab is closed in a
// background worktree. Without this, the remembered tab becomes stale
// and restoring it on worktree switch falls back to tabs[0].
@ -400,10 +391,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
cacheTimerByKey: nextCacheTimer,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingSnapshotByPtyId: nextSnapshots,
pendingColdRestoreByPtyId: nextColdRestores,
agentStatusByPaneKey: nextAgentStatus
pendingColdRestoreByPtyId: nextColdRestores
}
})
// Why: delegate agent-status cleanup to its own slice so the epoch and
// stale-freshness timer bookkeeping stay consistent with other agent-status
// mutations.
get().removeAgentStatusByTabPrefix(tabId)
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
const workspaceItem = tabs.find(
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId

View file

@ -1,4 +1,4 @@
export type AgentHookTarget = 'claude' | 'codex'
export type AgentHookTarget = 'claude' | 'codex' | 'gemini'
export type AgentHookInstallState = 'installed' | 'not_installed' | 'partial' | 'error'
@ -9,3 +9,9 @@ export type AgentHookInstallStatus = {
managedHooksPresent: boolean
detail: string | null
}
// Why: bumped whenever the managed script's request shape changes. The
// receiver logs a warning when it sees a request from a different version so a
// stale script installed by an older app build is diagnosable instead of
// silently producing partial payloads.
export const ORCA_HOOK_PROTOCOL_VERSION = '1'

View file

@ -4,12 +4,11 @@ import { parseAgentStatusPayload, AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-
describe('parseAgentStatusPayload', () => {
it('parses a valid working payload', () => {
const result = parseAgentStatusPayload(
'{"state":"working","summary":"Investigating test failures","next":"Fix the flaky assertion","agentType":"codex"}'
'{"state":"working","prompt":"Fix the flaky assertion","agentType":"codex"}'
)
expect(result).toEqual({
state: 'working',
summary: 'Investigating test failures',
next: 'Fix the flaky assertion',
prompt: 'Fix the flaky assertion',
agentType: 'codex'
})
})
@ -47,52 +46,46 @@ describe('parseAgentStatusPayload', () => {
expect(parseAgentStatusPayload('[]')).toBeNull()
})
it('normalizes multiline summary to single line', () => {
it('normalizes multiline prompt to single line', () => {
const result = parseAgentStatusPayload(
'{"state":"working","summary":"line one\\nline two\\nline three"}'
'{"state":"working","prompt":"line one\\nline two\\nline three"}'
)
expect(result!.summary).toBe('line one line two line three')
expect(result!.prompt).toBe('line one line two line three')
})
it('normalizes Windows-style line endings (\\r\\n) to single line', () => {
const result = parseAgentStatusPayload(
'{"state":"working","summary":"line one\\r\\nline two\\r\\nline three"}'
'{"state":"working","prompt":"line one\\r\\nline two\\r\\nline three"}'
)
expect(result!.summary).toBe('line one line two line three')
expect(result!.prompt).toBe('line one line two line three')
})
it('trims whitespace from fields', () => {
const result = parseAgentStatusPayload(
'{"state":"working","summary":" padded ","next":" also padded "}'
)
expect(result!.summary).toBe('padded')
expect(result!.next).toBe('also padded')
it('trims whitespace from the prompt field', () => {
const result = parseAgentStatusPayload('{"state":"working","prompt":" padded "}')
expect(result!.prompt).toBe('padded')
})
it('truncates fields beyond max length', () => {
it('truncates the prompt beyond max length', () => {
const longString = 'x'.repeat(300)
const result = parseAgentStatusPayload(`{"state":"working","summary":"${longString}"}`)
expect(result!.summary).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH)
const result = parseAgentStatusPayload(`{"state":"working","prompt":"${longString}"}`)
expect(result!.prompt).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH)
})
it('defaults missing summary and next to empty string', () => {
it('defaults missing prompt to empty string', () => {
const result = parseAgentStatusPayload('{"state":"done"}')
expect(result!.summary).toBe('')
expect(result!.next).toBe('')
expect(result!.prompt).toBe('')
})
it('handles non-string summary/next gracefully', () => {
const result = parseAgentStatusPayload('{"state":"working","summary":42,"next":true}')
expect(result!.summary).toBe('')
expect(result!.next).toBe('')
it('handles non-string prompt gracefully', () => {
const result = parseAgentStatusPayload('{"state":"working","prompt":42}')
expect(result!.prompt).toBe('')
})
it('accepts custom non-empty agentType values', () => {
const result = parseAgentStatusPayload('{"state":"working","agentType":"cursor"}')
expect(result).toEqual({
state: 'working',
summary: '',
next: '',
prompt: '',
agentType: 'cursor'
})
})

View file

@ -14,7 +14,7 @@ export type AgentType = WellKnownAgentType | (string & {})
/** A snapshot of a previous agent state, used to render activity blocks. */
export type AgentStateHistoryEntry = {
state: AgentStatusState
summary: string
prompt: string
/** When this state was first reported. */
startedAt: number
}
@ -24,10 +24,11 @@ export const AGENT_STATE_HISTORY_MAX = 20
export type AgentStatusEntry = {
state: AgentStatusState
/** Short description of what the agent is currently doing. */
summary: string
/** Short description of what the agent plans to do next. */
next: string
/** The user's most recent prompt, when the hook payload carried one.
* Cached across the turn subsequent tool-use events in the same turn do
* not include the prompt, so the renderer receives the last known value
* until a new prompt arrives or the pane resets. Empty when unknown. */
prompt: string
/** Timestamp (ms) of the last status update. */
updatedAt: number
/** Whether this entry was reported explicitly by the agent or inferred from heuristics. */
@ -48,28 +49,26 @@ export type AgentStatusEntry = {
export type AgentStatusPayload = {
state: AgentStatusState
summary?: string
next?: string
prompt?: string
agentType?: AgentType
}
/**
* The result of `parseAgentStatusPayload`: summary and next are always
* normalized to strings (empty string when the raw payload omits them),
* so consumers do not need nullish-coalescing on these fields.
* The result of `parseAgentStatusPayload`: prompt is always normalized to a
* string (empty string when the raw payload omits it), so consumers do not
* need nullish-coalescing on the field.
*/
export type ParsedAgentStatusPayload = {
state: AgentStatusState
summary: string
next: string
prompt: string
agentType?: AgentType
}
/** Maximum character length for summary and next fields. Truncated on parse. */
/** Maximum character length for the prompt field. Truncated on parse. */
export const AGENT_STATUS_MAX_FIELD_LENGTH = 200
/**
* Freshness threshold for explicit agent status. After this point the hover still
* shows the last reported summary, but heuristic state regains precedence for
* shows the last reported prompt, but heuristic state regains precedence for
* ordering and coarse status so stale "done" reports do not mask live prompts.
*/
export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
@ -112,8 +111,7 @@ export function parseAgentStatusPayload(json: string): ParsedAgentStatusPayload
}
return {
state: state as AgentStatusState,
summary: normalizeField(parsed.summary),
next: normalizeField(parsed.next),
prompt: normalizeField(parsed.prompt),
agentType:
typeof parsed.agentType === 'string' && parsed.agentType.trim().length > 0
? parsed.agentType.trim().slice(0, AGENT_TYPE_MAX_LENGTH)