WIP: movable concentric dashboard UI

Snapshot of the movable/concentric agent dashboard with draggable
worktree cards and standalone dashboard window. Preserved on this
branch before switching to a simple list view.
This commit is contained in:
brennanb2025 2026-04-19 11:07:44 -07:00
parent 9efc9ca293
commit 6416b2c3e3
43 changed files with 1875 additions and 671 deletions

View file

@ -12,6 +12,21 @@ type AgentHookEventPayload = {
payload: ParsedAgentStatusPayload
}
function extractPromptText(hookPayload: Record<string, unknown>): string {
// Why: Claude documents `prompt` on UserPromptSubmit, but different agents
// may use slightly different field names. Check a small allowlist so we can
// capture the user prompt when it is present without depending on one exact
// provider-specific key everywhere in the renderer.
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 = ''
@ -33,7 +48,17 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
})
}
function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | null {
function normalizeClaudeEvent(
eventName: unknown,
promptText: string
): ParsedAgentStatusPayload | null {
// Why: Claude's Stop event is the primary "turn complete" signal, but the
// SubagentStop and SessionEnd events also mark turn/session completion. If
// only Stop is treated as "done", a session whose last event is one of the
// other stop events lingers in whatever the previous state was — most often
// "working" from a trailing PostToolUse, which then decays to heuristic
// "idle" when the entry goes stale. Treating all terminal events as "done"
// matches the user's mental model: Claude is no longer actively working.
const state =
eventName === 'UserPromptSubmit' ||
eventName === 'PostToolUse' ||
@ -41,7 +66,7 @@ function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | nu
? 'working'
: eventName === 'PermissionRequest'
? 'waiting'
: eventName === 'Stop'
: eventName === 'Stop' || eventName === 'SubagentStop' || eventName === 'SessionEnd'
? 'done'
: null
@ -52,26 +77,32 @@ function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | nu
return parseAgentStatusPayload(
JSON.stringify({
state,
summary:
statusText:
state === 'waiting'
? 'Waiting for permission'
: state === 'done'
? 'Turn complete'
: 'Responding to prompt',
promptText,
agentType: 'claude'
})
)
}
function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | null {
function normalizeCodexEvent(
eventName: unknown,
promptText: string
): ParsedAgentStatusPayload | null {
// Why: Codex's PreToolUse fires on every tool invocation, not only on user
// approval prompts, so mapping it to "waiting" turned every running Codex
// agent into "Waiting for permission". Only map events that unambiguously
// indicate lifecycle transitions.
const state =
eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
? 'working'
: eventName === 'PreToolUse'
? 'waiting'
: eventName === 'Stop'
? 'done'
: null
: eventName === 'Stop'
? 'done'
: null
if (!state) {
return null
@ -80,12 +111,8 @@ function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | nul
return parseAgentStatusPayload(
JSON.stringify({
state,
summary:
state === 'waiting'
? 'Waiting for permission'
: state === 'done'
? 'Turn complete'
: 'Responding to prompt',
statusText: state === 'done' ? 'Turn complete' : 'Responding to prompt',
promptText,
agentType: 'codex'
})
)
@ -107,8 +134,11 @@ function normalizeHookPayload(
}
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
const payload =
source === 'claude' ? normalizeClaudeEvent(eventName) : normalizeCodexEvent(eventName)
source === 'claude'
? normalizeClaudeEvent(eventName, promptText)
: normalizeCodexEvent(eventName, promptText)
return payload ? { paneKey, payload } : null
}
@ -131,12 +161,14 @@ export class AgentHookServer {
this.token = randomUUID()
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== 'POST') {
console.log('[agent-hooks] reject non-POST', req.method, req.url)
res.writeHead(404)
res.end()
return
}
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
console.log('[agent-hooks] token mismatch on', req.url)
res.writeHead(403)
res.end()
return
@ -147,19 +179,28 @@ export class AgentHookServer {
const source =
req.url === '/hook/claude' ? 'claude' : req.url === '/hook/codex' ? 'codex' : null
if (!source) {
console.log('[agent-hooks] unknown path', req.url)
res.writeHead(404)
res.end()
return
}
const payload = normalizeHookPayload(source, body)
console.log('[agent-hooks] received', {
source,
eventName: (body as { payload?: { hook_event_name?: unknown } })?.payload
?.hook_event_name,
paneKey: (body as { paneKey?: unknown })?.paneKey,
normalized: payload?.payload.state ?? null
})
if (payload) {
this.onAgentStatus?.(payload)
}
res.writeHead(204)
res.end()
} catch {
} catch (error) {
console.log('[agent-hooks] error handling request', error)
// Why: agent hooks must fail open. The receiver returns success for
// malformed payloads so a newer or broken hook never blocks the agent.
res.writeHead(204)
@ -174,6 +215,7 @@ export class AgentHookServer {
if (address && typeof address === 'object') {
this.port = address.port
}
console.log('[agent-hooks] receiver listening', { port: this.port })
resolve()
})
})

View file

@ -91,18 +91,25 @@ export class ClaudeHookService {
}
}
const managedHooksPresent = Object.values(config.hooks ?? {}).some((definitions) =>
definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === getManagedCommand(scriptPath))
const command = getManagedCommand(scriptPath)
const installedEvents = CLAUDE_EVENTS.filter((event) => {
const definitions = config.hooks?.[event.eventName] ?? []
return definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
)
})
const managedHooksPresent = installedEvents.length > 0
const allHooksPresent = installedEvents.length === CLAUDE_EVENTS.length
return {
agent: 'claude',
state: managedHooksPresent ? 'installed' : 'not_installed',
state: !managedHooksPresent ? 'not_installed' : allHooksPresent ? 'installed' : 'partial',
configPath,
managedHooksPresent,
detail: null
detail:
managedHooksPresent && !allHooksPresent
? `Installed for ${installedEvents.length}/${CLAUDE_EVENTS.length} required events`
: null
}
}

View file

@ -10,11 +10,14 @@ import {
type HookDefinition
} from '../agent-hooks/installer-utils'
// Why: Codex permission prompts arrive through PreToolUse hook callbacks. Orca
// maps that event to the waiting state, so the managed hook registration must
// subscribe to PreToolUse or the sidebar can never show Codex as blocked on
// approval.
const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'] as const
// Why: Codex fires PreToolUse before every tool call, not only on permission
// prompts. Subscribing to it caused every tool invocation to flip Orca's
// sidebar to "Waiting for permission". The real approval signals are
// event-specific (exec_approval_request, apply_patch_approval_request,
// request_user_input) and would need dedicated normalization. Until we add
// that, keep the managed registration to lifecycle events that are
// unambiguous about working/done state.
const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop'] as const
function getConfigPath(): string {
return join(homedir(), '.codex', 'hooks.json')
@ -80,18 +83,25 @@ export class CodexHookService {
}
}
const managedHooksPresent = Object.values(config.hooks ?? {}).some((definitions) =>
definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === getManagedCommand(scriptPath))
const command = getManagedCommand(scriptPath)
const installedEvents = CODEX_EVENTS.filter((eventName) => {
const definitions = config.hooks?.[eventName] ?? []
return definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
)
})
const managedHooksPresent = installedEvents.length > 0
const allHooksPresent = installedEvents.length === CODEX_EVENTS.length
return {
agent: 'codex',
state: managedHooksPresent ? 'installed' : 'not_installed',
state: !managedHooksPresent ? 'not_installed' : allHooksPresent ? 'installed' : 'partial',
configPath,
managedHooksPresent,
detail: null
detail:
managedHooksPresent && !allHooksPresent
? `Installed for ${installedEvents.length}/${CODEX_EVENTS.length} required events`
: null
}
}
@ -112,13 +122,27 @@ export class CodexHookService {
const command = getManagedCommand(scriptPath)
const nextHooks = { ...config.hooks }
for (const eventName of CODEX_EVENTS) {
// Why: sweep the managed command out of every event first, not just the
// ones we currently subscribe to. Without this, an older Orca install that
// registered PreToolUse (which mis-mapped to "waiting") would leave a stale
// hook in ~/.codex/hooks.json and every tool call would still flip the
// sidebar to "Waiting for permission" after an install refresh.
for (const eventName of Object.keys(nextHooks)) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, (currentCommand) => currentCommand === command)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
for (const eventName of CODEX_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
}
nextHooks[eventName] = [...cleaned, definition]
nextHooks[eventName] = [...current, definition]
}
config.hooks = nextHooks

View file

@ -112,10 +112,20 @@ function openMainWindow(): BrowserWindow {
})
mainWindow = window
agentHookServer.setListener(({ paneKey, payload }) => {
if (mainWindow?.isDestroyed()) {
return
console.log('[agent-hooks] broadcasting agentStatus:set', {
paneKey,
state: payload.state,
statusText: payload.statusText
})
// Why: the detached agent dashboard is a second BrowserWindow with its own
// renderer subscribing to agentStatus:set. Broadcasting to all non-destroyed
// windows keeps both main and dashboard views in sync without forcing the
// dashboard to poll the main renderer over IPC.
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('agentStatus:set', { paneKey, ...payload })
}
}
mainWindow?.webContents.send('agentStatus:set', { paneKey, ...payload })
})
return window
}
@ -138,16 +148,20 @@ app.whenReady().then(async () => {
rateLimits.setCodexHomePathResolver(() => codexAccounts!.getSelectedManagedHomePath())
runtime = new OrcaRuntimeService(store, stats)
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
// Why: managed hook installation mutates user-global agent config.
// Startup must fail open so a malformed local config never bricks Orca.
for (const installManagedHooks of [
() => claudeHookService.install(),
() => codexHookService.install()
]) {
try {
installManagedHooks()
} catch (error) {
console.error('[agent-hooks] Failed to install managed hooks:', error)
if (store.getSettings().autoInstallAgentHooks) {
for (const installManagedHooks of [
() => claudeHookService.install(),
() => codexHookService.install()
]) {
try {
installManagedHooks()
} catch (error) {
// Why: managed hook installation mutates user-global agent config, so
// startup reconciliation only runs after the user has explicitly opted
// in via Settings. Even then it must fail open so a malformed local
// config never bricks Orca.
console.error('[agent-hooks] Failed to install managed hooks:', error)
}
}
}

View file

@ -325,11 +325,25 @@ export function registerPtyHandlers(
}
) => {
const provider = getProvider(args.connectionId)
// Why: the daemon PTY adapter does not route through LocalPtyProvider's
// `buildSpawnEnv` hook, so agent-hook loopback coordinates
// (ORCA_AGENT_HOOK_PORT / TOKEN) are never injected when the daemon is
// active. Merge them here so Claude/Codex managed hook scripts can reach
// the local receiver regardless of which provider handles the spawn.
const agentHookEnv = args.connectionId ? {} : agentHookServer.buildPtyEnv()
const mergedEnv = { ...args.env, ...agentHookEnv }
console.log('[agent-hooks] pty:spawn merged env', {
connectionId: args.connectionId ?? null,
hasPort: Boolean(mergedEnv.ORCA_AGENT_HOOK_PORT),
hasToken: Boolean(mergedEnv.ORCA_AGENT_HOOK_TOKEN),
hasPaneKey: Boolean(mergedEnv.ORCA_PANE_KEY),
paneKey: mergedEnv.ORCA_PANE_KEY
})
const result = await provider.spawn({
cols: args.cols,
rows: args.rows,
cwd: args.cwd,
env: args.env,
env: mergedEnv,
command: args.command,
worktreeId: args.worktreeId,
sessionId: args.sessionId

View file

@ -180,7 +180,7 @@ describe('registerCoreHandlers', () => {
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store)
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store, null)
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerCliHandlersMock).toHaveBeenCalled()

View file

@ -73,7 +73,7 @@ export function registerCoreHandlers(
browserSessionRegistry.restorePersistedUserAgent()
registerShellHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
registerUIHandlers(store, mainWindowWebContentsId)
registerFilesystemHandlers(store)
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)

View file

@ -1,14 +1,32 @@
import { ipcMain } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { WorkspaceSessionState } from '../../shared/types'
function broadcastSessionUpdate(sender: Electron.WebContents | null): void {
// Why: the detached agent-dashboard window mounts its own renderer and needs
// to observe tabs/terminal title changes written by the main window. Echo the
// new session state to every other window so the dashboard view stays live.
// We skip the origin WebContents so the writer does not pay the cost of
// re-hydrating its own update.
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) {
continue
}
if (sender && win.webContents.id === sender.id) {
continue
}
win.webContents.send('session:updated')
}
}
export function registerSessionHandlers(store: Store): void {
ipcMain.handle('session:get', () => {
return store.getWorkspaceSession()
})
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState) => {
ipcMain.handle('session:set', (event, args: WorkspaceSessionState) => {
store.setWorkspaceSession(args)
broadcastSessionUpdate(event.sender)
})
// Synchronous variant for the renderer's beforeunload handler.
@ -19,5 +37,6 @@ export function registerSessionHandlers(store: Store): void {
store.setWorkspaceSession(args)
store.flush()
event.returnValue = true
broadcastSessionUpdate(event.sender)
})
}

View file

@ -1,8 +1,9 @@
import { ipcMain } from 'electron'
import { app, BrowserWindow, ipcMain, webContents as electronWebContents } from 'electron'
import type { Store } from '../persistence'
import type { PersistedUIState } from '../../shared/types'
import { openAgentDashboardWindow } from '../window/createAgentDashboardWindow'
export function registerUIHandlers(store: Store): void {
export function registerUIHandlers(store: Store, mainWebContentsId: number | null = null): void {
ipcMain.handle('ui:get', () => {
return store.getUI()
})
@ -10,4 +11,47 @@ export function registerUIHandlers(store: Store): void {
ipcMain.handle('ui:set', (_event, args: Partial<PersistedUIState>) => {
store.updateUI(args)
})
ipcMain.handle('ui:openAgentDashboard', () => {
console.log('[ui] ui:openAgentDashboard handler invoked')
try {
openAgentDashboardWindow(store)
} catch (error) {
console.error('[ui] openAgentDashboardWindow threw', error)
throw error
}
})
// Why: the detached dashboard window has its own renderer store, so simply
// calling setActiveWorktree there does not switch the user's view in the
// main window. Route the request through main so the main window's
// renderer receives the existing ui:activateWorktree event — the same path
// used by CLI-created worktrees and notification clicks.
ipcMain.handle(
'ui:requestActivateWorktree',
(_event, args: { repoId: string; worktreeId: string }) => {
if (mainWebContentsId == null) {
return
}
const wc = electronWebContents.fromId(mainWebContentsId)
if (!wc || wc.isDestroyed()) {
return
}
const main = BrowserWindow.fromWebContents(wc)
if (!main || main.isDestroyed()) {
return
}
if (process.platform === 'darwin') {
app.focus({ steal: true })
}
if (main.isMinimized()) {
main.restore()
}
main.focus()
wc.send('ui:activateWorktree', {
repoId: args.repoId,
worktreeId: args.worktreeId
})
}
)
}

View file

@ -0,0 +1,183 @@
import { BrowserWindow, nativeTheme, screen, shell } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import icon from '../../../resources/icon.png?asset'
import devIcon from '../../../resources/icon-dev.png?asset'
import type { Store } from '../persistence'
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
const DEFAULT_WIDTH = 520
const DEFAULT_HEIGHT = 640
// Why: the detached dashboard is a secondary window that renders just the
// AgentDashboard component. We key its renderer route with a query param so
// the same main.tsx entry decides whether to mount the full app or the
// lightweight dashboard view.
const DASHBOARD_VIEW_PARAM = 'view=agent-dashboard'
let dashboardWindow: BrowserWindow | null = null
function computeDefaultBounds(): { width: number; height: number } {
try {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
return {
width: Math.min(DEFAULT_WIDTH, width),
height: Math.min(DEFAULT_HEIGHT, height)
}
} catch {
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }
}
}
// Why: on some platforms/timings `ready-to-show` never fires (e.g. if the
// renderer hits a runtime error before first paint). Without a fallback the
// window stays hidden forever and the user just sees nothing happen when they
// click the popout button. Force-show after a short grace period.
const READY_TO_SHOW_TIMEOUT_MS = 3000
function isBoundsOnScreen(bounds: {
x: number
y: number
width: number
height: number
}): boolean {
try {
const displays = screen.getAllDisplays()
return displays.some((d) => {
const wa = d.workArea
return (
bounds.x + bounds.width > wa.x &&
bounds.x < wa.x + wa.width &&
bounds.y + bounds.height > wa.y &&
bounds.y < wa.y + wa.height
)
})
} catch {
return true
}
}
export function openAgentDashboardWindow(store: Store | null): BrowserWindow {
console.log('[dashboard-window] openAgentDashboardWindow invoked')
// Why: singleton — a second invocation focuses the existing window instead
// of spawning duplicates. Multiple dashboard windows would subscribe to the
// same IPC events and compete for the same bounds-persistence slot.
if (dashboardWindow && !dashboardWindow.isDestroyed()) {
console.log('[dashboard-window] focusing existing window')
if (dashboardWindow.isMinimized()) {
dashboardWindow.restore()
}
dashboardWindow.focus()
return dashboardWindow
}
const rawSavedBounds = store?.getUI().agentDashboardWindowBounds ?? null
// Why: if the display that hosted the window last time is no longer present
// (external monitor unplugged), restoring its coordinates would place the
// window offscreen so it looks like the popout silently failed.
const savedBounds = rawSavedBounds && isBoundsOnScreen(rawSavedBounds) ? rawSavedBounds : null
if (rawSavedBounds && !savedBounds) {
console.log('[dashboard-window] discarding offscreen saved bounds', rawSavedBounds)
}
const defaultBounds = computeDefaultBounds()
const win = new BrowserWindow({
width: savedBounds?.width ?? defaultBounds.width,
height: savedBounds?.height ?? defaultBounds.height,
...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}),
minWidth: 360,
minHeight: 360,
show: false,
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
title: 'Agent Dashboard',
icon: is.dev ? devIcon : icon,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true
}
})
let shown = false
const revealWindow = (reason: string): void => {
if (shown || win.isDestroyed()) {
return
}
shown = true
console.log(`[dashboard-window] showing window (reason=${reason})`)
win.show()
}
win.once('ready-to-show', () => revealWindow('ready-to-show'))
const readyFallback = setTimeout(() => revealWindow('timeout-fallback'), READY_TO_SHOW_TIMEOUT_MS)
win.webContents.once('did-finish-load', () => {
console.log('[dashboard-window] did-finish-load')
revealWindow('did-finish-load')
})
win.webContents.on('did-fail-load', (_event, code, description, url) => {
console.error('[dashboard-window] did-fail-load', { code, description, url })
revealWindow('did-fail-load')
})
win.webContents.on('render-process-gone', (_event, details) => {
console.error('[dashboard-window] render-process-gone', details)
})
win.webContents.on('console-message', (details) => {
console.log(
`[dashboard-window][renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`
)
})
let boundsTimer: ReturnType<typeof setTimeout> | null = null
const saveBounds = (): void => {
if (boundsTimer) {
clearTimeout(boundsTimer)
}
boundsTimer = setTimeout(() => {
boundsTimer = null
if (win.isDestroyed()) {
return
}
store?.updateUI({ agentDashboardWindowBounds: win.getBounds() })
}, 500)
}
win.on('resize', saveBounds)
win.on('move', saveBounds)
// Why: external links clicked in the dashboard (e.g. a future "open PR"
// action) must escape into the OS browser, never open as a child window
// that would inherit the preload bridge.
win.webContents.setWindowOpenHandler((details) => {
const externalUrl = normalizeExternalBrowserUrl(details.url)
if (externalUrl) {
shell.openExternal(externalUrl)
}
return { action: 'deny' }
})
win.on('closed', () => {
if (boundsTimer) {
clearTimeout(boundsTimer)
boundsTimer = null
}
clearTimeout(readyFallback)
if (dashboardWindow === win) {
dashboardWindow = null
}
})
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
const url = `${process.env.ELECTRON_RENDERER_URL}?${DASHBOARD_VIEW_PARAM}`
console.log('[dashboard-window] loading dev URL', url)
win.loadURL(url).catch((err) => {
console.error('[dashboard-window] loadURL failed', err)
})
} else {
const htmlPath = join(__dirname, '../renderer/index.html')
console.log('[dashboard-window] loading prod file', htmlPath)
win.loadFile(htmlPath, { search: DASHBOARD_VIEW_PARAM }).catch((err) => {
console.error('[dashboard-window] loadFile failed', err)
})
}
dashboardWindow = win
return win
}

View file

@ -414,6 +414,7 @@ export type PreloadApi = {
get: () => Promise<WorkspaceSessionState>
set: (args: WorkspaceSessionState) => Promise<void>
setSync: (args: WorkspaceSessionState) => void
onUpdated: (callback: () => void) => () => void
}
updater: {
getVersion: () => Promise<string>
@ -575,6 +576,8 @@ export type PreloadApi = {
onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void
onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void) => () => void
confirmWindowClose: () => void
openAgentDashboard: () => Promise<void>
requestActivateWorktree: (args: { repoId: string; worktreeId: string }) => Promise<void>
}
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>

View file

@ -202,8 +202,8 @@ type AgentStatusApi = {
callback: (data: {
paneKey: string
state: string
summary?: string
next?: string
statusText?: string
promptText?: string
agentType?: string
}) => void
) => () => void

View file

@ -762,6 +762,14 @@ const api = {
/** Synchronous session save for beforeunload — blocks until flushed to disk. */
setSync: (args: unknown): void => {
ipcRenderer.sendSync('session:set-sync', args)
},
/** Fired when another window wrote a new session payload. The detached
* agent-dashboard window subscribes to this so its view of tabs/terminal
* titles stays in sync with the main window. */
onUpdated: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('session:updated', listener)
return () => ipcRenderer.removeListener('session:updated', listener)
}
},
@ -1133,7 +1141,16 @@ const api = {
/** Tell the main process to proceed with the window close. */
confirmWindowClose: (): void => {
ipcRenderer.send('window:confirm-close')
}
},
/** Open the agent dashboard in a detached secondary window. Focuses the
* existing window if one is already open. */
openAgentDashboard: (): Promise<void> => ipcRenderer.invoke('ui:openAgentDashboard'),
/** Ask the main window to activate a worktree (focusing main + routing
* through the same ui:activateWorktree path CLI/notifications use).
* Used by the detached dashboard window, whose renderer has its own
* store and cannot mutate the main window's active-worktree state. */
requestActivateWorktree: (args: { repoId: string; worktreeId: string }): Promise<void> =>
ipcRenderer.invoke('ui:requestActivateWorktree', args)
},
stats: {
@ -1296,8 +1313,8 @@ const api = {
callback: (data: {
paneKey: string
state: string
summary?: string
next?: string
statusText?: string
promptText?: string
agentType?: string
}) => void
): (() => void) => {
@ -1306,8 +1323,8 @@ const api = {
data: {
paneKey: string
state: string
summary?: string
next?: string
statusText?: string
promptText?: string
agentType?: string
}
) => callback(data)

View file

@ -493,14 +493,6 @@ function App(): React.JSX.Element {
e.preventDefault()
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
return
}
// Cmd/Ctrl+Shift+D — toggle right sidebar / agent dashboard tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') {
e.preventDefault()
actions.setRightSidebarTab('dashboard')
actions.setRightSidebarOpen(true)
}
}

View file

@ -0,0 +1,115 @@
import { useEffect } from 'react'
import { useAppStore } from './store'
import { useShallow } from 'zustand/react/shallow'
import AgentDashboard from './components/dashboard/AgentDashboard'
import { parseAgentStatusPayload } from '../../shared/agent-status-types'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
// Why: the detached dashboard is a lean secondary window. It does NOT
// reconnect PTYs, own terminal scrollback, or write the workspace session.
// It only hydrates enough state (repos, worktrees, tabs, agent status) to
// render AgentDashboard live, and subscribes to IPC pushes so the view stays
// in sync with whatever the main window does.
export default function DashboardApp(): React.JSX.Element {
console.log('[DashboardApp] mounting detached dashboard renderer')
const actions = useAppStore(
useShallow((s) => ({
fetchRepos: s.fetchRepos,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktrees: s.fetchWorktrees,
hydrateObserverSession: s.hydrateObserverSession,
hydrateTabsSession: s.hydrateTabsSession,
setAgentStatus: s.setAgentStatus
}))
)
useEffect(() => {
let cancelled = false
const hydrate = async (): Promise<void> => {
try {
console.log('[DashboardApp] hydrating: fetchRepos')
await actions.fetchRepos()
console.log('[DashboardApp] hydrating: fetchAllWorktrees')
await actions.fetchAllWorktrees()
console.log('[DashboardApp] hydrating: session.get')
const session = await window.api.session.get()
if (cancelled) {
return
}
actions.hydrateObserverSession(session)
actions.hydrateTabsSession(session)
console.log('[DashboardApp] hydration complete')
} catch (error) {
console.error('[DashboardApp] hydration failed', error)
}
}
void hydrate()
return () => {
cancelled = true
}
}, [actions])
useEffect(() => {
const unsubs: (() => void)[] = []
unsubs.push(
window.api.repos.onChanged(() => {
void useAppStore.getState().fetchRepos()
})
)
unsubs.push(
window.api.worktrees.onChanged((data: { repoId: string }) => {
void useAppStore.getState().fetchWorktrees(data.repoId)
})
)
// Why: the main window is the source of truth for terminal tabs (it owns
// every PTY). When it writes session:set, re-fetch so this window sees new
// tabs, retitled agents, or tabs that were closed.
unsubs.push(
window.api.session.onUpdated(() => {
void (async () => {
const session = await window.api.session.get()
const store = useAppStore.getState()
store.hydrateObserverSession(session)
store.hydrateTabsSession(session)
})()
})
)
unsubs.push(
window.api.agentStatus.onSet((data) => {
const payload = parseAgentStatusPayload(
JSON.stringify({
state: data.state,
statusText: data.statusText,
promptText: data.promptText,
agentType: data.agentType
})
)
if (!payload) {
return
}
const store = useAppStore.getState()
store.setAgentStatus(data.paneKey, payload, undefined)
})
)
return () => {
for (const fn of unsubs) {
fn()
}
}
}, [])
return (
<TooltipProvider delayDuration={400}>
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<AgentDashboard />
</div>
<Toaster />
</TooltipProvider>
)
}

