mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
WIP: agent dashboard and status updates before rebase
This commit is contained in:
parent
7e0f1d882b
commit
4671f31f0a
39 changed files with 1670 additions and 493 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
191
src/main/gemini/hook-service.ts
Normal file
191
src/main/gemini/hook-service.ts
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
src/preload/index.d.ts
vendored
19
src/preload/index.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
112
src/renderer/src/components/AgentStatusBadge.tsx
Normal file
112
src/renderer/src/components/AgentStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
237
src/renderer/src/components/dashboard/useRetainedAgents.ts
Normal file
237
src/renderer/src/components/dashboard/useRetainedAgents.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue