diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b799c980..3a76519a 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -12,6 +12,21 @@ type AgentHookEventPayload = { payload: ParsedAgentStatusPayload } +function extractPromptText(hookPayload: Record): 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 { return new Promise((resolve, reject) => { let body = '' @@ -33,7 +48,17 @@ function readJsonBody(req: IncomingMessage): Promise { }) } -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).hook_event_name + const promptText = extractPromptText(hookPayload as Record) 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() }) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index fff151e1..56224a53 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -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 } } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 780be5bb..4dedf3ce 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index 6f3c4466..8e945853 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) + } } } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 13832e15..7bf740f1 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 7fc13b4c..50b75f1c 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7d81c971..62d64c7d 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -73,7 +73,7 @@ export function registerCoreHandlers( browserSessionRegistry.restorePersistedUserAgent() registerShellHandlers() registerSessionHandlers(store) - registerUIHandlers(store) + registerUIHandlers(store, mainWindowWebContentsId) registerFilesystemHandlers(store) registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index dc962596..a626fb09 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -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) }) } diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index 7e96be6c..f170e851 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -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) => { 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 + }) + } + ) } diff --git a/src/main/window/createAgentDashboardWindow.ts b/src/main/window/createAgentDashboardWindow.ts new file mode 100644 index 00000000..85aee574 --- /dev/null +++ b/src/main/window/createAgentDashboardWindow.ts @@ -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 | 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 +} diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index a2554a66..f666cad4 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -414,6 +414,7 @@ export type PreloadApi = { get: () => Promise set: (args: WorkspaceSessionState) => Promise setSync: (args: WorkspaceSessionState) => void + onUpdated: (callback: () => void) => () => void } updater: { getVersion: () => Promise @@ -575,6 +576,8 @@ export type PreloadApi = { onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void) => () => void confirmWindowClose: () => void + openAgentDashboard: () => Promise + requestActivateWorktree: (args: { repoId: string; worktreeId: string }) => Promise } runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index db9277f4..11ac8c50 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -202,8 +202,8 @@ type AgentStatusApi = { callback: (data: { paneKey: string state: string - summary?: string - next?: string + statusText?: string + promptText?: string agentType?: string }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index f6952340..57dac5d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => 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 => + 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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b72d8c81..decbbcc3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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) } } diff --git a/src/renderer/src/DashboardApp.tsx b/src/renderer/src/DashboardApp.tsx new file mode 100644 index 00000000..d92ad715 --- /dev/null +++ b/src/renderer/src/DashboardApp.tsx @@ -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 => { + 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 ( + +
+ +
+ +
+ ) +} diff --git a/src/renderer/src/components/dashboard/AgentDashboard.tsx b/src/renderer/src/components/dashboard/AgentDashboard.tsx index 6be34e01..5413b15b 100644 --- a/src/renderer/src/components/dashboard/AgentDashboard.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboard.tsx @@ -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>(new Set()) const prevDominantStates = useRef>({}) + 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>(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' ? ( - + ) : (
diff --git a/src/renderer/src/components/dashboard/ConcentricView.tsx b/src/renderer/src/components/dashboard/ConcentricView.tsx index a0ba43d4..5de5a98c 100644 --- a/src/renderer/src/components/dashboard/ConcentricView.tsx +++ b/src/renderer/src/components/dashboard/ConcentricView.tsx @@ -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(null) + const [transform, setTransform] = useState(savedTransform) + const [isPanning, setIsPanning] = useState(false) const containerRef = useRef(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) + 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) => { + // 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 (
- No repos added. Add a repo to see agent activity. + No visible agents.
) } + const isTransformed = transform.x !== 0 || transform.y !== 0 || transform.scale !== 1 + return ( -
-
- {groups.map((group) => ( - - ))} +
{ + // 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. */} +
+
+ {groups.map((group) => ( + + ))} +
- {/* Floating tooltip overlay — shows per-agent detail on hover */} - {tooltip && ( + {/* Zoom controls — floating in the corner, outside the transform. */} +
+
+ {Math.round(transform.scale * 100)}% +
+ {isTransformed && ( + + )} +
+ + {/* Floating tooltip overlay — shows per-agent detail on hover. + Rendered outside the transformed layer so it stays screen-space. */} + {tooltip && containerRect && (
{/* Agent identity + state */} @@ -116,10 +385,29 @@ export default function ConcentricView({ groups, onCheckWorktree }: ConcentricVi
{/* Worktree context */} -
- {tooltip.worktreeName} - {tooltip.branchName ? ` · ${tooltip.branchName}` : ''} -
+
{tooltip.worktreeName}
+ {tooltip.blocks && tooltip.blocks.length > 0 ? ( +
+ {tooltip.blocks.map((block, i) => ( + + ))} +
+ ) : null} + {tooltip.promptText ? ( +
+ Prompt: {tooltip.promptText} +
+ ) : null} + {tooltip.statusText ? ( +
+ {tooltip.statusText} +
+ ) : null}
)}
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 65a636da..61e17de5 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -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 ( -
- {/* Top line: agent label + current state */} +
- {/* Status dot */} - {/* Agent type */} - {agentLabel} - {/* State label */} - + {agentLabel} + {stateLabel(agent.state)}
diff --git a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx b/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx index 132c7bad..9a441cf8 100644 --- a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx +++ b/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx @@ -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({
{branchName}
)} - {/* Agent rows with activity blocks */} {card.agents.length > 0 && (
{card.agents.map((agent) => ( diff --git a/src/renderer/src/components/dashboard/RepoSystem.tsx b/src/renderer/src/components/dashboard/RepoSystem.tsx index 92077d22..eb70c551 100644 --- a/src/renderer/src/components/dashboard/RepoSystem.tsx +++ b/src/renderer/src/components/dashboard/RepoSystem.tsx @@ -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 = { working: { fill: '#10b981', glow: '#34d399' }, blocked: { fill: '#f59e0b', glow: '#fbbf24' }, @@ -32,114 +24,45 @@ const STATE_COLORS: Record = { 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 = { - 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 -// - - - - - - - - - - - - - - - - {/* Background radial gradient — gives each repo a subtle ambient glow */} - - - {/* Outermost ring: repo boundary */} - + {/* Repo header */} +
+ - - {/* Middle ring: worktree orbit (animated dashed ring) */} - - - {/* Inner decorative ring */} - - - {/* Radial spokes connecting center to each worktree */} - {activeWorktrees.map((_, i) => { - const angle = worktreeAngle(i, activeWorktrees.length) - const [wx, wy] = worktreePosition(angle) - return ( - - ) - })} - - {/* Center: repo name + stats */} - - {truncate(group.repo.displayName, 16)} - - - {activeWorktrees.length} worktree{activeWorktrees.length !== 1 ? 's' : ''} - {' \u00B7 '} - {totalAgents} agent{totalAgents !== 1 ? 's' : ''} - - - {/* Attention badge at center (if any agents need attention) */} + + {truncate(group.repo.displayName, 28)} + + + {activeWorktrees.length} wt · {totalAgents} agent{totalAgents !== 1 ? 's' : ''} + {group.attentionCount > 0 && ( - <> - - - {group.attentionCount} - - + + {group.attentionCount} + )} +
- {/* ── 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 ( - + No active agents +
+ ) : ( + // 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. +
+ {activeWorktrees.map((card) => ( + onClick(card.worktree.id)} - onMouseLeave={onHideTooltip} - > - {/* Worktree circle background */} - + card={card} + onClick={onClick} + onDismiss={onDismiss} + onShowTooltip={onShowTooltip} + onHideTooltip={onHideTooltip} + /> + ))} +
+ )} +
+ ) +}) - {/* Glow ring: working state (gentle breathing animation) */} - {card.dominantState === 'working' && ( - - )} +// ─── 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) => void + onHideTooltip: () => void +} - {/* Pulse ring: blocked state (attention-drawing pulse) */} - {card.dominantState === 'blocked' && ( - - )} +const WorktreeGroup = React.memo(function WorktreeGroup({ + card, + onClick, + onDismiss, + onShowTooltip, + onHideTooltip +}: WorktreeGroupProps) { + return ( +
+
+ + {truncate(card.worktree.displayName, 28)} + + + {card.agents.length} agent{card.agents.length !== 1 ? 's' : ''} + +
+
+ {card.agents.map((agent) => ( + + ))} +
+
+ ) +}) - {/* Agent orbit ring (visible when 2+ agents — creates the nested - concentric pattern within each worktree) */} - {card.agents.length >= 2 && ( - - )} +// ─── 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) => 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 ( - { - // 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 */} - - - {showInitials && ( - - {agentInitial(agent.agentType)} - - )} - - ) - })} +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 */} - - {truncate(card.worktree.displayName, 14)} - - - ) - })} + 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 && ( - onClick(card.worktree.id)} + > + {/* Working/blocked glow overlay — same breathing treatment the + worktree-level tile had, now applied per-agent. */} + {(isWorking || isBlocked) && ( + + )} + + {/* Top row: yellow bell (done) on the left, state badge + dismiss on the right. */} +
+ {isDone && } + + {agentStateBadge(agent.state)} + + {isDone && ( + )} - +
+ + {/* 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. */} +
+ + + + {/* Current prompt/status — the action in this turn. Line-clamped to + 2 rows so it never pushes the tile larger than aspect-square. */} + {promptText && ( +
+ {promptText} +
+ )} +
+ + {/* 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 && ( +
+ {visibleBlocks.map((block, i) => { + const bc = stateColor(block.state) + return ( + + ) + })} +
+ )}
) }) diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index e7219e46..01a9f0de 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -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 + }) } } diff --git a/src/renderer/src/components/dashboard/useDashboardKeyboard.ts b/src/renderer/src/components/dashboard/useDashboardKeyboard.ts index e0ad9e01..708ae750 100644 --- a/src/renderer/src/components/dashboard/useDashboardKeyboard.ts +++ b/src/renderer/src/components/dashboard/useDashboardKeyboard.ts @@ -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, diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index f521a929..60aa4f95 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -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() - 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 - } } } diff --git a/src/renderer/src/components/right-sidebar/DashboardBottomPanel.tsx b/src/renderer/src/components/right-sidebar/DashboardBottomPanel.tsx new file mode 100644 index 00000000..c59360d0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/DashboardBottomPanel.tsx @@ -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 + 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(initial.height) + const [collapsed, setCollapsed] = useState(initial.collapsed) + + const containerRef = useRef(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) => { + 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 ( +
+ {/* Resize handle. Hidden while collapsed — user must expand first. */} + {!collapsed ? ( +
+ ) : null} + + {/* Header: title + controls */} +
setCollapsed((prev) => !prev)} + > + + + Agents + + + {activeAgentCount} + +
+ + + + + + Open in new window + + +
+ + {/* Body: full AgentDashboard */} + {!collapsed ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 56584bba..1a501180 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -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' && } {effectiveTab === 'source-control' && } {effectiveTab === 'checks' && } - {effectiveTab === 'dashboard' && }
) @@ -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. */} + + + + {/* Resize handle on LEFT side */}
) => 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(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 => { + 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 => { setLoading(true) try { @@ -294,9 +315,34 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem

- 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 ~/.claude and{' '} + ~/.codex.

+
+
+

Keep hooks installed

+

+ When enabled, Orca reconciles its managed hooks on startup. Disabled by default + because this mutates global agent config. +

+
+ +
{( [ @@ -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 (
) : ( - +
+ {needsRepair && ( + + Partial + + )} + +
)}
) diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 20e7bd74..ecdc2023 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -438,6 +438,8 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea ) : null, matchesSettingsSearch(searchQuery, GENERAL_CACHE_TIMER_SEARCH_ENTRIES) ? ( diff --git a/src/renderer/src/components/sidebar/AgentStatusHover.test.ts b/src/renderer/src/components/sidebar/AgentStatusHover.test.ts index cd525de4..4857f04f 100644 --- a/src/renderer/src/components/sidebar/AgentStatusHover.test.ts +++ b/src/renderer/src/components/sidebar/AgentStatusHover.test.ts @@ -21,8 +21,9 @@ function makeTab(overrides: Partial = {}): TerminalTab { function makeEntry(overrides: Partial & { 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' }), diff --git a/src/renderer/src/components/sidebar/AgentStatusHover.tsx b/src/renderer/src/components/sidebar/AgentStatusHover.tsx index bbbca928..8c433bd3 100644 --- a/src/renderer/src/components/sidebar/AgentStatusHover.tsx +++ b/src/renderer/src/components/sidebar/AgentStatusHover.tsx @@ -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)} - {formatTimeAgo(row.explicit.updatedAt, now)} + {formatElapsedAgentTime(row.explicit.stateStartedAt, now)}
- {row.explicit.summary && ( + {row.explicit.statusText && (
- {row.explicit.summary} + {row.explicit.statusText}
)} - {row.explicit.next && ( + {row.explicit.promptText && (
- Next: {row.explicit.next} + Prompt: {row.explicit.promptText}
)} +
+ Updated {formatTimeAgo(row.explicit.updatedAt, now)} +
{!isFresh && (
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] diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 3d478c75..11dbbf4c 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -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', diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 296bd936..c4f9bc1b 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -186,6 +186,9 @@ describe('useIpcEvents updater integration', () => { credentialResolvedListenerRef.current = listener return () => {} } + }, + agentStatus: { + onSet: () => () => {} } } }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 71dee441..b4202047 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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) diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 52050699..87cd16c3 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -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) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 25face2b..af2a29be 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -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. diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 49cd7d81..ebabf8e0 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( - - - + {isDashboardView ? : } ) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 7ed6be45..1b5920ab 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -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 + } + ]) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 40162787..64ff7bae 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -78,18 +78,27 @@ export const createAgentStatusSlice: StateCreator { 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 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 } @@ -1143,6 +1149,69 @@ export const createTerminalSlice: StateCreator }) }, + 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 = 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 = {} + 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, diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 2e4d173c..7907fae8 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -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' }) }) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 019101fb..993dc3b9 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4076fd7a..4a0af6f7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -123,7 +123,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { skipDeleteWorktreeConfirm: false, defaultTaskViewPreset: 'all', agentCmdOverrides: {}, - terminalMacOptionAsAlt: 'true' + terminalMacOptionAsAlt: 'true', + autoInstallAgentHooks: false } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 4f86e633..b025626c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 * (v1→v2 rename). When this flag is absent and sortBy is 'recent', the * main-process load() migrates it to 'smart' and sets this flag so the