View file

@ -11,6 +11,32 @@ import { useRetainedAgents } from './useRetainedAgents'
type ViewMode = 'list' | 'radial'
function computeDominantState(states: string[]): 'working' | 'blocked' | 'done' | 'idle' {
if (states.length === 0) {
return 'idle'
}
let hasWorking = false
let hasDone = false
for (const state of states) {
if (state === 'blocked' || state === 'waiting') {
return 'blocked'
}
if (state === 'working') {
hasWorking = true
}
if (state === 'done') {
hasDone = true
}
}
if (hasWorking) {
return 'working'
}
if (hasDone) {
return 'done'
}
return 'idle'
}
const AgentDashboard = React.memo(function AgentDashboard() {
const liveGroups = useDashboardData()
// Why: useRetainedAgents merges "done" entries for agents that have
@ -29,8 +55,51 @@ const AgentDashboard = React.memo(function AgentDashboard() {
const [checkedWorktreeIds, setCheckedWorktreeIds] = useState<Set<string>>(new Set())
const prevDominantStates = useRef<Record<string, string>>({})
const visibleGroups = useMemo(() => {
// Why: a user-dismissed worktree should hide completed agents regardless
// of whether they come from retained history or a still-live explicit
// status entry. Otherwise "done" agents can reappear in the radial view
// until their pane closes, which violates the remove-from-UI behavior.
return groups
.map((group) => {
const worktrees = group.worktrees
.map((wt) => {
if (!checkedWorktreeIds.has(wt.worktree.id)) {
return wt
}
const visibleAgents = wt.agents.filter((agent) => agent.state !== 'done')
if (visibleAgents.length === wt.agents.length) {
return wt
}
return {
...wt,
agents: visibleAgents,
dominantState: computeDominantState(visibleAgents.map((agent) => agent.state)),
latestActivityAt: Math.max(
0,
...visibleAgents.map((agent) => agent.entry?.updatedAt ?? 0)
)
}
})
.filter((wt) => wt.agents.length > 0)
const attentionCount = worktrees.reduce(
(count, wt) =>
count +
wt.agents.filter((agent) => agent.state === 'blocked' || agent.state === 'waiting')
.length,
0
)
return { ...group, worktrees, attentionCount }
})
.filter((group) => group.worktrees.length > 0)
}, [groups, checkedWorktreeIds])
const { filter, setFilter, filteredGroups, hasResults } = useDashboardFilter(
groups,
visibleGroups,
checkedWorktreeIds
)
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
@ -102,7 +171,7 @@ const AgentDashboard = React.memo(function AgentDashboard() {
let runningAgents = 0
let blockedAgents = 0
let doneAgents = 0
for (const group of groups) {
for (const group of visibleGroups) {
for (const wt of group.worktrees) {
for (const agent of wt.agents) {
if (agent.state === 'working') {
@ -118,7 +187,7 @@ const AgentDashboard = React.memo(function AgentDashboard() {
}
}
return { running: runningAgents, blocked: blockedAgents, done: doneAgents }
}, [groups])
}, [visibleGroups])
// Empty state: no repos at all
if (groups.length === 0) {
@ -196,7 +265,7 @@ const AgentDashboard = React.memo(function AgentDashboard() {
{/* Scrollable content — switches between concentric and list views */}
{viewMode === 'radial' ? (
<ConcentricView groups={groups} onCheckWorktree={handleCheckWorktree} />
<ConcentricView groups={visibleGroups} onCheckWorktree={handleCheckWorktree} />
) : (
<div className="flex-1 overflow-y-auto scrollbar-sleek">
<div className="flex flex-col gap-2 p-2">

View file

@ -1,22 +1,58 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Maximize2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import type { DashboardRepoGroup } from './useDashboardData'
import RepoSystem, { stateColor } from './RepoSystem'
// Why: the dashboard renders in two places — embedded in the main window's
// right sidebar, and in a detached secondary window. The detached renderer
// has its own Zustand store, so calling setActiveWorktree there does nothing
// visible to the user. Detect that case so we can instead ask main to
// activate the worktree in the main window.
const IS_DETACHED_DASHBOARD =
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('view') === 'agent-dashboard'
export type TooltipBlock = { state: string; title: string }
export type TooltipData = {
x: number
y: number
agentLabel: string
state: string
statusText?: string
promptText?: string
worktreeName: string
branchName?: string
blocks?: TooltipBlock[]
}
// ─── Scroll Position Persistence ─────────────────────────────────────────────
// Why: the concentric view unmounts when the user switches sidebar tabs. A
// module-level variable survives the unmount so scroll position is restored
// when the user returns to the dashboard.
let savedScrollTop = 0
// Why: approximate height of the tooltip card. Used to decide whether to
// flip the tooltip below the cursor when there isn't enough room above
// (otherwise the card is clipped by the top of the viewport).
const TOOLTIP_FLIP_THRESHOLD_PX = 140
// Why: clamp zoom so we never invert the content or zoom so far that a single
// worktree ring fills the whole viewport and loses context.
const MIN_SCALE = 0.3
const MAX_SCALE = 4
// Why: WebKit-legacy gesture events aren't in lib.dom.d.ts but are dispatched
// by Chromium on macOS for trackpad pinches. Declaring the shape locally avoids
// casting through any while still keeping the event handlers type-safe.
type GestureEvent = Event & { scale: number; clientX: number; clientY: number }
// Why: pixels the cursor can move during a mousedown before we treat it as a
// pan instead of a click. Matches rough OS-level drag thresholds.
const DRAG_THRESHOLD_PX = 3
// ─── Transform Persistence ───────────────────────────────────────────────────
// Why: the concentric view unmounts when the user switches sidebar tabs or
// closes/reopens the dashboard window. A module-level variable survives the
// unmount so pan/zoom position is restored when the user returns.
let savedTransform: Transform = { x: 0, y: 0, scale: 1 }
type Transform = { x: number; y: number; scale: number }
type ConcentricViewProps = {
groups: DashboardRepoGroup[]
@ -27,77 +63,310 @@ export default function ConcentricView({ groups, onCheckWorktree }: ConcentricVi
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
const [tooltip, setTooltip] = useState<TooltipData | null>(null)
const [transform, setTransform] = useState<Transform>(savedTransform)
const [isPanning, setIsPanning] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Why: ref copy of transform so wheel/mouse handlers attached via
// addEventListener read the latest values without needing to re-register.
const transformRef = useRef<Transform>(transform)
transformRef.current = transform
// Restore scroll position on mount, save on unmount
// Why: tracks whether the current mousedown has crossed the drag threshold.
// Checked on click to suppress accidental worktree activation after a pan.
const draggedRef = useRef(false)
const panStartRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null)
// Persist transform across unmount.
useEffect(() => {
const el = containerRef.current
if (el) {
el.scrollTop = savedScrollTop
}
return () => {
if (el) {
savedScrollTop = el.scrollTop
}
savedTransform = transformRef.current
}
}, [])
// Why: attach wheel + WebKit gesture listeners via native addEventListener
// with { passive: false } so we can preventDefault and stop the page (or
// parent) from scrolling / zooming the whole web contents while the user
// manipulates this canvas. React's synthetic wheel handler is always passive.
useEffect(() => {
const el = containerRef.current
if (!el) {
return
}
const applyZoomAt = (clientX: number, clientY: number, zoomFactor: number): void => {
const rect = el.getBoundingClientRect()
const mouseX = clientX - rect.left
const mouseY = clientY - rect.top
const prev = transformRef.current
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev.scale * zoomFactor))
const actualFactor = nextScale / prev.scale
// Keep the point under the cursor stationary in screen space.
const nextX = mouseX - (mouseX - prev.x) * actualFactor
const nextY = mouseY - (mouseY - prev.y) * actualFactor
setTransform({ x: nextX, y: nextY, scale: nextScale })
}
const handleWheel = (e: WheelEvent): void => {
e.preventDefault()
// Why: trackpad pinch-zoom in Chromium arrives as a wheel event with
// small deltaY and ctrlKey=true; normal wheel scroll has larger deltaY
// and no ctrlKey. Both should zoom here — we're a canvas, not a
// scrollable page. Pinch gets higher sensitivity so a natural gesture
// produces perceptible zoom instead of crawling.
const sensitivity = e.ctrlKey ? 0.02 : 0.0015
const zoomFactor = Math.exp(-e.deltaY * sensitivity)
applyZoomAt(e.clientX, e.clientY, zoomFactor)
}
// Why: on macOS, Electron/Chromium doesn't always synthesize pinch into
// ctrlKey-wheel events — especially in windows where visualZoomLevelLimits
// hasn't been configured. WebKit-legacy GestureEvents (gesturestart /
// gesturechange / gestureend) are still dispatched to the DOM for trackpad
// pinch and carry a `scale` property representing cumulative zoom relative
// to the gesture's start. Tracking the delta between successive scale
// readings lets us zoom even when the wheel path is silent.
let prevScale = 1
let anchorX = 0
let anchorY = 0
const handleGestureStart = (e: Event): void => {
e.preventDefault()
const ge = e as GestureEvent
prevScale = 1
anchorX = ge.clientX
anchorY = ge.clientY
}
const handleGestureChange = (e: Event): void => {
e.preventDefault()
const ge = e as GestureEvent
const delta = ge.scale / prevScale
prevScale = ge.scale
if (!Number.isFinite(delta) || delta <= 0) {
return
}
applyZoomAt(ge.clientX || anchorX, ge.clientY || anchorY, delta)
}
const handleGestureEnd = (e: Event): void => {
e.preventDefault()
}
el.addEventListener('wheel', handleWheel, { passive: false })
el.addEventListener('gesturestart', handleGestureStart, { passive: false })
el.addEventListener('gesturechange', handleGestureChange, { passive: false })
el.addEventListener('gestureend', handleGestureEnd, { passive: false })
return () => {
el.removeEventListener('wheel', handleWheel)
el.removeEventListener('gesturestart', handleGestureStart)
el.removeEventListener('gesturechange', handleGestureChange)
el.removeEventListener('gestureend', handleGestureEnd)
}
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Only left-button drags initiate a pan.
if (e.button !== 0) {
return
}
const prev = transformRef.current
panStartRef.current = {
x: e.clientX,
y: e.clientY,
tx: prev.x,
ty: prev.y
}
draggedRef.current = false
setIsPanning(true)
}, [])
useEffect(() => {
if (!isPanning) {
return
}
const handleMove = (e: MouseEvent): void => {
const start = panStartRef.current
if (!start) {
return
}
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (
!draggedRef.current &&
(Math.abs(dx) > DRAG_THRESHOLD_PX || Math.abs(dy) > DRAG_THRESHOLD_PX)
) {
draggedRef.current = true
// Why: hide any hover tooltip once a pan begins — it would stay
// frozen at its last screen position while the content moves.
setTooltip(null)
}
if (draggedRef.current) {
setTransform((curr) => ({ ...curr, x: start.tx + dx, y: start.ty + dy }))
}
}
const handleUp = (): void => {
panStartRef.current = null
setIsPanning(false)
}
window.addEventListener('mousemove', handleMove)
window.addEventListener('mouseup', handleUp)
return () => {
window.removeEventListener('mousemove', handleMove)
window.removeEventListener('mouseup', handleUp)
}
}, [isPanning])
const handleClick = useCallback(
(worktreeId: string) => {
// Why: a drag that crossed the threshold should not also count as a
// click on the worktree underneath. onMouseUp fires before onClick, so
// draggedRef is already set by this point.
if (draggedRef.current) {
return
}
// Why: clicking a tile — regardless of its state — only navigates to the
// terminal. Dismissal of "done" agents is an explicit action on the X
// button (see onDismiss) so users can click through to review a finished
// agent without losing it from the dashboard.
if (IS_DETACHED_DASHBOARD) {
// Detached window cannot mutate the main window's store directly;
// route through main so the main window receives the existing
// ui:activateWorktree event and focuses itself.
void window.api.ui.requestActivateWorktree({
repoId: getRepoIdFromWorktreeId(worktreeId),
worktreeId
})
return
}
setActiveWorktree(worktreeId)
setActiveView('terminal')
},
[setActiveWorktree, setActiveView]
)
const handleDismiss = useCallback(
(worktreeId: string) => {
if (draggedRef.current) {
return
}
onCheckWorktree(worktreeId)
},
[setActiveWorktree, setActiveView, onCheckWorktree]
[onCheckWorktree]
)
const showTooltip = useCallback((e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => {
// Why: while the user is actively panning, swallow tooltip updates so
// the card doesn't flicker under the moving cursor.
if (draggedRef.current) {
return
}
const container = containerRef.current
if (!container) {
return
}
const rect = container.getBoundingClientRect()
// Why: the tooltip is position:absolute inside the scrollable container,
// so we must add scrollTop to map viewport coords to content coords.
// Tooltip lives outside the transformed layer, so we store container-
// relative screen coords — no scroll or scale correction needed.
setTooltip({
...data,
x: e.clientX - rect.left,
y: e.clientY - rect.top + container.scrollTop
y: e.clientY - rect.top
})
}, [])
const containerRect = containerRef.current?.getBoundingClientRect()
// Why: tooltip renders above the cursor by default. If the cursor is too
// close to the top of the container, the tooltip gets clipped. Flip it
// below the cursor in that case.
const flipBelow = tooltip !== null && tooltip.y - TOOLTIP_FLIP_THRESHOLD_PX < 0
const hideTooltip = useCallback(() => setTooltip(null), [])
const resetView = useCallback(() => {
setTransform({ x: 0, y: 0, scale: 1 })
}, [])
if (groups.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center text-[11px] text-muted-foreground">
No repos added. Add a repo to see agent activity.
No visible agents.
</div>
)
}
const isTransformed = transform.x !== 0 || transform.y !== 0 || transform.scale !== 1
return (
<div ref={containerRef} className="relative flex-1 overflow-y-auto scrollbar-sleek">
<div className="flex flex-col items-center gap-1 py-2">
{groups.map((group) => (
<RepoSystem
key={group.repo.id}
group={group}
onClick={handleClick}
onShowTooltip={showTooltip}
onHideTooltip={hideTooltip}
/>
))}
<div
ref={containerRef}
className="relative flex-1 overflow-hidden select-none"
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
onMouseDown={handleMouseDown}
onDoubleClick={(e) => {
// Why: double-click on empty canvas resets the view. Skip when the
// double-click landed on an SVG element so double-tapping a worktree
// still goes through the worktree's own handlers.
const node = e.target as Element | null
if (node && node.closest('svg')) {
return
}
resetView()
}}
>
{/* Transformed content layer pan/zoom is applied here.
Width matches the container so items-center still centers repos
horizontally at scale=1, preserving the original layout. */}
<div
className="absolute left-0 top-0 w-full origin-top-left"
style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
// Why: disable transition while actively panning so motion tracks
// the cursor 1:1. Leave a short ease on wheel-driven zoom commits
// only when NOT panning for a subtle polish.
transition: isPanning ? 'none' : 'transform 60ms ease-out',
willChange: 'transform'
}}
>
<div className="flex flex-col items-center gap-1 py-2">
{groups.map((group) => (
<RepoSystem
key={group.repo.id}
group={group}
onClick={handleClick}
onDismiss={handleDismiss}
onShowTooltip={showTooltip}
onHideTooltip={hideTooltip}
/>
))}
</div>
</div>
{/* Floating tooltip overlay — shows per-agent detail on hover */}
{tooltip && (
{/* Zoom controls — floating in the corner, outside the transform. */}
<div className="pointer-events-none absolute right-2 top-2 z-40 flex items-center gap-1">
<div className="rounded-md border border-border/40 bg-popover/80 px-1.5 py-0.5 text-[10px] text-muted-foreground backdrop-blur-sm">
{Math.round(transform.scale * 100)}%
</div>
{isTransformed && (
<button
type="button"
onClick={resetView}
className="pointer-events-auto flex items-center justify-center rounded-md border border-border/40 bg-popover/80 p-1 text-muted-foreground backdrop-blur-sm transition-colors hover:text-foreground"
aria-label="Reset view"
title="Reset view (or double-click empty space)"
>
<Maximize2 size={11} />
</button>
)}
</div>
{/* Floating tooltip overlay shows per-agent detail on hover.
Rendered outside the transformed layer so it stays screen-space. */}
{tooltip && containerRect && (
<div
className="pointer-events-none absolute z-50 max-w-[220px] rounded-lg border border-border/50 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur-sm"
style={{
left: tooltip.x,
top: tooltip.y - 12,
transform: 'translate(-50%, -100%)'
// Why: clamp horizontal position so the tooltip (centered on x via
// transform) never extends past the edges of the container.
left: Math.min(Math.max(tooltip.x, 112), Math.max(112, containerRect.width - 112)),
top: flipBelow ? tooltip.y + 18 : tooltip.y - 12,
transform: flipBelow ? 'translate(-50%, 0)' : 'translate(-50%, -100%)'
}}
>
{/* Agent identity + state */}
@ -116,10 +385,29 @@ export default function ConcentricView({ groups, onCheckWorktree }: ConcentricVi
</div>
{/* Worktree context */}
<div className="mt-0.5 text-[9px] text-muted-foreground/50">
{tooltip.worktreeName}
{tooltip.branchName ? ` · ${tooltip.branchName}` : ''}
</div>
<div className="mt-0.5 text-[9px] text-muted-foreground/50">{tooltip.worktreeName}</div>
{tooltip.blocks && tooltip.blocks.length > 0 ? (
<div className="mt-1.5 flex items-center gap-0.5">
{tooltip.blocks.map((block, i) => (
<span
key={`${i}-${block.state}`}
title={block.title}
className="h-1.5 w-3 rounded-sm"
style={{ backgroundColor: stateColor(block.state).fill, opacity: 0.75 }}
/>
))}
</div>
) : null}
{tooltip.promptText ? (
<div className="mt-1 max-w-[220px] text-[10px] leading-snug text-muted-foreground/80">
Prompt: {tooltip.promptText}
</div>
) : null}
{tooltip.statusText ? (
<div className="mt-1 max-w-[220px] text-[10px] leading-snug text-muted-foreground/65">
{tooltip.statusText}
</div>
) : null}
</div>
)}
</div>

View file

@ -49,28 +49,35 @@ function stateLabelColor(state: string): string {
}
}
// Why: the tooltip preserves the fuller prompt/status text so no information
// is permanently hidden behind the compact row layout.
function rowTooltip(agent: DashboardAgentRowData): string {
const parts: string[] = []
const prompt = agent.promptText.trim()
const status = agent.statusText.trim()
if (prompt) {
parts.push(`Prompt: ${prompt}`)
}
if (status) {
parts.push(status)
}
return parts.join('\n')
}
type Props = {
agent: DashboardAgentRowData
}
const DashboardAgentRow = React.memo(function DashboardAgentRow({ agent }: Props) {
const agentLabel = formatAgentTypeLabel(agent.agentType)
const tooltip = rowTooltip(agent)
return (
<div
className={cn(
'rounded px-1.5 py-1 bg-background/30',
agent.source === 'heuristic' && 'opacity-70'
)}
>
{/* Top line: agent label + current state */}
<div title={tooltip || undefined} className={cn('rounded px-1.5 py-1 bg-background/30')}>
<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))}>
<span className="text-[10px] font-medium text-foreground/80 truncate">{agentLabel}</span>
<span className={cn('ml-auto text-[10px] font-medium', stateLabelColor(agent.state))}>
{stateLabel(agent.state)}
</span>
</div>

View file

@ -1,9 +1,18 @@
import React, { useCallback } from 'react'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import DashboardAgentRow from './DashboardAgentRow'
import type { DashboardWorktreeCard as DashboardWorktreeCardData } from './useDashboardData'
// Why: the dashboard renders in two places — embedded in the main window's
// right sidebar, and in a detached secondary window. The detached renderer
// has its own Zustand store, so calling setActiveWorktree there has no
// effect on what the user sees. Detect that case and route through main.
const IS_DETACHED_DASHBOARD =
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('view') === 'agent-dashboard'
function dominantStateBadge(state: string): { label: string; className: string } {
switch (state) {
case 'working':
@ -39,8 +48,15 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
// "checked" so done agents disappear from the active filter. The two actions
// (navigate + check) must both fire on click.
const handleClick = useCallback(() => {
setActiveWorktree(card.worktree.id)
setActiveView('terminal')
if (IS_DETACHED_DASHBOARD) {
void window.api.ui.requestActivateWorktree({
repoId: getRepoIdFromWorktreeId(card.worktree.id),
worktreeId: card.worktree.id
})
} else {
setActiveWorktree(card.worktree.id)
setActiveView('terminal')
}
onCheck()
}, [card.worktree.id, setActiveWorktree, setActiveView, onCheck])
@ -93,7 +109,6 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
<div className="mt-0.5 text-[10px] text-muted-foreground/60 truncate">{branchName}</div>
)}
{/* Agent rows with activity blocks */}
{card.agents.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1">
{card.agents.map((agent) => (

View file

@ -1,29 +1,21 @@
/* eslint-disable max-lines -- Why: RepoSystem is a single SVG component whose
layout constants, animation styles, and rendering are tightly coupled. Splitting
the SVG template across files would scatter the coordinate system and make the
visual layout harder to trace during debugging. */
import React from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import type { DashboardRepoGroup } from './useDashboardData'
import { AgentIcon } from '@/lib/agent-catalog'
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
import type { TuiAgent } from '../../../../shared/types'
import type {
DashboardRepoGroup,
DashboardAgentRow,
DashboardWorktreeCard
} from './useDashboardData'
import type { TooltipData } from './ConcentricView'
// ─── Layout Constants ────────────────────────────────────────────────────────
// Why: the SVG viewBox is fixed at 280x280 so each repo "system" renders as a
// square that scales to the sidebar width. Radii define the three concentric
// rings: outer (repo boundary), orbit (where worktrees sit), and inner (decorative).
const SVG_SIZE = 280
const CX = SVG_SIZE / 2
const CY = SVG_SIZE / 2
const REPO_RING_R = 115
const ORBIT_R = 75
const INNER_DECOR_R = 28
const BASE_WT_R = 24
const BASE_AGENT_R = 8
// ─── State Colors ────────────────────────────────────────────────────────────
// Why: hardcoded hex values ensure consistent SVG rendering regardless of CSS
// variable availability. These match the Tailwind color tokens used in the
// card-grid dashboard (emerald-500, amber-500, sky-500, zinc-500).
// Why: hardcoded hex values ensure consistent rendering regardless of CSS
// variable availability. These match the Tailwind color tokens used elsewhere
// (emerald-500, amber-500, sky-500, zinc-500).
const STATE_COLORS: Record<string, { fill: string; glow: string }> = {
working: { fill: '#10b981', glow: '#34d399' },
blocked: { fill: '#f59e0b', glow: '#fbbf24' },
@ -32,114 +24,45 @@ const STATE_COLORS: Record<string, { fill: string; glow: string }> = {
idle: { fill: '#71717a', glow: '#a1a1aa' }
}
export function stateColor(state: string) {
export function stateColor(state: string): { fill: string; glow: string } {
return STATE_COLORS[state] ?? STATE_COLORS.idle
}
// ─── Agent Type Initials ─────────────────────────────────────────────────────
// Why: visible initials inside small agent circles provide quick identification
// of which agent type is running, fulfilling the "visible icons" requirement.
const AGENT_INITIALS: Record<string, string> = {
claude: 'C',
codex: 'X',
gemini: 'G',
opencode: 'O',
aider: 'A',
unknown: '?'
}
function agentInitial(type: string): string {
return AGENT_INITIALS[type] ?? (type.charAt(0).toUpperCase() || '?')
}
// ─── Layout Helpers ──────────────────────────────────────────────────────────
/** Compute worktree circle radius that avoids overlap on the orbit ring. */
function computeWorktreeRadius(count: number): number {
if (count <= 1) {
return BASE_WT_R
}
// Why: the max radius is derived from the chord distance between adjacent
// worktrees on the orbit. This prevents circles from overlapping as the
// count increases.
const maxR = ORBIT_R * Math.sin(Math.PI / count) - 2
return Math.max(12, Math.min(BASE_WT_R, Math.floor(maxR)))
}
function computeAgentRadius(wtR: number): number {
return Math.max(4, Math.min(BASE_AGENT_R, Math.round(wtR * 0.33)))
}
function worktreeAngle(index: number, total: number): number {
if (total === 1) {
return -Math.PI / 2
}
return -Math.PI / 2 + (2 * Math.PI * index) / total
}
function worktreePosition(angle: number): [number, number] {
return [CX + ORBIT_R * Math.cos(angle), CY + ORBIT_R * Math.sin(angle)]
}
/** Position agents in a small orbit within their worktree circle.
* 1 agent: centered. 2+: arranged on a mini orbit ring, creating the
* "concentric circles within circles" visual hierarchy. */
function agentPositions(count: number, cx: number, cy: number, wtR: number): [number, number][] {
if (count === 0) {
return []
}
if (count === 1) {
return [[cx, cy]]
}
const agentOrbitR = wtR * 0.55
return Array.from({ length: count }, (_, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i) / count
return [cx + agentOrbitR * Math.cos(angle), cy + agentOrbitR * Math.sin(angle)]
})
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s
}
// ─── SVG Animations ──────────────────────────────────────────────────────────
// Why: defined as a constant string so all repo SVGs share the same keyframes.
// The `cv-` prefix prevents collisions with other page styles since inline SVG
// <style> tags leak into the global CSS scope.
const SVG_STYLES = `
@keyframes cv-breathe {
0%, 100% { stroke-opacity: 0.12; }
50% { stroke-opacity: 0.45; }
// Why: state-to-badge mapping matches the dashboard mock — RUNNING, PAUSED,
// DONE, IDLE labels sit in the top-right of every agent tile. `waiting` is
// folded into PAUSED to match the blocked/paused bucket used elsewhere.
function agentStateBadge(state: string): string {
switch (state) {
case 'working':
return 'RUNNING'
case 'blocked':
case 'waiting':
return 'PAUSED'
case 'done':
return 'DONE'
case 'idle':
return 'IDLE'
default:
return state.toUpperCase()
}
.cv-breathe { animation: cv-breathe 3s ease-in-out infinite; }
@keyframes cv-orbit-spin {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -16; }
}
.cv-orbit-spin { animation: cv-orbit-spin 30s linear infinite; }
@keyframes cv-pulse-ring {
0%, 100% { stroke-opacity: 0.15; }
50% { stroke-opacity: 0.55; }
}
.cv-pulse-ring { animation: cv-pulse-ring 2s ease-in-out infinite; }
.cv-wt { cursor: pointer; }
.cv-wt > .cv-wt-bg {
transition: stroke-opacity 200ms ease, fill-opacity 200ms ease;
}
.cv-wt:hover > .cv-wt-bg {
stroke-opacity: 0.75;
fill-opacity: 0.28;
}
`
}
// ─── RepoSystem Component ────────────────────────────────────────────────────
// Why: memoized so tooltip state changes in the parent don't re-render SVGs.
// Why: renders each repo as a card containing a grid of rounded-square
// worktree tiles. Worktrees are sorted by `latestActivityAt` descending so the
// most recently active tile sits at the top-left. Each tile shows the dominant
// state via a left color bar + pill, the worktree name + branch, and a compact
// list of agent rows. "Done" worktrees get an explicit dismiss (X) button so
// clicking the tile itself navigates (like any other state) while dismissal is
// opt-in.
export type RepoSystemProps = {
group: DashboardRepoGroup
onClick: (worktreeId: string) => void
onDismiss: (worktreeId: string) => void
onShowTooltip: (e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => void
onHideTooltip: () => void
}
@ -147,276 +70,262 @@ export type RepoSystemProps = {
const RepoSystem = React.memo(function RepoSystem({
group,
onClick,
onDismiss,
onShowTooltip,
onHideTooltip
}: RepoSystemProps) {
const activeWorktrees = group.worktrees.filter((wt) => wt.agents.length > 0)
const wtR = computeWorktreeRadius(activeWorktrees.length)
const agentR = computeAgentRadius(wtR)
const showInitials = agentR >= 6
// Why: sort most-recent-first so the tile the user most likely cares about
// sits at the top-left corner of the repo's grid. Worktrees with no activity
// timestamp (latestActivityAt === 0) fall to the end in their natural order.
const activeWorktrees = group.worktrees
.filter((wt) => wt.agents.length > 0)
.slice()
.sort((a, b) => b.latestActivityAt - a.latestActivityAt)
const totalAgents = activeWorktrees.reduce((s, w) => s + w.agents.length, 0)
return (
<div className="w-full max-w-[280px]">
<svg viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`} className="w-full" style={{ aspectRatio: '1' }}>
<style>{SVG_STYLES}</style>
<defs>
<filter id={`cv-glow-${group.repo.id}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id={`cv-bg-${group.repo.id}`} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={group.repo.badgeColor} stopOpacity="0.08" />
<stop offset="65%" stopColor={group.repo.badgeColor} stopOpacity="0.025" />
<stop offset="100%" stopColor={group.repo.badgeColor} stopOpacity="0" />
</radialGradient>
</defs>
{/* Background radial gradient — gives each repo a subtle ambient glow */}
<circle cx={CX} cy={CY} r={REPO_RING_R + 8} fill={`url(#cv-bg-${group.repo.id})`} />
{/* Outermost ring: repo boundary */}
<circle
cx={CX}
cy={CY}
r={REPO_RING_R}
fill="none"
stroke={group.repo.badgeColor}
strokeWidth="1.5"
strokeOpacity="0.35"
<div className="w-full max-w-[560px] rounded-lg border-2 border-border bg-accent/10 p-2.5">
{/* Repo header */}
<div className="mb-2 flex items-center gap-1.5 px-0.5">
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: group.repo.badgeColor }}
/>
{/* Middle ring: worktree orbit (animated dashed ring) */}
<circle
cx={CX}
cy={CY}
r={ORBIT_R}
fill="none"
stroke="currentColor"
strokeWidth="0.5"
strokeOpacity="0.08"
strokeDasharray="3 5"
className="cv-orbit-spin"
/>
{/* Inner decorative ring */}
<circle
cx={CX}
cy={CY}
r={INNER_DECOR_R}
fill="none"
stroke="currentColor"
strokeWidth="0.5"
strokeOpacity="0.06"
/>
{/* Radial spokes connecting center to each worktree */}
{activeWorktrees.map((_, i) => {
const angle = worktreeAngle(i, activeWorktrees.length)
const [wx, wy] = worktreePosition(angle)
return (
<line
key={`spoke-${i}`}
x1={CX}
y1={CY}
x2={wx}
y2={wy}
stroke={group.repo.badgeColor}
strokeWidth="0.5"
strokeOpacity="0.07"
/>
)
})}
{/* Center: repo name + stats */}
<text
x={CX}
y={CY - 4}
textAnchor="middle"
className="fill-foreground"
style={{ fontSize: 12, fontWeight: 600, letterSpacing: '0.01em' }}
>
{truncate(group.repo.displayName, 16)}
</text>
<text
x={CX}
y={CY + 10}
textAnchor="middle"
className="fill-muted-foreground"
style={{ fontSize: 8.5 }}
>
{activeWorktrees.length} worktree{activeWorktrees.length !== 1 ? 's' : ''}
{' \u00B7 '}
{totalAgents} agent{totalAgents !== 1 ? 's' : ''}
</text>
{/* Attention badge at center (if any agents need attention) */}
<span className="truncate text-[12px] font-semibold text-foreground">
{truncate(group.repo.displayName, 28)}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
{activeWorktrees.length} wt · {totalAgents} agent{totalAgents !== 1 ? 's' : ''}
</span>
{group.attentionCount > 0 && (
<>
<circle cx={CX + 30} cy={CY - 14} r={8} fill="#f59e0b" fillOpacity="0.2" />
<text
x={CX + 30}
y={CY - 10.5}
textAnchor="middle"
fill="#f59e0b"
style={{ fontSize: 9, fontWeight: 700 }}
>
{group.attentionCount}
</text>
</>
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-[9.5px] font-semibold text-amber-500">
{group.attentionCount}
</span>
)}
</div>
{/* ── Worktree nodes on the orbit ring ── */}
{activeWorktrees.map((card, i) => {
const angle = worktreeAngle(i, activeWorktrees.length)
const [wx, wy] = worktreePosition(angle)
const sc = stateColor(card.dominantState)
const aPos = agentPositions(card.agents.length, wx, wy, wtR)
const branchName = card.worktree.branch?.replace(/^refs\/heads\//, '')
return (
<g
{activeWorktrees.length === 0 ? (
<div className="px-1 py-4 text-center text-[10px] italic text-muted-foreground/40">
No active agents
</div>
) : (
// Why: each worktree gets its own outlined group so the visual
// hierarchy reads repo → worktree → agents. Agents inside a worktree
// are still laid out as fixed-size square tiles.
<div className="flex flex-col gap-2">
{activeWorktrees.map((card) => (
<WorktreeGroup
key={card.worktree.id}
className="cv-wt"
onClick={() => onClick(card.worktree.id)}
onMouseLeave={onHideTooltip}
>
{/* Worktree circle background */}
<circle
className="cv-wt-bg"
cx={wx}
cy={wy}
r={wtR}
fill={sc.fill}
fillOpacity="0.14"
stroke={sc.fill}
strokeWidth="1.5"
strokeOpacity="0.45"
/>
card={card}
onClick={onClick}
onDismiss={onDismiss}
onShowTooltip={onShowTooltip}
onHideTooltip={onHideTooltip}
/>
))}
</div>
)}
</div>
)
})
{/* Glow ring: working state (gentle breathing animation) */}
{card.dominantState === 'working' && (
<circle
cx={wx}
cy={wy}
r={wtR + 4}
fill="none"
stroke={sc.glow}
strokeWidth="1"
className="cv-breathe"
filter={`url(#cv-glow-${group.repo.id})`}
/>
)}
// ─── WorktreeGroup ───────────────────────────────────────────────────────────
// Why: outlines a single worktree's agents so the repo → worktree → agent
// hierarchy is visually clear. Shows the worktree name/branch in a small
// header above its agent tiles.
type WorktreeGroupProps = {
card: DashboardWorktreeCard
onClick: (worktreeId: string) => void
onDismiss: (worktreeId: string) => void
onShowTooltip: (e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => void
onHideTooltip: () => void
}
{/* Pulse ring: blocked state (attention-drawing pulse) */}
{card.dominantState === 'blocked' && (
<circle
cx={wx}
cy={wy}
r={wtR + 5}
fill="none"
stroke={sc.glow}
strokeWidth="1.5"
className="cv-pulse-ring"
/>
)}
const WorktreeGroup = React.memo(function WorktreeGroup({
card,
onClick,
onDismiss,
onShowTooltip,
onHideTooltip
}: WorktreeGroupProps) {
return (
<div className="rounded-md border-2 border-border/80 bg-background/20 p-1.5">
<div className="mb-1.5 flex items-center gap-1.5 px-0.5">
<span className="truncate text-[11px] font-medium text-muted-foreground">
{truncate(card.worktree.displayName, 28)}
</span>
<span className="ml-auto shrink-0 text-[9.5px] text-muted-foreground/50">
{card.agents.length} agent{card.agents.length !== 1 ? 's' : ''}
</span>
</div>
<div
className="grid gap-1.5"
// Why: fixed-width columns (not 1fr) so each agent tile renders at a
// constant size regardless of container width or how many tiles are
// in the row. aspect-square on the tile then gives a constant height.
style={{ gridTemplateColumns: 'repeat(auto-fill, 120px)' }}
>
{card.agents.map((agent) => (
<AgentSquareTile
key={agent.paneKey}
agent={agent}
card={card}
onClick={onClick}
onDismiss={onDismiss}
onShowTooltip={onShowTooltip}
onHideTooltip={onHideTooltip}
/>
))}
</div>
</div>
)
})
{/* Agent orbit ring (visible when 2+ agents creates the nested
concentric pattern within each worktree) */}
{card.agents.length >= 2 && (
<circle
cx={wx}
cy={wy}
r={wtR * 0.55}
fill="none"
stroke="currentColor"
strokeWidth="0.4"
strokeOpacity="0.12"
strokeDasharray="1.5 2.5"
/>
)}
// ─── AgentSquareTile ─────────────────────────────────────────────────────────
// Why: a single agent's square card — matches the dashboard mock. The tile is
// aspect-square and shows a state badge top-right (RUNNING / PAUSED / DONE /
// IDLE), the agent name prominently, and the parent worktree's name/path
// underneath. Hover surfaces the full tooltip via the same mechanism the old
// row-based layout used.
type AgentSquareTileProps = {
agent: DashboardAgentRow
card: DashboardWorktreeCard
onClick: (worktreeId: string) => void
onDismiss: (worktreeId: string) => void
onShowTooltip: (e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => void
onHideTooltip: () => void
}
{/* Agent icons — each has its own hover for per-agent tooltip */}
{card.agents.map((agent, j) => {
const [ax, ay] = aPos[j]
const ac = stateColor(agent.state)
return (
<g
key={agent.paneKey}
onMouseMove={(e) => {
// Why: stopPropagation prevents the worktree-level
// onMouseLeave from firing when moving between agents
// within the same worktree circle.
e.stopPropagation()
onShowTooltip(e, {
agentLabel: formatAgentTypeLabel(agent.agentType),
state: agent.state,
worktreeName: card.worktree.displayName,
branchName
})
}}
onMouseLeave={onHideTooltip}
>
{/* Invisible larger hit area for easier hovering */}
<circle cx={ax} cy={ay} r={Math.max(agentR + 4, 12)} fill="transparent" />
<circle
cx={ax}
cy={ay}
r={agentR}
fill={ac.fill}
fillOpacity="0.9"
stroke={ac.glow}
strokeWidth="0.5"
strokeOpacity="0.4"
/>
{showInitials && (
<text
x={ax}
y={ay + Math.round(agentR * 0.4)}
textAnchor="middle"
fill="white"
style={{
fontSize: Math.max(7, Math.round(agentR * 1.15)),
fontWeight: 700
}}
>
{agentInitial(agent.agentType)}
</text>
)}
</g>
)
})}
const AgentSquareTile = React.memo(function AgentSquareTile({
agent,
card,
onClick,
onDismiss
}: AgentSquareTileProps) {
const sc = stateColor(agent.state)
const isWorking = agent.state === 'working'
const isBlocked = agent.state === 'blocked' || agent.state === 'waiting'
const isDone = agent.state === 'done'
const agentLabel = formatAgentTypeLabel(agent.agentType)
// Why: show the submitted prompt — the user's question/task — inside the
// tile. statusText (e.g. "Turn complete") is transient agent chatter and
// not what the user wants to see at a glance; fall back to it only if no
// prompt was captured.
const promptText = (agent.promptText || agent.statusText || '').trim()
{/* Worktree label below the circle */}
<text
x={wx}
y={wy + wtR + 12}
textAnchor="middle"
className="fill-foreground/80"
style={{ fontSize: 8.5, fontWeight: 500 }}
>
{truncate(card.worktree.displayName, 14)}
</text>
</g>
)
})}
const history = agent.entry?.stateHistory ?? []
const blocks = [
...history.map((h) => ({
state: h.state,
title: `${h.state}${h.statusText ? `${h.statusText}` : ''}`
})),
{
state: agent.state,
title: `${agent.state}${agent.statusText ? `${agent.statusText}` : ''}`
}
]
{/* Empty state when no worktrees have active agents */}
{activeWorktrees.length === 0 && (
<text
x={CX}
y={CY + 26}
textAnchor="middle"
className="fill-muted-foreground/40"
style={{ fontSize: 9, fontStyle: 'italic' }}
// Why: fixed-width blocks (matching the old hover popover) so each turn is
// legibly sized and doesn't stretch with the tile. Cap count to fit within
// the tile width without wrapping.
const MAX_BLOCKS = 8
const visibleBlocks = blocks.slice(-MAX_BLOCKS)
return (
<div
className={cn(
'group relative flex aspect-square cursor-pointer flex-col overflow-hidden rounded-lg border-2 bg-background/40 p-1.5',
'transition-colors hover:bg-background/70'
)}
style={{ borderColor: `${sc.fill}88` }}
onClick={() => onClick(card.worktree.id)}
>
{/* Working/blocked glow overlay same breathing treatment the
worktree-level tile had, now applied per-agent. */}
{(isWorking || isBlocked) && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 animate-pulse rounded-lg"
style={{
boxShadow: `inset 0 0 14px ${sc.glow}40`,
animationDuration: isBlocked ? '1.5s' : '3s'
}}
/>
)}
{/* Top row: yellow bell (done) on the left, state badge + dismiss on the right. */}
<div className="relative flex items-start gap-1">
{isDone && <FilledBellIcon className="size-3.5 shrink-0 text-amber-500 drop-shadow-sm" />}
<span
className="ml-auto rounded px-1 py-[1px] text-[8px] font-semibold uppercase tracking-wider"
style={{ backgroundColor: `${sc.fill}22`, color: sc.fill }}
>
{agentStateBadge(agent.state)}
</span>
{isDone && (
<button
type="button"
className="flex size-3.5 items-center justify-center rounded text-muted-foreground/70 opacity-60 hover:bg-background/70 hover:text-foreground hover:opacity-100 group-hover:opacity-100"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
// Why: stopPropagation so the tile's own onClick (which
// navigates to the terminal) doesn't fire.
e.stopPropagation()
onDismiss(card.worktree.id)
}}
aria-label="Dismiss completed agent"
title="Remove from view"
>
No active agents
</text>
<X size={9} strokeWidth={2.5} />
</button>
)}
</svg>
</div>
{/* Body: agent icon then prompt stacked vertically at the top of the
tile. The icon replaces the textual agent name (claude/codex/)
with the same SVG/favicon we render in the workspace selector. */}
<div className="relative mt-1 min-w-0">
<span
className="flex size-4 shrink-0 items-center justify-center text-foreground"
title={agentLabel}
aria-label={agentLabel}
>
<AgentIcon agent={agent.agentType as TuiAgent} size={14} />
</span>
{/* Current prompt/status the action in this turn. Line-clamped to
2 rows so it never pushes the tile larger than aspect-square. */}
{promptText && (
<div
className="mt-1 overflow-hidden text-[11px] font-medium leading-snug text-foreground/90"
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2
}}
title={promptText}
>
{promptText}
</div>
)}
</div>
{/* Past-turn blocks pinned to the bottom of the tile so they form a
consistent baseline across agents regardless of prompt length. */}
{visibleBlocks.length > 0 && (
<div className="relative mt-auto flex flex-wrap items-center gap-[2px] pt-1">
{visibleBlocks.map((block, i) => {
const bc = stateColor(block.state)
return (
<span
key={`${i}-${block.state}`}
title={block.title}
className="h-1.5 w-3 rounded-sm"
style={{ backgroundColor: bc.fill, opacity: 0.8 }}
/>
)
})}
</div>
)}
</div>
)
})

View file

@ -1,6 +1,5 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { detectAgentStatusFromTitle, inferAgentTypeFromTitle } from '@/lib/agent-status'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { Repo, Worktree, TerminalTab } from '../../../../shared/types'
@ -12,7 +11,10 @@ export type DashboardAgentRow = {
tab: TerminalTab
agentType: AgentType
state: string
source: 'agent' | 'heuristic'
source: 'agent'
stateStartedAt: number | null
statusText: string
promptText: string
}
export type DashboardWorktreeCard = {
@ -70,40 +72,27 @@ function buildAgentRowsForWorktree(
const tabs = tabsByWorktree[worktreeId] ?? []
const rows: DashboardAgentRow[] = []
// Why: the dashboard is driven entirely by explicit hook-reported status.
// Title-heuristic fallbacks are deliberately omitted — braille spinners and
// task-description titles cannot reliably distinguish Claude from Codex,
// which led to Codex sessions being mislabeled as Claude. If hooks aren't
// installed or haven't fired yet, the agent simply doesn't appear.
for (const tab of tabs) {
// Find explicit status entries for panes within this tab
const explicitEntries = Object.values(agentStatusByPaneKey).filter((entry) =>
entry.paneKey.startsWith(`${tab.id}:`)
)
if (explicitEntries.length > 0) {
// Why: an explicit agent status report is proof that a terminal is active,
// even if ptyId hasn't been set yet (e.g. the PTY was spawned but the tab
// metadata hasn't received the ptyId back from the main process yet).
for (const entry of explicitEntries) {
rows.push({
paneKey: entry.paneKey,
entry,
tab,
agentType: entry.agentType ?? inferAgentTypeFromTitle(entry.terminalTitle ?? tab.title),
state: entry.state,
source: 'agent'
})
}
} else if (tab.ptyId) {
// Heuristic fallback from terminal title — only for tabs with a known PTY
const heuristicStatus = detectAgentStatusFromTitle(tab.title)
if (heuristicStatus) {
rows.push({
paneKey: `heuristic:${tab.id}`,
entry: null,
tab,
agentType: inferAgentTypeFromTitle(tab.title),
// Map heuristic 'permission' to 'blocked' for dashboard consistency
state: heuristicStatus === 'permission' ? 'blocked' : heuristicStatus,
source: 'heuristic'
})
}
for (const entry of explicitEntries) {
rows.push({
paneKey: entry.paneKey,
entry,
tab,
agentType: entry.agentType ?? 'unknown',
state: entry.state,
source: 'agent',
stateStartedAt: entry.stateStartedAt,
statusText: entry.statusText,
promptText: entry.promptText
})
}
}

View file

@ -47,13 +47,15 @@ export function useDashboardKeyboard({
}: 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 is now embedded in multiple places (sidebar bottom
// panel, detached window). Only steal keys when focus is already inside
// a dashboard worktree card, so arrow-key filtering in the file
// explorer (and other panels) is not interrupted.
const activeEl = document.activeElement as HTMLElement | null
if (!activeEl?.closest('[data-worktree-id]')) {
return
}
@ -73,7 +75,7 @@ export function useDashboardKeyboard({
return
}
// Filter quick-select: 1-4 keys
// Filter quick-select: 1-5 keys
if (FILTER_KEYS[e.key]) {
e.preventDefault()
setFilter(FILTER_KEYS[e.key])
@ -123,8 +125,6 @@ export function useDashboardKeyboard({
}
},
[
rightSidebarOpen,
rightSidebarTab,
filteredGroups,
collapsedRepos,
focusedWorktreeId,

View file

@ -42,37 +42,10 @@ export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): {
}
}
// 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
}
}
if (!current.has(paneKey) && !retainedRef.current.has(paneKey) && prev.row.state !== 'idle') {
disappeared.push({ ...prev.row, worktreeId: prev.worktreeId })
}
}
@ -94,17 +67,6 @@ export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): {
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
}
}
}

View file

@ -0,0 +1,193 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, PanelTopOpen } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '../../store'
import { useShallow } from 'zustand/react/shallow'
import { countWorkingAgents } from '../../lib/agent-status'
import AgentDashboard from '../dashboard/AgentDashboard'
const MIN_HEIGHT = 120
const DEFAULT_HEIGHT = 240
const HEADER_HEIGHT = 30
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
// AgentDashboard, independent of which activity tab the user has open. Users
// drag the top edge to resize upward (within the available sidebar height)
// and can fully collapse to a single row. The pop-out button reuses the
// existing detached-window flow from window.api.ui.openAgentDashboard().
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)
const agentInputs = useAppStore(
useShallow((s) => ({
tabsByWorktree: s.tabsByWorktree,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
worktreesByRepo: s.worktreesByRepo
}))
)
const activeAgentCount = useMemo(() => countWorkingAgents(agentInputs), [agentInputs])
// Why: persist height + collapsed via localStorage (renderer-only) so the
// layout survives reloads without threading through the main-process UI
// store. Debounce writes so continuous drag doesn't spam localStorage.
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: the panel's parent sidebar column is the constraint. Cap
// expansion so the dashboard can't push the activity bar or panel
// content to a zero-height strip. Leave 120px for the active panel.
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 — user must expand first. */}
{!collapsed ? (
<div
className="absolute left-0 right-0 h-[6px] -mt-[3px] cursor-row-resize z-10 hover:bg-ring/20 active:bg-ring/30 transition-colors"
onMouseDown={onResizeStart}
aria-label="Resize dashboard panel"
/>
) : null}
{/* Header: title + controls */}
<div
className="flex shrink-0 items-center px-2 gap-1 cursor-pointer select-none"
style={{ height: HEADER_HEIGHT }}
onClick={() => setCollapsed((prev) => !prev)}
>
<button
type="button"
className="flex items-center justify-center w-5 h-5 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>
<span className="text-[10px] text-muted-foreground/70 tabular-nums ml-1">
{activeAgentCount}
</span>
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center justify-center w-5 h-5 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
onClick={(event) => {
event.stopPropagation()
void window.api.ui.openAgentDashboard()
}}
aria-label="Open dashboard in new window"
>
<PanelTopOpen size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Open in new window
</TooltipContent>
</Tooltip>
</div>
{/* Body: full AgentDashboard */}
{!collapsed ? (
<div className="flex-1 min-h-0 overflow-hidden">
<AgentDashboard />
</div>
) : null}
</div>
)
}

View file

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Files, Search, GitBranch, ListChecks, LayoutDashboard, PanelRight } from 'lucide-react'
import { Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { useSidebarResize } from '@/hooks/useSidebarResize'
@ -19,7 +19,7 @@ import FileExplorer from './FileExplorer'
import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
import AgentDashboard from '../dashboard/AgentDashboard'
import DashboardBottomPanel from './DashboardBottomPanel'
const MIN_WIDTH = 220
// Why: long file names (e.g. construction drawing sheets, multi-part document
@ -112,12 +112,6 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
title: 'Checks',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`,
gitOnly: true
},
{
id: 'dashboard',
icon: LayoutDashboard,
title: 'Agent Dashboard',
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}D`
}
]
@ -174,7 +168,6 @@ function RightSidebarInner(): React.JSX.Element {
{effectiveTab === 'search' && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
{effectiveTab === 'dashboard' && <AgentDashboard />}
</div>
)
@ -255,6 +248,13 @@ function RightSidebarInner(): React.JSX.Element {
{panelContent}
{/* Why: persistent bottom-docked dashboard section. Lives below
whichever activity panel is active so users can glance at agent
status without losing their current sidebar tab. */}
<TooltipProvider delayDuration={400}>
<DashboardBottomPanel />
</TooltipProvider>
{/* Resize handle on LEFT side */}
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"

View file

@ -1,8 +1,10 @@
/* eslint-disable max-lines */
import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, Download, FolderOpen, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../../../../shared/agent-hook-types'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import {
Dialog,
@ -17,6 +19,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
type CliSectionProps = {
currentPlatform: string
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
const ORCA_CLI_SKILL_INSTALL_COMMAND =
@ -45,7 +49,11 @@ function getInstallDescription(platform: string): string {
return 'CLI registration is not yet available on this platform.'
}
export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Element {
export function CliSection({
currentPlatform,
settings,
updateSettings
}: CliSectionProps): React.JSX.Element {
const [status, setStatus] = useState<CliInstallStatus | null>(null)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@ -89,6 +97,19 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
}
}
const handleAutoInstallHooksChange = async (enabled: boolean): Promise<void> => {
try {
await updateSettings({ autoInstallAgentHooks: enabled })
toast.success(
enabled
? 'Native hook management enabled. Orca will keep hooks installed on launch.'
: 'Native hook management disabled. Orca will no longer rewrite hook config on launch.'
)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update hook setting.')
}
}
const refreshStatus = async (): Promise<void> => {
setLoading(true)
try {
@ -294,9 +315,34 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
</Button>
</div>
<p className="text-[11px] text-muted-foreground/70">
Orca installs Claude and Codex global hooks so agent lifecycle updates flow into the
sidebar automatically.
Opt in if you want Orca to manage Claude and Codex global hooks for you. This
updates the user-wide hook config in <code>~/.claude</code> and{' '}
<code>~/.codex</code>.
</p>
<div className="mt-2 flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-background/40 px-3 py-2">
<div className="space-y-0.5">
<p className="text-xs font-medium">Keep hooks installed</p>
<p className="text-[11px] text-muted-foreground">
When enabled, Orca reconciles its managed hooks on startup. Disabled by default
because this mutates global agent config.
</p>
</div>
<button
role="switch"
aria-checked={settings.autoInstallAgentHooks}
onClick={() => void handleAutoInstallHooksChange(!settings.autoInstallAgentHooks)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
settings.autoInstallAgentHooks ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${busyHook !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
disabled={busyHook !== null}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.autoInstallAgentHooks ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="mt-2 space-y-2">
{(
[
@ -304,7 +350,8 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
['codex', hookStatuses.codex]
] as const
).map(([agent, status]) => {
const installed = status?.managedHooksPresent === true
const installed = status?.state === 'installed'
const needsRepair = status?.state === 'partial'
return (
<div
key={agent}
@ -332,16 +379,27 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
Installed
</Button>
) : (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={busyHook !== null || hookStatuses.loading}
onClick={() => void handleInstallHook(agent)}
>
<Download className="size-3.5" />
{busyHook === agent ? 'Installing…' : 'Install'}
</Button>
<div className="flex items-center gap-2">
{needsRepair && (
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">
Partial
</span>
)}
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={busyHook !== null || hookStatuses.loading}
onClick={() => void handleInstallHook(agent)}
>
<Download className="size-3.5" />
{busyHook === agent
? 'Installing…'
: needsRepair
? 'Repair'
: 'Install'}
</Button>
</div>
)}
</div>
)

View file

@ -438,6 +438,8 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
<CliSection
key="cli"
currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'}
settings={settings}
updateSettings={updateSettings}
/>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_CACHE_TIMER_SEARCH_ENTRIES) ? (

View file

@ -21,8 +21,9 @@ 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 ?? '',
statusText: overrides.statusText ?? '',
promptText: overrides.promptText ?? '',
stateStartedAt: overrides.stateStartedAt ?? NOW - 30_000,
updatedAt: overrides.updatedAt ?? NOW - 30_000,
source: overrides.source ?? 'agent',
agentType: overrides.agentType ?? 'codex',
@ -37,11 +38,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', statusText: 'Fix login bug' }),
'tab-1:2': makeEntry({
paneKey: 'tab-1:2',
state: 'blocked',
summary: 'Waiting on failing test'
statusText: 'Waiting on failing test'
})
},
NOW
@ -58,7 +59,7 @@ describe('buildAgentStatusHoverRows', () => {
expect(rows[0]?.kind).toBe('heuristic')
})
it('keeps stale explicit summaries but orders by heuristic urgency', () => {
it('keeps stale explicit status text but orders by heuristic urgency', () => {
const rows = buildAgentStatusHoverRows(
[
makeTab({ id: 'tab-a', title: 'codex permission needed' }),

View file

@ -1,8 +1,9 @@
import React, { useMemo } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { useAppStore } from '@/store'
import {
detectAgentStatusFromTitle,
formatElapsedAgentTime,
formatAgentTypeLabel,
inferAgentTypeFromTitle,
isExplicitAgentStatusFresh
@ -241,24 +242,27 @@ function AgentRow({ row, now }: { row: HoverRow; now: number }): React.JSX.Eleme
{formatAgentTypeLabel(row.agentType)}
</span>
<span className="ml-auto text-[10px] text-muted-foreground/50">
{formatTimeAgo(row.explicit.updatedAt, now)}
{formatElapsedAgentTime(row.explicit.stateStartedAt, now)}
</span>
</div>
{row.explicit.summary && (
{row.explicit.statusText && (
<div className={cn('pl-4 text-[11px] leading-snug', !isFresh && 'opacity-60')}>
{row.explicit.summary}
{row.explicit.statusText}
</div>
)}
{row.explicit.next && (
{row.explicit.promptText && (
<div
className={cn(
'pl-4 text-[10.5px] leading-snug text-muted-foreground',
!isFresh && 'opacity-60'
)}
>
Next: {row.explicit.next}
Prompt: {row.explicit.promptText}
</div>
)}
<div className="pl-4 text-[10px] text-muted-foreground/50">
Updated {formatTimeAgo(row.explicit.updatedAt, now)}
</div>
{!isFresh && (
<div className="pl-4 text-[10px] italic text-muted-foreground/60">
Showing last reported task details; live terminal state has taken precedence.
@ -315,12 +319,17 @@ const AgentStatusHover = React.memo(function AgentStatusHover({
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setNow(Date.now())
}, [agentStatusByPaneKey, agentStatusEpoch, tabs])
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(interval)
}, [])
// Why: timestamps in the hover are relative labels, so recompute "now" when
// the source rows change or a stored freshness boundary expires, rather than
// on an interval that would churn the sidebar every minute.
// oxlint-disable-next-line react-hooks/exhaustive-deps
const now = useMemo(() => Date.now(), [agentStatusByPaneKey, agentStatusEpoch, tabs])
const rows = useMemo(
() => buildAgentStatusHoverRows(tabs, agentStatusByPaneKey, now),
[tabs, agentStatusByPaneKey, now]

View file

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

View file

@ -186,6 +186,9 @@ describe('useIpcEvents updater integration', () => {
credentialResolvedListenerRef.current = listener
return () => {}
}
},
agentStatus: {
onSet: () => () => {}
}
}
})

View file

@ -408,8 +408,8 @@ export function useIpcEvents(): void {
const payload = parseAgentStatusPayload(
JSON.stringify({
state: data.state,
summary: data.summary,
next: data.next,
statusText: data.statusText,
promptText: data.promptText,
agentType: data.agentType
})
)
@ -424,7 +424,8 @@ export function useIpcEvents(): void {
console.log('[agentStatus:set] Storing:', {
paneKey: data.paneKey,
state: payload.state,
summary: payload.summary,
statusText: payload.statusText,
promptText: payload.promptText,
currentTitle
})
store.setAgentStatus(data.paneKey, payload, currentTitle)

View file

@ -7,6 +7,7 @@ import {
detectAgentStatusFromTitle,
clearWorkingIndicators,
createAgentStatusTracker,
formatElapsedAgentTime,
getAgentLabel,
inferAgentTypeFromTitle,
isGeminiTerminalTitle,
@ -223,6 +224,20 @@ describe('normalizeTerminalTitle', () => {
})
})
describe('formatElapsedAgentTime', () => {
it('formats seconds for short durations', () => {
expect(formatElapsedAgentTime(1_000, 16_000)).toBe('15s')
})
it('formats minutes and seconds under one hour', () => {
expect(formatElapsedAgentTime(0, 125_000)).toBe('2m 5s')
})
it('formats hours and minutes for long durations', () => {
expect(formatElapsedAgentTime(0, 3_780_000)).toBe('1h 3m')
})
})
describe('isGeminiTerminalTitle', () => {
it('detects Gemini titles by symbol or name', () => {
expect(isGeminiTerminalTitle('✦ Typing prompt... (workspace)')).toBe(true)

View file

@ -109,9 +109,12 @@ export function inferAgentTypeFromTitle(title: string | null | undefined): Agent
if (isGeminiTerminalTitle(title)) {
return 'gemini'
}
if (isClaudeAgent(title)) {
return 'claude'
}
// Why: Codex, OpenCode, and Aider titles can include braille-spinner
// prefixes during startup. `isClaudeAgent` treats any braille spinner as
// Claude, which caused a running Codex session to mis-report as Claude in
// the dashboard until its first explicit hook event arrived. Match the
// ordering used by `getAgentLabel` and prefer explicit agent-name hits
// over Claude's generic spinner heuristic.
if (includesAgentName(title, 'codex')) {
return 'codex'
}
@ -121,6 +124,9 @@ export function inferAgentTypeFromTitle(title: string | null | undefined): Agent
if (includesAgentName(title, 'aider')) {
return 'aider'
}
if (isClaudeAgent(title)) {
return 'claude'
}
return 'unknown'
}
@ -151,6 +157,23 @@ export function isExplicitAgentStatusFresh(
return now - entry.updatedAt <= staleAfterMs
}
export function formatElapsedAgentTime(startedAt: number, now: number): string {
const elapsedMs = Math.max(0, now - startedAt)
const totalSeconds = Math.floor(elapsedMs / 1000)
const seconds = totalSeconds % 60
const totalMinutes = Math.floor(totalSeconds / 60)
const minutes = totalMinutes % 60
const hours = Math.floor(totalMinutes / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
if (totalMinutes > 0) {
return `${totalMinutes}m ${seconds}s`
}
return `${seconds}s`
}
/**
* Map an explicit AgentStatusState to the visual Status used by
* StatusIndicator and WorktreeCard.

View file

@ -3,6 +3,7 @@ import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import DashboardApp from './DashboardApp'
if (import.meta.env.DEV) {
import('react-grab').then(({ init }) => init())
@ -18,8 +19,13 @@ function applySystemTheme(): void {
applySystemTheme()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applySystemTheme)
// Why: the detached agent dashboard window loads the same renderer bundle but
// with ?view=agent-dashboard in its URL. Mount the lean DashboardApp in that
// case so the second window does not try to claim PTYs or write workspace
// session — it just renders AgentDashboard over live IPC-pushed state.
const isDashboardView =
new URLSearchParams(window.location.search).get('view') === 'agent-dashboard'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
<StrictMode>{isDashboardView ? <DashboardApp /> : <App />}</StrictMode>
)

View file

@ -21,7 +21,11 @@ describe('agent status freshness expiry', () => {
const store = createTestStore()
store
.getState()
.setAgentStatus('tab-1:1', { state: 'working', summary: 'Fix tests', next: '' }, 'codex')
.setAgentStatus(
'tab-1:1',
{ state: 'working', statusText: 'Fix tests', promptText: '' },
'codex'
)
// setAgentStatus bumps epoch once synchronously
expect(store.getState().agentStatusEpoch).toBe(1)
@ -42,7 +46,11 @@ describe('agent status freshness expiry', () => {
const store = createTestStore()
store
.getState()
.setAgentStatus('tab-1:1', { state: 'working', summary: 'Fix tests', next: '' }, 'codex')
.setAgentStatus(
'tab-1:1',
{ state: 'working', statusText: 'Fix tests', promptText: '' },
'codex'
)
// set bumps to 1, remove bumps to 2
store.getState().removeAgentStatus('tab-1:1')
expect(store.getState().agentStatusEpoch).toBe(2)
@ -55,3 +63,76 @@ describe('agent status freshness expiry', () => {
expect(store.getState().agentStatusEpoch).toBe(2)
})
})
describe('agent status elapsed timing', () => {
afterEach(() => {
vi.useRealTimers()
})
it('preserves stateStartedAt across in-state updates', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z'))
const store = createTestStore()
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', statusText: 'Start fix', promptText: 'Fix login flow' },
'codex'
)
const startedAt = store.getState().agentStatusByPaneKey['tab-1:1']?.stateStartedAt
expect(startedAt).toBe(Date.now())
vi.advanceTimersByTime(15_000)
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', statusText: 'Still fixing', promptText: '' },
'codex'
)
const entry = store.getState().agentStatusByPaneKey['tab-1:1']
expect(entry?.stateStartedAt).toBe(startedAt)
expect(entry?.updatedAt).toBe(Date.now())
expect(entry?.promptText).toBe('Fix login flow')
})
it('records the previous state start time in history when the state changes', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z'))
const store = createTestStore()
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'working', statusText: 'Start fix', promptText: '' },
'codex'
)
const workingStartedAt = store.getState().agentStatusByPaneKey['tab-1:1']?.stateStartedAt
vi.advanceTimersByTime(15_000)
store
.getState()
.setAgentStatus(
'tab-1:1',
{ state: 'blocked', statusText: 'Need input', promptText: '' },
'codex'
)
const entry = store.getState().agentStatusByPaneKey['tab-1:1']
expect(entry?.stateStartedAt).toBe(Date.now())
expect(entry?.stateHistory).toEqual([
{
state: 'working',
statusText: 'Start fix',
promptText: '',
startedAt: workingStartedAt
}
])
})
})

View file

@ -78,18 +78,27 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
setAgentStatus: (paneKey, payload, terminalTitle) => {
set((s) => {
const now = Date.now()
const existing = s.agentStatusByPaneKey[paneKey]
const effectiveTitle = terminalTitle ?? existing?.terminalTitle
// 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 detail-
// 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 }
// Why: history should capture how long the prior state actually
// lasted. Using updatedAt here would reset durations whenever an
// agent posts a progress-only status text update without changing state.
{
state: existing.state,
statusText: existing.statusText,
promptText: existing.promptText,
startedAt: existing.stateStartedAt
}
]
if (history.length > AGENT_STATE_HISTORY_MAX) {
history = history.slice(history.length - AGENT_STATE_HISTORY_MAX)
@ -98,9 +107,18 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const entry: AgentStatusEntry = {
state: payload.state,
summary: payload.summary,
next: payload.next,
updatedAt: Date.now(),
statusText: payload.statusText,
// Why: only UserPromptSubmit-style hooks carry the raw prompt text.
// Later events in the same turn (permission, stop) must retain the
// last submitted prompt so the dashboard still shows what this turn
// is about instead of blanking the prompt after the first transition.
promptText: payload.promptText || existing?.promptText || '',
// Why: keep the current state's original start time until the agent
// transitions to a new state. Otherwise the elapsed timer would jump
// backward on every in-state status-text refresh.
stateStartedAt:
existing && existing.state === payload.state ? existing.stateStartedAt : now,
updatedAt: 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

View file

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

View file

@ -150,6 +150,12 @@ export type TerminalSlice = {
* already have a timer. Called when the feature is enabled mid-session. */
seedCacheTimersForIdleTabs: () => void
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
/** Dashboard-only hydrate: mirrors tabs from the session without nulling
* ptyId or resetting agent-like titles. The detached dashboard window is a
* read-only observer it never reconnects PTYs, so stripping that state
* (as hydrateWorkspaceSession does for cold boot) would suppress heuristic
* agent detection in useDashboardData on every session:updated broadcast. */
hydrateObserverSession: (session: WorkspaceSessionState) => void
reconnectPersistedTerminals: (signal?: AbortSignal) => Promise<void>
}
@ -1143,6 +1149,69 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
hydrateObserverSession: (session) => {
set((s) => {
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
// Why: preserve ptyId and title as written by the main window. The
// dashboard never spawns or reconnects PTYs; its only job is to display
// what the main window persists. Clearing ptyId here would suppress the
// heuristic agent-detection path in useDashboardData on every
// session:updated broadcast (~150ms while typing), causing statuses to
// flicker or disappear.
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
Object.entries(session.tabsByWorktree)
.filter(([worktreeId]) => validWorktreeIds.has(worktreeId))
.map(([worktreeId, tabs]) => [
worktreeId,
[...tabs]
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
.map((tab, index) => ({ ...tab, sortOrder: index }))
])
.filter(([, tabs]) => tabs.length > 0)
)
const validTabIds = new Set(
Object.values(tabsByWorktree)
.flat()
.map((tab) => tab.id)
)
const activeWorktreeId =
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
? session.activeWorktreeId
: null
const activeTabId =
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
const activeRepoId =
session.activeRepoId && s.repos.some((repo) => repo.id === session.activeRepoId)
? session.activeRepoId
: null
const activeTabIdByWorktree: Record<string, string | null> = {}
if (session.activeTabIdByWorktree) {
for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) {
if (validWorktreeIds.has(wId) && tabId && validTabIds.has(tabId)) {
activeTabIdByWorktree[wId] = tabId
}
}
}
return {
activeRepoId,
activeWorktreeId,
activeTabId,
activeTabIdByWorktree,
tabsByWorktree,
terminalLayoutsByTabId: Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => validTabIds.has(tabId))
)
}
})
},
reconnectPersistedTerminals: async (_signal) => {
const {
pendingReconnectWorktreeIds,

View file

@ -4,12 +4,12 @@ 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","statusText":"Investigating test failures","promptText":"Fix the auth flow","agentType":"codex"}'
)
expect(result).toEqual({
state: 'working',
summary: 'Investigating test failures',
next: 'Fix the flaky assertion',
statusText: 'Investigating test failures',
promptText: 'Fix the auth flow',
agentType: 'codex'
})
})
@ -47,52 +47,52 @@ describe('parseAgentStatusPayload', () => {
expect(parseAgentStatusPayload('[]')).toBeNull()
})
it('normalizes multiline summary to single line', () => {
it('normalizes multiline statusText to single line', () => {
const result = parseAgentStatusPayload(
'{"state":"working","summary":"line one\\nline two\\nline three"}'
'{"state":"working","statusText":"line one\\nline two\\nline three"}'
)
expect(result!.summary).toBe('line one line two line three')
expect(result!.statusText).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","statusText":"line one\\r\\nline two\\r\\nline three"}'
)
expect(result!.summary).toBe('line one line two line three')
expect(result!.statusText).toBe('line one line two line three')
})
it('trims whitespace from fields', () => {
const result = parseAgentStatusPayload(
'{"state":"working","summary":" padded ","next":" also padded "}'
'{"state":"working","statusText":" padded ","promptText":" prompt "}'
)
expect(result!.summary).toBe('padded')
expect(result!.next).toBe('also padded')
expect(result!.statusText).toBe('padded')
expect(result!.promptText).toBe('prompt')
})
it('truncates fields 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","statusText":"${longString}"}`)
expect(result!.statusText).toHaveLength(AGENT_STATUS_MAX_FIELD_LENGTH)
})
it('defaults missing summary and next to empty string', () => {
it('defaults missing statusText and promptText to empty string', () => {
const result = parseAgentStatusPayload('{"state":"done"}')
expect(result!.summary).toBe('')
expect(result!.next).toBe('')
expect(result!.statusText).toBe('')
expect(result!.promptText).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 statusText/promptText gracefully', () => {
const result = parseAgentStatusPayload('{"state":"working","statusText":42,"promptText":true}')
expect(result!.statusText).toBe('')
expect(result!.promptText).toBe('')
})
it('accepts custom non-empty agentType values', () => {
const result = parseAgentStatusPayload('{"state":"working","agentType":"cursor"}')
expect(result).toEqual({
state: 'working',
summary: '',
next: '',
statusText: '',
promptText: '',
agentType: 'cursor'
})
})

View file

@ -14,7 +14,8 @@ export type AgentType = WellKnownAgentType | (string & {})
/** A snapshot of a previous agent state, used to render activity blocks. */
export type AgentStateHistoryEntry = {
state: AgentStatusState
summary: string
statusText: string
promptText: string
/** When this state was first reported. */
startedAt: number
}
@ -24,10 +25,12 @@ 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
/** Short agent-reported label for the current state. */
statusText: string
/** Latest submitted user prompt for the current turn, when the hook provides it. */
promptText: string
/** When the current state began. Preserved across status-text-only updates. */
stateStartedAt: number
/** Timestamp (ms) of the last status update. */
updatedAt: number
/** Whether this entry was reported explicitly by the agent or inferred from heuristics. */
@ -48,28 +51,28 @@ export type AgentStatusEntry = {
export type AgentStatusPayload = {
state: AgentStatusState
summary?: string
next?: string
statusText?: string
promptText?: 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`: statusText 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
statusText: string
promptText: string
agentType?: AgentType
}
/** Maximum character length for summary and next fields. Truncated on parse. */
/** Maximum character length for prompt/status text fields. 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 status text, 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 +115,8 @@ export function parseAgentStatusPayload(json: string): ParsedAgentStatusPayload
}
return {
state: state as AgentStatusState,
summary: normalizeField(parsed.summary),
next: normalizeField(parsed.next),
statusText: normalizeField(parsed.statusText),
promptText: normalizeField(parsed.promptText),
agentType:
typeof parsed.agentType === 'string' && parsed.agentType.trim().length > 0
? parsed.agentType.trim().slice(0, AGENT_TYPE_MAX_LENGTH)

View file

@ -123,7 +123,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
skipDeleteWorktreeConfirm: false,
defaultTaskViewPreset: 'all',
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'true'
terminalMacOptionAsAlt: 'true',
autoInstallAgentHooks: false
}
}

View file

@ -634,6 +634,10 @@ export type GlobalSettings = {
* 'true' = full Meta on both Option keys;
* 'left' / 'right' = only that Option key acts as Meta, the other composes. */
terminalMacOptionAsAlt: 'true' | 'false' | 'left' | 'right'
/** Why: installing Claude/Codex hooks mutates user-global config files in
* ~/.claude and ~/.codex, so Orca must never do it implicitly. This flag is
* an explicit user opt-in that gates startup reconciliation. */
autoInstallAgentHooks: boolean
}
export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test'
@ -698,6 +702,9 @@ export type PersistedUIState = {
windowBounds?: { x: number; y: number; width: number; height: number } | null
/** Whether the window was maximized when it was last closed. */
windowMaximized?: boolean
/** Saved bounds for the detached agent dashboard window so it restores to
* the user's last size/position when reopened. */
agentDashboardWindowBounds?: { x: number; y: number; width: number; height: number } | null
/** One-shot migration flag: 'recent' used to mean the weighted smart sort
* (v1v2 rename). When this flag is absent and sortBy is 'recent', the
* main-process load() migrates it to 'smart' and sets this flag so the