mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
WIP: movable concentric dashboard UI
Snapshot of the movable/concentric agent dashboard with draggable worktree cards and standalone dashboard window. Preserved on this branch before switching to a simple list view.
This commit is contained in:
parent
9efc9ca293
commit
6416b2c3e3
43 changed files with 1875 additions and 671 deletions
|
|
@ -12,6 +12,21 @@ type AgentHookEventPayload = {
|
|||
payload: ParsedAgentStatusPayload
|
||||
}
|
||||
|
||||
function extractPromptText(hookPayload: Record<string, unknown>): string {
|
||||
// Why: Claude documents `prompt` on UserPromptSubmit, but different agents
|
||||
// may use slightly different field names. Check a small allowlist so we can
|
||||
// capture the user prompt when it is present without depending on one exact
|
||||
// provider-specific key everywhere in the renderer.
|
||||
const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message']
|
||||
for (const key of candidateKeys) {
|
||||
const value = hookPayload[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
|
|
@ -33,7 +48,17 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|||
})
|
||||
}
|
||||
|
||||
function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | null {
|
||||
function normalizeClaudeEvent(
|
||||
eventName: unknown,
|
||||
promptText: string
|
||||
): ParsedAgentStatusPayload | null {
|
||||
// Why: Claude's Stop event is the primary "turn complete" signal, but the
|
||||
// SubagentStop and SessionEnd events also mark turn/session completion. If
|
||||
// only Stop is treated as "done", a session whose last event is one of the
|
||||
// other stop events lingers in whatever the previous state was — most often
|
||||
// "working" from a trailing PostToolUse, which then decays to heuristic
|
||||
// "idle" when the entry goes stale. Treating all terminal events as "done"
|
||||
// matches the user's mental model: Claude is no longer actively working.
|
||||
const state =
|
||||
eventName === 'UserPromptSubmit' ||
|
||||
eventName === 'PostToolUse' ||
|
||||
|
|
@ -41,7 +66,7 @@ function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | nu
|
|||
? 'working'
|
||||
: eventName === 'PermissionRequest'
|
||||
? 'waiting'
|
||||
: eventName === 'Stop'
|
||||
: eventName === 'Stop' || eventName === 'SubagentStop' || eventName === 'SessionEnd'
|
||||
? 'done'
|
||||
: null
|
||||
|
||||
|
|
@ -52,26 +77,32 @@ function normalizeClaudeEvent(eventName: unknown): ParsedAgentStatusPayload | nu
|
|||
return parseAgentStatusPayload(
|
||||
JSON.stringify({
|
||||
state,
|
||||
summary:
|
||||
statusText:
|
||||
state === 'waiting'
|
||||
? 'Waiting for permission'
|
||||
: state === 'done'
|
||||
? 'Turn complete'
|
||||
: 'Responding to prompt',
|
||||
promptText,
|
||||
agentType: 'claude'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | null {
|
||||
function normalizeCodexEvent(
|
||||
eventName: unknown,
|
||||
promptText: string
|
||||
): ParsedAgentStatusPayload | null {
|
||||
// Why: Codex's PreToolUse fires on every tool invocation, not only on user
|
||||
// approval prompts, so mapping it to "waiting" turned every running Codex
|
||||
// agent into "Waiting for permission". Only map events that unambiguously
|
||||
// indicate lifecycle transitions.
|
||||
const state =
|
||||
eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
|
||||
? 'working'
|
||||
: eventName === 'PreToolUse'
|
||||
? 'waiting'
|
||||
: eventName === 'Stop'
|
||||
? 'done'
|
||||
: null
|
||||
: eventName === 'Stop'
|
||||
? 'done'
|
||||
: null
|
||||
|
||||
if (!state) {
|
||||
return null
|
||||
|
|
@ -80,12 +111,8 @@ function normalizeCodexEvent(eventName: unknown): ParsedAgentStatusPayload | nul
|
|||
return parseAgentStatusPayload(
|
||||
JSON.stringify({
|
||||
state,
|
||||
summary:
|
||||
state === 'waiting'
|
||||
? 'Waiting for permission'
|
||||
: state === 'done'
|
||||
? 'Turn complete'
|
||||
: 'Responding to prompt',
|
||||
statusText: state === 'done' ? 'Turn complete' : 'Responding to prompt',
|
||||
promptText,
|
||||
agentType: 'codex'
|
||||
})
|
||||
)
|
||||
|
|
@ -107,8 +134,11 @@ function normalizeHookPayload(
|
|||
}
|
||||
|
||||
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
|
||||
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
|
||||
const payload =
|
||||
source === 'claude' ? normalizeClaudeEvent(eventName) : normalizeCodexEvent(eventName)
|
||||
source === 'claude'
|
||||
? normalizeClaudeEvent(eventName, promptText)
|
||||
: normalizeCodexEvent(eventName, promptText)
|
||||
|
||||
return payload ? { paneKey, payload } : null
|
||||
}
|
||||
|
|
@ -131,12 +161,14 @@ export class AgentHookServer {
|
|||
this.token = randomUUID()
|
||||
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method !== 'POST') {
|
||||
console.log('[agent-hooks] reject non-POST', req.method, req.url)
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
|
||||
console.log('[agent-hooks] token mismatch on', req.url)
|
||||
res.writeHead(403)
|
||||
res.end()
|
||||
return
|
||||
|
|
@ -147,19 +179,28 @@ export class AgentHookServer {
|
|||
const source =
|
||||
req.url === '/hook/claude' ? 'claude' : req.url === '/hook/codex' ? 'codex' : null
|
||||
if (!source) {
|
||||
console.log('[agent-hooks] unknown path', req.url)
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const payload = normalizeHookPayload(source, body)
|
||||
console.log('[agent-hooks] received', {
|
||||
source,
|
||||
eventName: (body as { payload?: { hook_event_name?: unknown } })?.payload
|
||||
?.hook_event_name,
|
||||
paneKey: (body as { paneKey?: unknown })?.paneKey,
|
||||
normalized: payload?.payload.state ?? null
|
||||
})
|
||||
if (payload) {
|
||||
this.onAgentStatus?.(payload)
|
||||
}
|
||||
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.log('[agent-hooks] error handling request', error)
|
||||
// Why: agent hooks must fail open. The receiver returns success for
|
||||
// malformed payloads so a newer or broken hook never blocks the agent.
|
||||
res.writeHead(204)
|
||||
|
|
@ -174,6 +215,7 @@ export class AgentHookServer {
|
|||
if (address && typeof address === 'object') {
|
||||
this.port = address.port
|
||||
}
|
||||
console.log('[agent-hooks] receiver listening', { port: this.port })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export function registerCoreHandlers(
|
|||
browserSessionRegistry.restorePersistedUserAgent()
|
||||
registerShellHandlers()
|
||||
registerSessionHandlers(store)
|
||||
registerUIHandlers(store)
|
||||
registerUIHandlers(store, mainWindowWebContentsId)
|
||||
registerFilesystemHandlers(store)
|
||||
registerFilesystemWatcherHandlers()
|
||||
registerRuntimeHandlers(runtime)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, webContents as electronWebContents } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { PersistedUIState } from '../../shared/types'
|
||||
import { openAgentDashboardWindow } from '../window/createAgentDashboardWindow'
|
||||
|
||||
export function registerUIHandlers(store: Store): void {
|
||||
export function registerUIHandlers(store: Store, mainWebContentsId: number | null = null): void {
|
||||
ipcMain.handle('ui:get', () => {
|
||||
return store.getUI()
|
||||
})
|
||||
|
|
@ -10,4 +11,47 @@ export function registerUIHandlers(store: Store): void {
|
|||
ipcMain.handle('ui:set', (_event, args: Partial<PersistedUIState>) => {
|
||||
store.updateUI(args)
|
||||
})
|
||||
|
||||
ipcMain.handle('ui:openAgentDashboard', () => {
|
||||
console.log('[ui] ui:openAgentDashboard handler invoked')
|
||||
try {
|
||||
openAgentDashboardWindow(store)
|
||||
} catch (error) {
|
||||
console.error('[ui] openAgentDashboardWindow threw', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the detached dashboard window has its own renderer store, so simply
|
||||
// calling setActiveWorktree there does not switch the user's view in the
|
||||
// main window. Route the request through main so the main window's
|
||||
// renderer receives the existing ui:activateWorktree event — the same path
|
||||
// used by CLI-created worktrees and notification clicks.
|
||||
ipcMain.handle(
|
||||
'ui:requestActivateWorktree',
|
||||
(_event, args: { repoId: string; worktreeId: string }) => {
|
||||
if (mainWebContentsId == null) {
|
||||
return
|
||||
}
|
||||
const wc = electronWebContents.fromId(mainWebContentsId)
|
||||
if (!wc || wc.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const main = BrowserWindow.fromWebContents(wc)
|
||||
if (!main || main.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
app.focus({ steal: true })
|
||||
}
|
||||
if (main.isMinimized()) {
|
||||
main.restore()
|
||||
}
|
||||
main.focus()
|
||||
wc.send('ui:activateWorktree', {
|
||||
repoId: args.repoId,
|
||||
worktreeId: args.worktreeId
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
183
src/main/window/createAgentDashboardWindow.ts
Normal file
183
src/main/window/createAgentDashboardWindow.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { BrowserWindow, nativeTheme, screen, shell } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import icon from '../../../resources/icon.png?asset'
|
||||
import devIcon from '../../../resources/icon-dev.png?asset'
|
||||
import type { Store } from '../persistence'
|
||||
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
|
||||
|
||||
const DEFAULT_WIDTH = 520
|
||||
const DEFAULT_HEIGHT = 640
|
||||
|
||||
// Why: the detached dashboard is a secondary window that renders just the
|
||||
// AgentDashboard component. We key its renderer route with a query param so
|
||||
// the same main.tsx entry decides whether to mount the full app or the
|
||||
// lightweight dashboard view.
|
||||
const DASHBOARD_VIEW_PARAM = 'view=agent-dashboard'
|
||||
|
||||
let dashboardWindow: BrowserWindow | null = null
|
||||
|
||||
function computeDefaultBounds(): { width: number; height: number } {
|
||||
try {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
return {
|
||||
width: Math.min(DEFAULT_WIDTH, width),
|
||||
height: Math.min(DEFAULT_HEIGHT, height)
|
||||
}
|
||||
} catch {
|
||||
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: on some platforms/timings `ready-to-show` never fires (e.g. if the
|
||||
// renderer hits a runtime error before first paint). Without a fallback the
|
||||
// window stays hidden forever and the user just sees nothing happen when they
|
||||
// click the popout button. Force-show after a short grace period.
|
||||
const READY_TO_SHOW_TIMEOUT_MS = 3000
|
||||
|
||||
function isBoundsOnScreen(bounds: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}): boolean {
|
||||
try {
|
||||
const displays = screen.getAllDisplays()
|
||||
return displays.some((d) => {
|
||||
const wa = d.workArea
|
||||
return (
|
||||
bounds.x + bounds.width > wa.x &&
|
||||
bounds.x < wa.x + wa.width &&
|
||||
bounds.y + bounds.height > wa.y &&
|
||||
bounds.y < wa.y + wa.height
|
||||
)
|
||||
})
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function openAgentDashboardWindow(store: Store | null): BrowserWindow {
|
||||
console.log('[dashboard-window] openAgentDashboardWindow invoked')
|
||||
// Why: singleton — a second invocation focuses the existing window instead
|
||||
// of spawning duplicates. Multiple dashboard windows would subscribe to the
|
||||
// same IPC events and compete for the same bounds-persistence slot.
|
||||
if (dashboardWindow && !dashboardWindow.isDestroyed()) {
|
||||
console.log('[dashboard-window] focusing existing window')
|
||||
if (dashboardWindow.isMinimized()) {
|
||||
dashboardWindow.restore()
|
||||
}
|
||||
dashboardWindow.focus()
|
||||
return dashboardWindow
|
||||
}
|
||||
|
||||
const rawSavedBounds = store?.getUI().agentDashboardWindowBounds ?? null
|
||||
// Why: if the display that hosted the window last time is no longer present
|
||||
// (external monitor unplugged), restoring its coordinates would place the
|
||||
// window offscreen so it looks like the popout silently failed.
|
||||
const savedBounds = rawSavedBounds && isBoundsOnScreen(rawSavedBounds) ? rawSavedBounds : null
|
||||
if (rawSavedBounds && !savedBounds) {
|
||||
console.log('[dashboard-window] discarding offscreen saved bounds', rawSavedBounds)
|
||||
}
|
||||
const defaultBounds = computeDefaultBounds()
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: savedBounds?.width ?? defaultBounds.width,
|
||||
height: savedBounds?.height ?? defaultBounds.height,
|
||||
...(savedBounds ? { x: savedBounds.x, y: savedBounds.y } : {}),
|
||||
minWidth: 360,
|
||||
minHeight: 360,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
|
||||
title: 'Agent Dashboard',
|
||||
icon: is.dev ? devIcon : icon,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
let shown = false
|
||||
const revealWindow = (reason: string): void => {
|
||||
if (shown || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
shown = true
|
||||
console.log(`[dashboard-window] showing window (reason=${reason})`)
|
||||
win.show()
|
||||
}
|
||||
win.once('ready-to-show', () => revealWindow('ready-to-show'))
|
||||
const readyFallback = setTimeout(() => revealWindow('timeout-fallback'), READY_TO_SHOW_TIMEOUT_MS)
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
console.log('[dashboard-window] did-finish-load')
|
||||
revealWindow('did-finish-load')
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_event, code, description, url) => {
|
||||
console.error('[dashboard-window] did-fail-load', { code, description, url })
|
||||
revealWindow('did-fail-load')
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
console.error('[dashboard-window] render-process-gone', details)
|
||||
})
|
||||
win.webContents.on('console-message', (details) => {
|
||||
console.log(
|
||||
`[dashboard-window][renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`
|
||||
)
|
||||
})
|
||||
|
||||
let boundsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const saveBounds = (): void => {
|
||||
if (boundsTimer) {
|
||||
clearTimeout(boundsTimer)
|
||||
}
|
||||
boundsTimer = setTimeout(() => {
|
||||
boundsTimer = null
|
||||
if (win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
store?.updateUI({ agentDashboardWindowBounds: win.getBounds() })
|
||||
}, 500)
|
||||
}
|
||||
win.on('resize', saveBounds)
|
||||
win.on('move', saveBounds)
|
||||
|
||||
// Why: external links clicked in the dashboard (e.g. a future "open PR"
|
||||
// action) must escape into the OS browser, never open as a child window
|
||||
// that would inherit the preload bridge.
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
const externalUrl = normalizeExternalBrowserUrl(details.url)
|
||||
if (externalUrl) {
|
||||
shell.openExternal(externalUrl)
|
||||
}
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (boundsTimer) {
|
||||
clearTimeout(boundsTimer)
|
||||
boundsTimer = null
|
||||
}
|
||||
clearTimeout(readyFallback)
|
||||
if (dashboardWindow === win) {
|
||||
dashboardWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
|
||||
const url = `${process.env.ELECTRON_RENDERER_URL}?${DASHBOARD_VIEW_PARAM}`
|
||||
console.log('[dashboard-window] loading dev URL', url)
|
||||
win.loadURL(url).catch((err) => {
|
||||
console.error('[dashboard-window] loadURL failed', err)
|
||||
})
|
||||
} else {
|
||||
const htmlPath = join(__dirname, '../renderer/index.html')
|
||||
console.log('[dashboard-window] loading prod file', htmlPath)
|
||||
win.loadFile(htmlPath, { search: DASHBOARD_VIEW_PARAM }).catch((err) => {
|
||||
console.error('[dashboard-window] loadFile failed', err)
|
||||
})
|
||||
}
|
||||
|
||||
dashboardWindow = win
|
||||
return win
|
||||
}
|
||||
3
src/preload/api-types.d.ts
vendored
3
src/preload/api-types.d.ts
vendored
|
|
@ -414,6 +414,7 @@ export type PreloadApi = {
|
|||
get: () => Promise<WorkspaceSessionState>
|
||||
set: (args: WorkspaceSessionState) => Promise<void>
|
||||
setSync: (args: WorkspaceSessionState) => void
|
||||
onUpdated: (callback: () => void) => () => void
|
||||
}
|
||||
updater: {
|
||||
getVersion: () => Promise<string>
|
||||
|
|
@ -575,6 +576,8 @@ export type PreloadApi = {
|
|||
onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void
|
||||
onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void) => () => void
|
||||
confirmWindowClose: () => void
|
||||
openAgentDashboard: () => Promise<void>
|
||||
requestActivateWorktree: (args: { repoId: string; worktreeId: string }) => Promise<void>
|
||||
}
|
||||
runtime: {
|
||||
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
|
||||
|
|
|
|||
4
src/preload/index.d.ts
vendored
4
src/preload/index.d.ts
vendored
|
|
@ -202,8 +202,8 @@ type AgentStatusApi = {
|
|||
callback: (data: {
|
||||
paneKey: string
|
||||
state: string
|
||||
summary?: string
|
||||
next?: string
|
||||
statusText?: string
|
||||
promptText?: string
|
||||
agentType?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
|
|
|
|||
|
|
@ -762,6 +762,14 @@ const api = {
|
|||
/** Synchronous session save for beforeunload — blocks until flushed to disk. */
|
||||
setSync: (args: unknown): void => {
|
||||
ipcRenderer.sendSync('session:set-sync', args)
|
||||
},
|
||||
/** Fired when another window wrote a new session payload. The detached
|
||||
* agent-dashboard window subscribes to this so its view of tabs/terminal
|
||||
* titles stays in sync with the main window. */
|
||||
onUpdated: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('session:updated', listener)
|
||||
return () => ipcRenderer.removeListener('session:updated', listener)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1133,7 +1141,16 @@ const api = {
|
|||
/** Tell the main process to proceed with the window close. */
|
||||
confirmWindowClose: (): void => {
|
||||
ipcRenderer.send('window:confirm-close')
|
||||
}
|
||||
},
|
||||
/** Open the agent dashboard in a detached secondary window. Focuses the
|
||||
* existing window if one is already open. */
|
||||
openAgentDashboard: (): Promise<void> => ipcRenderer.invoke('ui:openAgentDashboard'),
|
||||
/** Ask the main window to activate a worktree (focusing main + routing
|
||||
* through the same ui:activateWorktree path CLI/notifications use).
|
||||
* Used by the detached dashboard window, whose renderer has its own
|
||||
* store and cannot mutate the main window's active-worktree state. */
|
||||
requestActivateWorktree: (args: { repoId: string; worktreeId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('ui:requestActivateWorktree', args)
|
||||
},
|
||||
|
||||
stats: {
|
||||
|
|
@ -1296,8 +1313,8 @@ const api = {
|
|||
callback: (data: {
|
||||
paneKey: string
|
||||
state: string
|
||||
summary?: string
|
||||
next?: string
|
||||
statusText?: string
|
||||
promptText?: string
|
||||
agentType?: string
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
|
|
@ -1306,8 +1323,8 @@ const api = {
|
|||
data: {
|
||||
paneKey: string
|
||||
state: string
|
||||
summary?: string
|
||||
next?: string
|
||||
statusText?: string
|
||||
promptText?: string
|
||||
agentType?: string
|
||||
}
|
||||
) => callback(data)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
115
src/renderer/src/DashboardApp.tsx
Normal file
115
src/renderer/src/DashboardApp.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useAppStore } from './store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import AgentDashboard from './components/dashboard/AgentDashboard'
|
||||
import { parseAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
// Why: the detached dashboard is a lean secondary window. It does NOT
|
||||
// reconnect PTYs, own terminal scrollback, or write the workspace session.
|
||||
// It only hydrates enough state (repos, worktrees, tabs, agent status) to
|
||||
// render AgentDashboard live, and subscribes to IPC pushes so the view stays
|
||||
// in sync with whatever the main window does.
|
||||
export default function DashboardApp(): React.JSX.Element {
|
||||
console.log('[DashboardApp] mounting detached dashboard renderer')
|
||||
const actions = useAppStore(
|
||||
useShallow((s) => ({
|
||||
fetchRepos: s.fetchRepos,
|
||||
fetchAllWorktrees: s.fetchAllWorktrees,
|
||||
fetchWorktrees: s.fetchWorktrees,
|
||||
hydrateObserverSession: s.hydrateObserverSession,
|
||||
hydrateTabsSession: s.hydrateTabsSession,
|
||||
setAgentStatus: s.setAgentStatus
|
||||
}))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const hydrate = async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DashboardApp] hydrating: fetchRepos')
|
||||
await actions.fetchRepos()
|
||||
console.log('[DashboardApp] hydrating: fetchAllWorktrees')
|
||||
await actions.fetchAllWorktrees()
|
||||
console.log('[DashboardApp] hydrating: session.get')
|
||||
const session = await window.api.session.get()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
actions.hydrateObserverSession(session)
|
||||
actions.hydrateTabsSession(session)
|
||||
console.log('[DashboardApp] hydration complete')
|
||||
} catch (error) {
|
||||
console.error('[DashboardApp] hydration failed', error)
|
||||
}
|
||||
}
|
||||
void hydrate()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [actions])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubs: (() => void)[] = []
|
||||
|
||||
unsubs.push(
|
||||
window.api.repos.onChanged(() => {
|
||||
void useAppStore.getState().fetchRepos()
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.worktrees.onChanged((data: { repoId: string }) => {
|
||||
void useAppStore.getState().fetchWorktrees(data.repoId)
|
||||
})
|
||||
)
|
||||
|
||||
// Why: the main window is the source of truth for terminal tabs (it owns
|
||||
// every PTY). When it writes session:set, re-fetch so this window sees new
|
||||
// tabs, retitled agents, or tabs that were closed.
|
||||
unsubs.push(
|
||||
window.api.session.onUpdated(() => {
|
||||
void (async () => {
|
||||
const session = await window.api.session.get()
|
||||
const store = useAppStore.getState()
|
||||
store.hydrateObserverSession(session)
|
||||
store.hydrateTabsSession(session)
|
||||
})()
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.agentStatus.onSet((data) => {
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({
|
||||
state: data.state,
|
||||
statusText: data.statusText,
|
||||
promptText: data.promptText,
|
||||
agentType: data.agentType
|
||||
})
|
||||
)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
store.setAgentStatus(data.paneKey, payload, undefined)
|
||||
})
|
||||
)
|
||||
|
||||
return () => {
|
||||
for (const fn of unsubs) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AgentDashboard />
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,32 @@ import { useRetainedAgents } from './useRetainedAgents'
|
|||
|
||||
type ViewMode = 'list' | 'radial'
|
||||
|
||||
function computeDominantState(states: string[]): 'working' | 'blocked' | 'done' | 'idle' {
|
||||
if (states.length === 0) {
|
||||
return 'idle'
|
||||
}
|
||||
let hasWorking = false
|
||||
let hasDone = false
|
||||
for (const state of states) {
|
||||
if (state === 'blocked' || state === 'waiting') {
|
||||
return 'blocked'
|
||||
}
|
||||
if (state === 'working') {
|
||||
hasWorking = true
|
||||
}
|
||||
if (state === 'done') {
|
||||
hasDone = true
|
||||
}
|
||||
}
|
||||
if (hasWorking) {
|
||||
return 'working'
|
||||
}
|
||||
if (hasDone) {
|
||||
return 'done'
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
const AgentDashboard = React.memo(function AgentDashboard() {
|
||||
const liveGroups = useDashboardData()
|
||||
// Why: useRetainedAgents merges "done" entries for agents that have
|
||||
|
|
@ -29,8 +55,51 @@ const AgentDashboard = React.memo(function AgentDashboard() {
|
|||
const [checkedWorktreeIds, setCheckedWorktreeIds] = useState<Set<string>>(new Set())
|
||||
const prevDominantStates = useRef<Record<string, string>>({})
|
||||
|
||||
const visibleGroups = useMemo(() => {
|
||||
// Why: a user-dismissed worktree should hide completed agents regardless
|
||||
// of whether they come from retained history or a still-live explicit
|
||||
// status entry. Otherwise "done" agents can reappear in the radial view
|
||||
// until their pane closes, which violates the remove-from-UI behavior.
|
||||
return groups
|
||||
.map((group) => {
|
||||
const worktrees = group.worktrees
|
||||
.map((wt) => {
|
||||
if (!checkedWorktreeIds.has(wt.worktree.id)) {
|
||||
return wt
|
||||
}
|
||||
|
||||
const visibleAgents = wt.agents.filter((agent) => agent.state !== 'done')
|
||||
if (visibleAgents.length === wt.agents.length) {
|
||||
return wt
|
||||
}
|
||||
|
||||
return {
|
||||
...wt,
|
||||
agents: visibleAgents,
|
||||
dominantState: computeDominantState(visibleAgents.map((agent) => agent.state)),
|
||||
latestActivityAt: Math.max(
|
||||
0,
|
||||
...visibleAgents.map((agent) => agent.entry?.updatedAt ?? 0)
|
||||
)
|
||||
}
|
||||
})
|
||||
.filter((wt) => wt.agents.length > 0)
|
||||
|
||||
const attentionCount = worktrees.reduce(
|
||||
(count, wt) =>
|
||||
count +
|
||||
wt.agents.filter((agent) => agent.state === 'blocked' || agent.state === 'waiting')
|
||||
.length,
|
||||
0
|
||||
)
|
||||
|
||||
return { ...group, worktrees, attentionCount }
|
||||
})
|
||||
.filter((group) => group.worktrees.length > 0)
|
||||
}, [groups, checkedWorktreeIds])
|
||||
|
||||
const { filter, setFilter, filteredGroups, hasResults } = useDashboardFilter(
|
||||
groups,
|
||||
visibleGroups,
|
||||
checkedWorktreeIds
|
||||
)
|
||||
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
|
||||
|
|
@ -102,7 +171,7 @@ const AgentDashboard = React.memo(function AgentDashboard() {
|
|||
let runningAgents = 0
|
||||
let blockedAgents = 0
|
||||
let doneAgents = 0
|
||||
for (const group of groups) {
|
||||
for (const group of visibleGroups) {
|
||||
for (const wt of group.worktrees) {
|
||||
for (const agent of wt.agents) {
|
||||
if (agent.state === 'working') {
|
||||
|
|
@ -118,7 +187,7 @@ const AgentDashboard = React.memo(function AgentDashboard() {
|
|||
}
|
||||
}
|
||||
return { running: runningAgents, blocked: blockedAgents, done: doneAgents }
|
||||
}, [groups])
|
||||
}, [visibleGroups])
|
||||
|
||||
// Empty state: no repos at all
|
||||
if (groups.length === 0) {
|
||||
|
|
@ -196,7 +265,7 @@ const AgentDashboard = React.memo(function AgentDashboard() {
|
|||
|
||||
{/* Scrollable content — switches between concentric and list views */}
|
||||
{viewMode === 'radial' ? (
|
||||
<ConcentricView groups={groups} onCheckWorktree={handleCheckWorktree} />
|
||||
<ConcentricView groups={visibleGroups} onCheckWorktree={handleCheckWorktree} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
|
|
|
|||
|
|
@ -1,22 +1,58 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import type { DashboardRepoGroup } from './useDashboardData'
|
||||
import RepoSystem, { stateColor } from './RepoSystem'
|
||||
|
||||
// Why: the dashboard renders in two places — embedded in the main window's
|
||||
// right sidebar, and in a detached secondary window. The detached renderer
|
||||
// has its own Zustand store, so calling setActiveWorktree there does nothing
|
||||
// visible to the user. Detect that case so we can instead ask main to
|
||||
// activate the worktree in the main window.
|
||||
const IS_DETACHED_DASHBOARD =
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).get('view') === 'agent-dashboard'
|
||||
|
||||
export type TooltipBlock = { state: string; title: string }
|
||||
|
||||
export type TooltipData = {
|
||||
x: number
|
||||
y: number
|
||||
agentLabel: string
|
||||
state: string
|
||||
statusText?: string
|
||||
promptText?: string
|
||||
worktreeName: string
|
||||
branchName?: string
|
||||
blocks?: TooltipBlock[]
|
||||
}
|
||||
|
||||
// ─── Scroll Position Persistence ─────────────────────────────────────────────
|
||||
// Why: the concentric view unmounts when the user switches sidebar tabs. A
|
||||
// module-level variable survives the unmount so scroll position is restored
|
||||
// when the user returns to the dashboard.
|
||||
let savedScrollTop = 0
|
||||
// Why: approximate height of the tooltip card. Used to decide whether to
|
||||
// flip the tooltip below the cursor when there isn't enough room above
|
||||
// (otherwise the card is clipped by the top of the viewport).
|
||||
const TOOLTIP_FLIP_THRESHOLD_PX = 140
|
||||
|
||||
// Why: clamp zoom so we never invert the content or zoom so far that a single
|
||||
// worktree ring fills the whole viewport and loses context.
|
||||
const MIN_SCALE = 0.3
|
||||
const MAX_SCALE = 4
|
||||
|
||||
// Why: WebKit-legacy gesture events aren't in lib.dom.d.ts but are dispatched
|
||||
// by Chromium on macOS for trackpad pinches. Declaring the shape locally avoids
|
||||
// casting through any while still keeping the event handlers type-safe.
|
||||
type GestureEvent = Event & { scale: number; clientX: number; clientY: number }
|
||||
|
||||
// Why: pixels the cursor can move during a mousedown before we treat it as a
|
||||
// pan instead of a click. Matches rough OS-level drag thresholds.
|
||||
const DRAG_THRESHOLD_PX = 3
|
||||
|
||||
// ─── Transform Persistence ───────────────────────────────────────────────────
|
||||
// Why: the concentric view unmounts when the user switches sidebar tabs or
|
||||
// closes/reopens the dashboard window. A module-level variable survives the
|
||||
// unmount so pan/zoom position is restored when the user returns.
|
||||
let savedTransform: Transform = { x: 0, y: 0, scale: 1 }
|
||||
|
||||
type Transform = { x: number; y: number; scale: number }
|
||||
|
||||
type ConcentricViewProps = {
|
||||
groups: DashboardRepoGroup[]
|
||||
|
|
@ -27,77 +63,310 @@ export default function ConcentricView({ groups, onCheckWorktree }: ConcentricVi
|
|||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
const setActiveView = useAppStore((s) => s.setActiveView)
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null)
|
||||
const [transform, setTransform] = useState<Transform>(savedTransform)
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// Why: ref copy of transform so wheel/mouse handlers attached via
|
||||
// addEventListener read the latest values without needing to re-register.
|
||||
const transformRef = useRef<Transform>(transform)
|
||||
transformRef.current = transform
|
||||
|
||||
// Restore scroll position on mount, save on unmount
|
||||
// Why: tracks whether the current mousedown has crossed the drag threshold.
|
||||
// Checked on click to suppress accidental worktree activation after a pan.
|
||||
const draggedRef = useRef(false)
|
||||
const panStartRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null)
|
||||
|
||||
// Persist transform across unmount.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (el) {
|
||||
el.scrollTop = savedScrollTop
|
||||
}
|
||||
return () => {
|
||||
if (el) {
|
||||
savedScrollTop = el.scrollTop
|
||||
}
|
||||
savedTransform = transformRef.current
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: attach wheel + WebKit gesture listeners via native addEventListener
|
||||
// with { passive: false } so we can preventDefault and stop the page (or
|
||||
// parent) from scrolling / zooming the whole web contents while the user
|
||||
// manipulates this canvas. React's synthetic wheel handler is always passive.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const applyZoomAt = (clientX: number, clientY: number, zoomFactor: number): void => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const mouseX = clientX - rect.left
|
||||
const mouseY = clientY - rect.top
|
||||
const prev = transformRef.current
|
||||
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev.scale * zoomFactor))
|
||||
const actualFactor = nextScale / prev.scale
|
||||
// Keep the point under the cursor stationary in screen space.
|
||||
const nextX = mouseX - (mouseX - prev.x) * actualFactor
|
||||
const nextY = mouseY - (mouseY - prev.y) * actualFactor
|
||||
setTransform({ x: nextX, y: nextY, scale: nextScale })
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault()
|
||||
// Why: trackpad pinch-zoom in Chromium arrives as a wheel event with
|
||||
// small deltaY and ctrlKey=true; normal wheel scroll has larger deltaY
|
||||
// and no ctrlKey. Both should zoom here — we're a canvas, not a
|
||||
// scrollable page. Pinch gets higher sensitivity so a natural gesture
|
||||
// produces perceptible zoom instead of crawling.
|
||||
const sensitivity = e.ctrlKey ? 0.02 : 0.0015
|
||||
const zoomFactor = Math.exp(-e.deltaY * sensitivity)
|
||||
applyZoomAt(e.clientX, e.clientY, zoomFactor)
|
||||
}
|
||||
|
||||
// Why: on macOS, Electron/Chromium doesn't always synthesize pinch into
|
||||
// ctrlKey-wheel events — especially in windows where visualZoomLevelLimits
|
||||
// hasn't been configured. WebKit-legacy GestureEvents (gesturestart /
|
||||
// gesturechange / gestureend) are still dispatched to the DOM for trackpad
|
||||
// pinch and carry a `scale` property representing cumulative zoom relative
|
||||
// to the gesture's start. Tracking the delta between successive scale
|
||||
// readings lets us zoom even when the wheel path is silent.
|
||||
let prevScale = 1
|
||||
let anchorX = 0
|
||||
let anchorY = 0
|
||||
const handleGestureStart = (e: Event): void => {
|
||||
e.preventDefault()
|
||||
const ge = e as GestureEvent
|
||||
prevScale = 1
|
||||
anchorX = ge.clientX
|
||||
anchorY = ge.clientY
|
||||
}
|
||||
const handleGestureChange = (e: Event): void => {
|
||||
e.preventDefault()
|
||||
const ge = e as GestureEvent
|
||||
const delta = ge.scale / prevScale
|
||||
prevScale = ge.scale
|
||||
if (!Number.isFinite(delta) || delta <= 0) {
|
||||
return
|
||||
}
|
||||
applyZoomAt(ge.clientX || anchorX, ge.clientY || anchorY, delta)
|
||||
}
|
||||
const handleGestureEnd = (e: Event): void => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||
el.addEventListener('gesturestart', handleGestureStart, { passive: false })
|
||||
el.addEventListener('gesturechange', handleGestureChange, { passive: false })
|
||||
el.addEventListener('gestureend', handleGestureEnd, { passive: false })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', handleWheel)
|
||||
el.removeEventListener('gesturestart', handleGestureStart)
|
||||
el.removeEventListener('gesturechange', handleGestureChange)
|
||||
el.removeEventListener('gestureend', handleGestureEnd)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Only left-button drags initiate a pan.
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
const prev = transformRef.current
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
tx: prev.x,
|
||||
ty: prev.y
|
||||
}
|
||||
draggedRef.current = false
|
||||
setIsPanning(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanning) {
|
||||
return
|
||||
}
|
||||
const handleMove = (e: MouseEvent): void => {
|
||||
const start = panStartRef.current
|
||||
if (!start) {
|
||||
return
|
||||
}
|
||||
const dx = e.clientX - start.x
|
||||
const dy = e.clientY - start.y
|
||||
if (
|
||||
!draggedRef.current &&
|
||||
(Math.abs(dx) > DRAG_THRESHOLD_PX || Math.abs(dy) > DRAG_THRESHOLD_PX)
|
||||
) {
|
||||
draggedRef.current = true
|
||||
// Why: hide any hover tooltip once a pan begins — it would stay
|
||||
// frozen at its last screen position while the content moves.
|
||||
setTooltip(null)
|
||||
}
|
||||
if (draggedRef.current) {
|
||||
setTransform((curr) => ({ ...curr, x: start.tx + dx, y: start.ty + dy }))
|
||||
}
|
||||
}
|
||||
const handleUp = (): void => {
|
||||
panStartRef.current = null
|
||||
setIsPanning(false)
|
||||
}
|
||||
window.addEventListener('mousemove', handleMove)
|
||||
window.addEventListener('mouseup', handleUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove)
|
||||
window.removeEventListener('mouseup', handleUp)
|
||||
}
|
||||
}, [isPanning])
|
||||
|
||||
const handleClick = useCallback(
|
||||
(worktreeId: string) => {
|
||||
// Why: a drag that crossed the threshold should not also count as a
|
||||
// click on the worktree underneath. onMouseUp fires before onClick, so
|
||||
// draggedRef is already set by this point.
|
||||
if (draggedRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: clicking a tile — regardless of its state — only navigates to the
|
||||
// terminal. Dismissal of "done" agents is an explicit action on the X
|
||||
// button (see onDismiss) so users can click through to review a finished
|
||||
// agent without losing it from the dashboard.
|
||||
if (IS_DETACHED_DASHBOARD) {
|
||||
// Detached window cannot mutate the main window's store directly;
|
||||
// route through main so the main window receives the existing
|
||||
// ui:activateWorktree event and focuses itself.
|
||||
void window.api.ui.requestActivateWorktree({
|
||||
repoId: getRepoIdFromWorktreeId(worktreeId),
|
||||
worktreeId
|
||||
})
|
||||
return
|
||||
}
|
||||
setActiveWorktree(worktreeId)
|
||||
setActiveView('terminal')
|
||||
},
|
||||
[setActiveWorktree, setActiveView]
|
||||
)
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(worktreeId: string) => {
|
||||
if (draggedRef.current) {
|
||||
return
|
||||
}
|
||||
onCheckWorktree(worktreeId)
|
||||
},
|
||||
[setActiveWorktree, setActiveView, onCheckWorktree]
|
||||
[onCheckWorktree]
|
||||
)
|
||||
|
||||
const showTooltip = useCallback((e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => {
|
||||
// Why: while the user is actively panning, swallow tooltip updates so
|
||||
// the card doesn't flicker under the moving cursor.
|
||||
if (draggedRef.current) {
|
||||
return
|
||||
}
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const rect = container.getBoundingClientRect()
|
||||
// Why: the tooltip is position:absolute inside the scrollable container,
|
||||
// so we must add scrollTop to map viewport coords to content coords.
|
||||
// Tooltip lives outside the transformed layer, so we store container-
|
||||
// relative screen coords — no scroll or scale correction needed.
|
||||
setTooltip({
|
||||
...data,
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top + container.scrollTop
|
||||
y: e.clientY - rect.top
|
||||
})
|
||||
}, [])
|
||||
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
// Why: tooltip renders above the cursor by default. If the cursor is too
|
||||
// close to the top of the container, the tooltip gets clipped. Flip it
|
||||
// below the cursor in that case.
|
||||
const flipBelow = tooltip !== null && tooltip.y - TOOLTIP_FLIP_THRESHOLD_PX < 0
|
||||
|
||||
const hideTooltip = useCallback(() => setTooltip(null), [])
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setTransform({ x: 0, y: 0, scale: 1 })
|
||||
}, [])
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-[11px] text-muted-foreground">
|
||||
No repos added. Add a repo to see agent activity.
|
||||
No visible agents.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isTransformed = transform.x !== 0 || transform.y !== 0 || transform.scale !== 1
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<div className="flex flex-col items-center gap-1 py-2">
|
||||
{groups.map((group) => (
|
||||
<RepoSystem
|
||||
key={group.repo.id}
|
||||
group={group}
|
||||
onClick={handleClick}
|
||||
onShowTooltip={showTooltip}
|
||||
onHideTooltip={hideTooltip}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 overflow-hidden select-none"
|
||||
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={(e) => {
|
||||
// Why: double-click on empty canvas resets the view. Skip when the
|
||||
// double-click landed on an SVG element so double-tapping a worktree
|
||||
// still goes through the worktree's own handlers.
|
||||
const node = e.target as Element | null
|
||||
if (node && node.closest('svg')) {
|
||||
return
|
||||
}
|
||||
resetView()
|
||||
}}
|
||||
>
|
||||
{/* Transformed content layer — pan/zoom is applied here.
|
||||
Width matches the container so items-center still centers repos
|
||||
horizontally at scale=1, preserving the original layout. */}
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full origin-top-left"
|
||||
style={{
|
||||
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
|
||||
// Why: disable transition while actively panning so motion tracks
|
||||
// the cursor 1:1. Leave a short ease on wheel-driven zoom commits
|
||||
// only when NOT panning for a subtle polish.
|
||||
transition: isPanning ? 'none' : 'transform 60ms ease-out',
|
||||
willChange: 'transform'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 py-2">
|
||||
{groups.map((group) => (
|
||||
<RepoSystem
|
||||
key={group.repo.id}
|
||||
group={group}
|
||||
onClick={handleClick}
|
||||
onDismiss={handleDismiss}
|
||||
onShowTooltip={showTooltip}
|
||||
onHideTooltip={hideTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating tooltip overlay — shows per-agent detail on hover */}
|
||||
{tooltip && (
|
||||
{/* Zoom controls — floating in the corner, outside the transform. */}
|
||||
<div className="pointer-events-none absolute right-2 top-2 z-40 flex items-center gap-1">
|
||||
<div className="rounded-md border border-border/40 bg-popover/80 px-1.5 py-0.5 text-[10px] text-muted-foreground backdrop-blur-sm">
|
||||
{Math.round(transform.scale * 100)}%
|
||||
</div>
|
||||
{isTransformed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="pointer-events-auto flex items-center justify-center rounded-md border border-border/40 bg-popover/80 p-1 text-muted-foreground backdrop-blur-sm transition-colors hover:text-foreground"
|
||||
aria-label="Reset view"
|
||||
title="Reset view (or double-click empty space)"
|
||||
>
|
||||
<Maximize2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating tooltip overlay — shows per-agent detail on hover.
|
||||
Rendered outside the transformed layer so it stays screen-space. */}
|
||||
{tooltip && containerRect && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 max-w-[220px] rounded-lg border border-border/50 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
left: tooltip.x,
|
||||
top: tooltip.y - 12,
|
||||
transform: 'translate(-50%, -100%)'
|
||||
// Why: clamp horizontal position so the tooltip (centered on x via
|
||||
// transform) never extends past the edges of the container.
|
||||
left: Math.min(Math.max(tooltip.x, 112), Math.max(112, containerRect.width - 112)),
|
||||
top: flipBelow ? tooltip.y + 18 : tooltip.y - 12,
|
||||
transform: flipBelow ? 'translate(-50%, 0)' : 'translate(-50%, -100%)'
|
||||
}}
|
||||
>
|
||||
{/* Agent identity + state */}
|
||||
|
|
@ -116,10 +385,29 @@ export default function ConcentricView({ groups, onCheckWorktree }: ConcentricVi
|
|||
</div>
|
||||
|
||||
{/* Worktree context */}
|
||||
<div className="mt-0.5 text-[9px] text-muted-foreground/50">
|
||||
{tooltip.worktreeName}
|
||||
{tooltip.branchName ? ` · ${tooltip.branchName}` : ''}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[9px] text-muted-foreground/50">{tooltip.worktreeName}</div>
|
||||
{tooltip.blocks && tooltip.blocks.length > 0 ? (
|
||||
<div className="mt-1.5 flex items-center gap-0.5">
|
||||
{tooltip.blocks.map((block, i) => (
|
||||
<span
|
||||
key={`${i}-${block.state}`}
|
||||
title={block.title}
|
||||
className="h-1.5 w-3 rounded-sm"
|
||||
style={{ backgroundColor: stateColor(block.state).fill, opacity: 0.75 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tooltip.promptText ? (
|
||||
<div className="mt-1 max-w-[220px] text-[10px] leading-snug text-muted-foreground/80">
|
||||
Prompt: {tooltip.promptText}
|
||||
</div>
|
||||
) : null}
|
||||
{tooltip.statusText ? (
|
||||
<div className="mt-1 max-w-[220px] text-[10px] leading-snug text-muted-foreground/65">
|
||||
{tooltip.statusText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,28 +49,35 @@ function stateLabelColor(state: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: the tooltip preserves the fuller prompt/status text so no information
|
||||
// is permanently hidden behind the compact row layout.
|
||||
function rowTooltip(agent: DashboardAgentRowData): string {
|
||||
const parts: string[] = []
|
||||
const prompt = agent.promptText.trim()
|
||||
const status = agent.statusText.trim()
|
||||
if (prompt) {
|
||||
parts.push(`Prompt: ${prompt}`)
|
||||
}
|
||||
if (status) {
|
||||
parts.push(status)
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
type Props = {
|
||||
agent: DashboardAgentRowData
|
||||
}
|
||||
|
||||
const DashboardAgentRow = React.memo(function DashboardAgentRow({ agent }: Props) {
|
||||
const agentLabel = formatAgentTypeLabel(agent.agentType)
|
||||
const tooltip = rowTooltip(agent)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded px-1.5 py-1 bg-background/30',
|
||||
agent.source === 'heuristic' && 'opacity-70'
|
||||
)}
|
||||
>
|
||||
{/* Top line: agent label + current state */}
|
||||
<div title={tooltip || undefined} className={cn('rounded px-1.5 py-1 bg-background/30')}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Status dot */}
|
||||
<span className={cn('size-[6px] shrink-0 rounded-full', currentDotClasses(agent.state))} />
|
||||
{/* Agent type */}
|
||||
<span className="text-[10px] font-medium text-foreground/80">{agentLabel}</span>
|
||||
{/* State label */}
|
||||
<span className={cn('text-[10px] font-medium', stateLabelColor(agent.state))}>
|
||||
<span className="text-[10px] font-medium text-foreground/80 truncate">{agentLabel}</span>
|
||||
<span className={cn('ml-auto text-[10px] font-medium', stateLabelColor(agent.state))}>
|
||||
{stateLabel(agent.state)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import DashboardAgentRow from './DashboardAgentRow'
|
||||
import type { DashboardWorktreeCard as DashboardWorktreeCardData } from './useDashboardData'
|
||||
|
||||
// Why: the dashboard renders in two places — embedded in the main window's
|
||||
// right sidebar, and in a detached secondary window. The detached renderer
|
||||
// has its own Zustand store, so calling setActiveWorktree there has no
|
||||
// effect on what the user sees. Detect that case and route through main.
|
||||
const IS_DETACHED_DASHBOARD =
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).get('view') === 'agent-dashboard'
|
||||
|
||||
function dominantStateBadge(state: string): { label: string; className: string } {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
|
|
@ -39,8 +48,15 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
|
|||
// "checked" so done agents disappear from the active filter. The two actions
|
||||
// (navigate + check) must both fire on click.
|
||||
const handleClick = useCallback(() => {
|
||||
setActiveWorktree(card.worktree.id)
|
||||
setActiveView('terminal')
|
||||
if (IS_DETACHED_DASHBOARD) {
|
||||
void window.api.ui.requestActivateWorktree({
|
||||
repoId: getRepoIdFromWorktreeId(card.worktree.id),
|
||||
worktreeId: card.worktree.id
|
||||
})
|
||||
} else {
|
||||
setActiveWorktree(card.worktree.id)
|
||||
setActiveView('terminal')
|
||||
}
|
||||
onCheck()
|
||||
}, [card.worktree.id, setActiveWorktree, setActiveView, onCheck])
|
||||
|
||||
|
|
@ -93,7 +109,6 @@ const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
|
|||
<div className="mt-0.5 text-[10px] text-muted-foreground/60 truncate">{branchName}</div>
|
||||
)}
|
||||
|
||||
{/* Agent rows with activity blocks */}
|
||||
{card.agents.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-col gap-1">
|
||||
{card.agents.map((agent) => (
|
||||
|
|
|
|||
|
|
@ -1,29 +1,21 @@
|
|||
/* eslint-disable max-lines -- Why: RepoSystem is a single SVG component whose
|
||||
layout constants, animation styles, and rendering are tightly coupled. Splitting
|
||||
the SVG template across files would scatter the coordinate system and make the
|
||||
visual layout harder to trace during debugging. */
|
||||
import React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import type { DashboardRepoGroup } from './useDashboardData'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type {
|
||||
DashboardRepoGroup,
|
||||
DashboardAgentRow,
|
||||
DashboardWorktreeCard
|
||||
} from './useDashboardData'
|
||||
import type { TooltipData } from './ConcentricView'
|
||||
|
||||
// ─── Layout Constants ────────────────────────────────────────────────────────
|
||||
// Why: the SVG viewBox is fixed at 280x280 so each repo "system" renders as a
|
||||
// square that scales to the sidebar width. Radii define the three concentric
|
||||
// rings: outer (repo boundary), orbit (where worktrees sit), and inner (decorative).
|
||||
const SVG_SIZE = 280
|
||||
const CX = SVG_SIZE / 2
|
||||
const CY = SVG_SIZE / 2
|
||||
const REPO_RING_R = 115
|
||||
const ORBIT_R = 75
|
||||
const INNER_DECOR_R = 28
|
||||
const BASE_WT_R = 24
|
||||
const BASE_AGENT_R = 8
|
||||
|
||||
// ─── State Colors ────────────────────────────────────────────────────────────
|
||||
// Why: hardcoded hex values ensure consistent SVG rendering regardless of CSS
|
||||
// variable availability. These match the Tailwind color tokens used in the
|
||||
// card-grid dashboard (emerald-500, amber-500, sky-500, zinc-500).
|
||||
// Why: hardcoded hex values ensure consistent rendering regardless of CSS
|
||||
// variable availability. These match the Tailwind color tokens used elsewhere
|
||||
// (emerald-500, amber-500, sky-500, zinc-500).
|
||||
const STATE_COLORS: Record<string, { fill: string; glow: string }> = {
|
||||
working: { fill: '#10b981', glow: '#34d399' },
|
||||
blocked: { fill: '#f59e0b', glow: '#fbbf24' },
|
||||
|
|
@ -32,114 +24,45 @@ const STATE_COLORS: Record<string, { fill: string; glow: string }> = {
|
|||
idle: { fill: '#71717a', glow: '#a1a1aa' }
|
||||
}
|
||||
|
||||
export function stateColor(state: string) {
|
||||
export function stateColor(state: string): { fill: string; glow: string } {
|
||||
return STATE_COLORS[state] ?? STATE_COLORS.idle
|
||||
}
|
||||
|
||||
// ─── Agent Type Initials ─────────────────────────────────────────────────────
|
||||
// Why: visible initials inside small agent circles provide quick identification
|
||||
// of which agent type is running, fulfilling the "visible icons" requirement.
|
||||
const AGENT_INITIALS: Record<string, string> = {
|
||||
claude: 'C',
|
||||
codex: 'X',
|
||||
gemini: 'G',
|
||||
opencode: 'O',
|
||||
aider: 'A',
|
||||
unknown: '?'
|
||||
}
|
||||
|
||||
function agentInitial(type: string): string {
|
||||
return AGENT_INITIALS[type] ?? (type.charAt(0).toUpperCase() || '?')
|
||||
}
|
||||
|
||||
// ─── Layout Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Compute worktree circle radius that avoids overlap on the orbit ring. */
|
||||
function computeWorktreeRadius(count: number): number {
|
||||
if (count <= 1) {
|
||||
return BASE_WT_R
|
||||
}
|
||||
// Why: the max radius is derived from the chord distance between adjacent
|
||||
// worktrees on the orbit. This prevents circles from overlapping as the
|
||||
// count increases.
|
||||
const maxR = ORBIT_R * Math.sin(Math.PI / count) - 2
|
||||
return Math.max(12, Math.min(BASE_WT_R, Math.floor(maxR)))
|
||||
}
|
||||
|
||||
function computeAgentRadius(wtR: number): number {
|
||||
return Math.max(4, Math.min(BASE_AGENT_R, Math.round(wtR * 0.33)))
|
||||
}
|
||||
|
||||
function worktreeAngle(index: number, total: number): number {
|
||||
if (total === 1) {
|
||||
return -Math.PI / 2
|
||||
}
|
||||
return -Math.PI / 2 + (2 * Math.PI * index) / total
|
||||
}
|
||||
|
||||
function worktreePosition(angle: number): [number, number] {
|
||||
return [CX + ORBIT_R * Math.cos(angle), CY + ORBIT_R * Math.sin(angle)]
|
||||
}
|
||||
|
||||
/** Position agents in a small orbit within their worktree circle.
|
||||
* 1 agent: centered. 2+: arranged on a mini orbit ring, creating the
|
||||
* "concentric circles within circles" visual hierarchy. */
|
||||
function agentPositions(count: number, cx: number, cy: number, wtR: number): [number, number][] {
|
||||
if (count === 0) {
|
||||
return []
|
||||
}
|
||||
if (count === 1) {
|
||||
return [[cx, cy]]
|
||||
}
|
||||
const agentOrbitR = wtR * 0.55
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / count
|
||||
return [cx + agentOrbitR * Math.cos(angle), cy + agentOrbitR * Math.sin(angle)]
|
||||
})
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s
|
||||
}
|
||||
|
||||
// ─── SVG Animations ──────────────────────────────────────────────────────────
|
||||
// Why: defined as a constant string so all repo SVGs share the same keyframes.
|
||||
// The `cv-` prefix prevents collisions with other page styles since inline SVG
|
||||
// <style> tags leak into the global CSS scope.
|
||||
const SVG_STYLES = `
|
||||
@keyframes cv-breathe {
|
||||
0%, 100% { stroke-opacity: 0.12; }
|
||||
50% { stroke-opacity: 0.45; }
|
||||
// Why: state-to-badge mapping matches the dashboard mock — RUNNING, PAUSED,
|
||||
// DONE, IDLE labels sit in the top-right of every agent tile. `waiting` is
|
||||
// folded into PAUSED to match the blocked/paused bucket used elsewhere.
|
||||
function agentStateBadge(state: string): string {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return 'RUNNING'
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
return 'PAUSED'
|
||||
case 'done':
|
||||
return 'DONE'
|
||||
case 'idle':
|
||||
return 'IDLE'
|
||||
default:
|
||||
return state.toUpperCase()
|
||||
}
|
||||
.cv-breathe { animation: cv-breathe 3s ease-in-out infinite; }
|
||||
|
||||
@keyframes cv-orbit-spin {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -16; }
|
||||
}
|
||||
.cv-orbit-spin { animation: cv-orbit-spin 30s linear infinite; }
|
||||
|
||||
@keyframes cv-pulse-ring {
|
||||
0%, 100% { stroke-opacity: 0.15; }
|
||||
50% { stroke-opacity: 0.55; }
|
||||
}
|
||||
.cv-pulse-ring { animation: cv-pulse-ring 2s ease-in-out infinite; }
|
||||
|
||||
.cv-wt { cursor: pointer; }
|
||||
.cv-wt > .cv-wt-bg {
|
||||
transition: stroke-opacity 200ms ease, fill-opacity 200ms ease;
|
||||
}
|
||||
.cv-wt:hover > .cv-wt-bg {
|
||||
stroke-opacity: 0.75;
|
||||
fill-opacity: 0.28;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// ─── RepoSystem Component ────────────────────────────────────────────────────
|
||||
// Why: memoized so tooltip state changes in the parent don't re-render SVGs.
|
||||
// Why: renders each repo as a card containing a grid of rounded-square
|
||||
// worktree tiles. Worktrees are sorted by `latestActivityAt` descending so the
|
||||
// most recently active tile sits at the top-left. Each tile shows the dominant
|
||||
// state via a left color bar + pill, the worktree name + branch, and a compact
|
||||
// list of agent rows. "Done" worktrees get an explicit dismiss (X) button so
|
||||
// clicking the tile itself navigates (like any other state) while dismissal is
|
||||
// opt-in.
|
||||
export type RepoSystemProps = {
|
||||
group: DashboardRepoGroup
|
||||
onClick: (worktreeId: string) => void
|
||||
onDismiss: (worktreeId: string) => void
|
||||
onShowTooltip: (e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => void
|
||||
onHideTooltip: () => void
|
||||
}
|
||||
|
|
@ -147,276 +70,262 @@ export type RepoSystemProps = {
|
|||
const RepoSystem = React.memo(function RepoSystem({
|
||||
group,
|
||||
onClick,
|
||||
onDismiss,
|
||||
onShowTooltip,
|
||||
onHideTooltip
|
||||
}: RepoSystemProps) {
|
||||
const activeWorktrees = group.worktrees.filter((wt) => wt.agents.length > 0)
|
||||
const wtR = computeWorktreeRadius(activeWorktrees.length)
|
||||
const agentR = computeAgentRadius(wtR)
|
||||
const showInitials = agentR >= 6
|
||||
// Why: sort most-recent-first so the tile the user most likely cares about
|
||||
// sits at the top-left corner of the repo's grid. Worktrees with no activity
|
||||
// timestamp (latestActivityAt === 0) fall to the end in their natural order.
|
||||
const activeWorktrees = group.worktrees
|
||||
.filter((wt) => wt.agents.length > 0)
|
||||
.slice()
|
||||
.sort((a, b) => b.latestActivityAt - a.latestActivityAt)
|
||||
const totalAgents = activeWorktrees.reduce((s, w) => s + w.agents.length, 0)
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[280px]">
|
||||
<svg viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`} className="w-full" style={{ aspectRatio: '1' }}>
|
||||
<style>{SVG_STYLES}</style>
|
||||
<defs>
|
||||
<filter id={`cv-glow-${group.repo.id}`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id={`cv-bg-${group.repo.id}`} cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor={group.repo.badgeColor} stopOpacity="0.08" />
|
||||
<stop offset="65%" stopColor={group.repo.badgeColor} stopOpacity="0.025" />
|
||||
<stop offset="100%" stopColor={group.repo.badgeColor} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Background radial gradient — gives each repo a subtle ambient glow */}
|
||||
<circle cx={CX} cy={CY} r={REPO_RING_R + 8} fill={`url(#cv-bg-${group.repo.id})`} />
|
||||
|
||||
{/* Outermost ring: repo boundary */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={REPO_RING_R}
|
||||
fill="none"
|
||||
stroke={group.repo.badgeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.35"
|
||||
<div className="w-full max-w-[560px] rounded-lg border-2 border-border bg-accent/10 p-2.5">
|
||||
{/* Repo header */}
|
||||
<div className="mb-2 flex items-center gap-1.5 px-0.5">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: group.repo.badgeColor }}
|
||||
/>
|
||||
|
||||
{/* Middle ring: worktree orbit (animated dashed ring) */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={ORBIT_R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.08"
|
||||
strokeDasharray="3 5"
|
||||
className="cv-orbit-spin"
|
||||
/>
|
||||
|
||||
{/* Inner decorative ring */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={INNER_DECOR_R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.06"
|
||||
/>
|
||||
|
||||
{/* Radial spokes connecting center to each worktree */}
|
||||
{activeWorktrees.map((_, i) => {
|
||||
const angle = worktreeAngle(i, activeWorktrees.length)
|
||||
const [wx, wy] = worktreePosition(angle)
|
||||
return (
|
||||
<line
|
||||
key={`spoke-${i}`}
|
||||
x1={CX}
|
||||
y1={CY}
|
||||
x2={wx}
|
||||
y2={wy}
|
||||
stroke={group.repo.badgeColor}
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.07"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center: repo name + stats */}
|
||||
<text
|
||||
x={CX}
|
||||
y={CY - 4}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground"
|
||||
style={{ fontSize: 12, fontWeight: 600, letterSpacing: '0.01em' }}
|
||||
>
|
||||
{truncate(group.repo.displayName, 16)}
|
||||
</text>
|
||||
<text
|
||||
x={CX}
|
||||
y={CY + 10}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground"
|
||||
style={{ fontSize: 8.5 }}
|
||||
>
|
||||
{activeWorktrees.length} worktree{activeWorktrees.length !== 1 ? 's' : ''}
|
||||
{' \u00B7 '}
|
||||
{totalAgents} agent{totalAgents !== 1 ? 's' : ''}
|
||||
</text>
|
||||
|
||||
{/* Attention badge at center (if any agents need attention) */}
|
||||
<span className="truncate text-[12px] font-semibold text-foreground">
|
||||
{truncate(group.repo.displayName, 28)}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
|
||||
{activeWorktrees.length} wt · {totalAgents} agent{totalAgents !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{group.attentionCount > 0 && (
|
||||
<>
|
||||
<circle cx={CX + 30} cy={CY - 14} r={8} fill="#f59e0b" fillOpacity="0.2" />
|
||||
<text
|
||||
x={CX + 30}
|
||||
y={CY - 10.5}
|
||||
textAnchor="middle"
|
||||
fill="#f59e0b"
|
||||
style={{ fontSize: 9, fontWeight: 700 }}
|
||||
>
|
||||
{group.attentionCount}
|
||||
</text>
|
||||
</>
|
||||
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-[9.5px] font-semibold text-amber-500">
|
||||
{group.attentionCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Worktree nodes on the orbit ring ── */}
|
||||
{activeWorktrees.map((card, i) => {
|
||||
const angle = worktreeAngle(i, activeWorktrees.length)
|
||||
const [wx, wy] = worktreePosition(angle)
|
||||
const sc = stateColor(card.dominantState)
|
||||
const aPos = agentPositions(card.agents.length, wx, wy, wtR)
|
||||
const branchName = card.worktree.branch?.replace(/^refs\/heads\//, '')
|
||||
|
||||
return (
|
||||
<g
|
||||
{activeWorktrees.length === 0 ? (
|
||||
<div className="px-1 py-4 text-center text-[10px] italic text-muted-foreground/40">
|
||||
No active agents
|
||||
</div>
|
||||
) : (
|
||||
// Why: each worktree gets its own outlined group so the visual
|
||||
// hierarchy reads repo → worktree → agents. Agents inside a worktree
|
||||
// are still laid out as fixed-size square tiles.
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeWorktrees.map((card) => (
|
||||
<WorktreeGroup
|
||||
key={card.worktree.id}
|
||||
className="cv-wt"
|
||||
onClick={() => onClick(card.worktree.id)}
|
||||
onMouseLeave={onHideTooltip}
|
||||
>
|
||||
{/* Worktree circle background */}
|
||||
<circle
|
||||
className="cv-wt-bg"
|
||||
cx={wx}
|
||||
cy={wy}
|
||||
r={wtR}
|
||||
fill={sc.fill}
|
||||
fillOpacity="0.14"
|
||||
stroke={sc.fill}
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.45"
|
||||
/>
|
||||
card={card}
|
||||
onClick={onClick}
|
||||
onDismiss={onDismiss}
|
||||
onShowTooltip={onShowTooltip}
|
||||
onHideTooltip={onHideTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
{/* Glow ring: working state (gentle breathing animation) */}
|
||||
{card.dominantState === 'working' && (
|
||||
<circle
|
||||
cx={wx}
|
||||
cy={wy}
|
||||
r={wtR + 4}
|
||||
fill="none"
|
||||
stroke={sc.glow}
|
||||
strokeWidth="1"
|
||||
className="cv-breathe"
|
||||
filter={`url(#cv-glow-${group.repo.id})`}
|
||||
/>
|
||||
)}
|
||||
// ─── WorktreeGroup ───────────────────────────────────────────────────────────
|
||||
// Why: outlines a single worktree's agents so the repo → worktree → agent
|
||||
// hierarchy is visually clear. Shows the worktree name/branch in a small
|
||||
// header above its agent tiles.
|
||||
type WorktreeGroupProps = {
|
||||
card: DashboardWorktreeCard
|
||||
onClick: (worktreeId: string) => void
|
||||
onDismiss: (worktreeId: string) => void
|
||||
onShowTooltip: (e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => void
|
||||
onHideTooltip: () => void
|
||||
}
|
||||
|
||||
{/* Pulse ring: blocked state (attention-drawing pulse) */}
|
||||
{card.dominantState === 'blocked' && (
|
||||
<circle
|
||||
cx={wx}
|
||||
cy={wy}
|
||||
r={wtR + 5}
|
||||
fill="none"
|
||||
stroke={sc.glow}
|
||||
strokeWidth="1.5"
|
||||
className="cv-pulse-ring"
|
||||
/>
|
||||
)}
|
||||
const WorktreeGroup = React.memo(function WorktreeGroup({
|
||||
card,
|
||||
onClick,
|
||||
onDismiss,
|
||||
onShowTooltip,
|
||||
onHideTooltip
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
<div className="rounded-md border-2 border-border/80 bg-background/20 p-1.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 px-0.5">
|
||||
<span className="truncate text-[11px] font-medium text-muted-foreground">
|
||||
{truncate(card.worktree.displayName, 28)}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[9.5px] text-muted-foreground/50">
|
||||
{card.agents.length} agent{card.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
// Why: fixed-width columns (not 1fr) so each agent tile renders at a
|
||||
// constant size regardless of container width or how many tiles are
|
||||
// in the row. aspect-square on the tile then gives a constant height.
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, 120px)' }}
|
||||
>
|
||||
{card.agents.map((agent) => (
|
||||
<AgentSquareTile
|
||||
key={agent.paneKey}
|
||||
agent={agent}
|
||||
card={card}
|
||||
onClick={onClick}
|
||||
onDismiss={onDismiss}
|
||||
onShowTooltip={onShowTooltip}
|
||||
onHideTooltip={onHideTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
{/* Agent orbit ring (visible when 2+ agents — creates the nested
|
||||
concentric pattern within each worktree) */}
|
||||
{card.agents.length >= 2 && (
|
||||
<circle
|
||||
cx={wx}
|
||||
cy={wy}
|
||||
r={wtR * 0.55}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.4"
|
||||
strokeOpacity="0.12"
|
||||
strokeDasharray="1.5 2.5"
|
||||
/>
|
||||
)}
|
||||
// ─── AgentSquareTile ─────────────────────────────────────────────────────────
|
||||
// Why: a single agent's square card — matches the dashboard mock. The tile is
|
||||
// aspect-square and shows a state badge top-right (RUNNING / PAUSED / DONE /
|
||||
// IDLE), the agent name prominently, and the parent worktree's name/path
|
||||
// underneath. Hover surfaces the full tooltip via the same mechanism the old
|
||||
// row-based layout used.
|
||||
type AgentSquareTileProps = {
|
||||
agent: DashboardAgentRow
|
||||
card: DashboardWorktreeCard
|
||||
onClick: (worktreeId: string) => void
|
||||
onDismiss: (worktreeId: string) => void
|
||||
onShowTooltip: (e: React.MouseEvent, data: Omit<TooltipData, 'x' | 'y'>) => void
|
||||
onHideTooltip: () => void
|
||||
}
|
||||
|
||||
{/* Agent icons — each has its own hover for per-agent tooltip */}
|
||||
{card.agents.map((agent, j) => {
|
||||
const [ax, ay] = aPos[j]
|
||||
const ac = stateColor(agent.state)
|
||||
return (
|
||||
<g
|
||||
key={agent.paneKey}
|
||||
onMouseMove={(e) => {
|
||||
// Why: stopPropagation prevents the worktree-level
|
||||
// onMouseLeave from firing when moving between agents
|
||||
// within the same worktree circle.
|
||||
e.stopPropagation()
|
||||
onShowTooltip(e, {
|
||||
agentLabel: formatAgentTypeLabel(agent.agentType),
|
||||
state: agent.state,
|
||||
worktreeName: card.worktree.displayName,
|
||||
branchName
|
||||
})
|
||||
}}
|
||||
onMouseLeave={onHideTooltip}
|
||||
>
|
||||
{/* Invisible larger hit area for easier hovering */}
|
||||
<circle cx={ax} cy={ay} r={Math.max(agentR + 4, 12)} fill="transparent" />
|
||||
<circle
|
||||
cx={ax}
|
||||
cy={ay}
|
||||
r={agentR}
|
||||
fill={ac.fill}
|
||||
fillOpacity="0.9"
|
||||
stroke={ac.glow}
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.4"
|
||||
/>
|
||||
{showInitials && (
|
||||
<text
|
||||
x={ax}
|
||||
y={ay + Math.round(agentR * 0.4)}
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
style={{
|
||||
fontSize: Math.max(7, Math.round(agentR * 1.15)),
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{agentInitial(agent.agentType)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
const AgentSquareTile = React.memo(function AgentSquareTile({
|
||||
agent,
|
||||
card,
|
||||
onClick,
|
||||
onDismiss
|
||||
}: AgentSquareTileProps) {
|
||||
const sc = stateColor(agent.state)
|
||||
const isWorking = agent.state === 'working'
|
||||
const isBlocked = agent.state === 'blocked' || agent.state === 'waiting'
|
||||
const isDone = agent.state === 'done'
|
||||
const agentLabel = formatAgentTypeLabel(agent.agentType)
|
||||
// Why: show the submitted prompt — the user's question/task — inside the
|
||||
// tile. statusText (e.g. "Turn complete") is transient agent chatter and
|
||||
// not what the user wants to see at a glance; fall back to it only if no
|
||||
// prompt was captured.
|
||||
const promptText = (agent.promptText || agent.statusText || '').trim()
|
||||
|
||||
{/* Worktree label below the circle */}
|
||||
<text
|
||||
x={wx}
|
||||
y={wy + wtR + 12}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground/80"
|
||||
style={{ fontSize: 8.5, fontWeight: 500 }}
|
||||
>
|
||||
{truncate(card.worktree.displayName, 14)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
const history = agent.entry?.stateHistory ?? []
|
||||
const blocks = [
|
||||
...history.map((h) => ({
|
||||
state: h.state,
|
||||
title: `${h.state}${h.statusText ? ` — ${h.statusText}` : ''}`
|
||||
})),
|
||||
{
|
||||
state: agent.state,
|
||||
title: `${agent.state}${agent.statusText ? ` — ${agent.statusText}` : ''}`
|
||||
}
|
||||
]
|
||||
|
||||
{/* Empty state when no worktrees have active agents */}
|
||||
{activeWorktrees.length === 0 && (
|
||||
<text
|
||||
x={CX}
|
||||
y={CY + 26}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground/40"
|
||||
style={{ fontSize: 9, fontStyle: 'italic' }}
|
||||
// Why: fixed-width blocks (matching the old hover popover) so each turn is
|
||||
// legibly sized and doesn't stretch with the tile. Cap count to fit within
|
||||
// the tile width without wrapping.
|
||||
const MAX_BLOCKS = 8
|
||||
const visibleBlocks = blocks.slice(-MAX_BLOCKS)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex aspect-square cursor-pointer flex-col overflow-hidden rounded-lg border-2 bg-background/40 p-1.5',
|
||||
'transition-colors hover:bg-background/70'
|
||||
)}
|
||||
style={{ borderColor: `${sc.fill}88` }}
|
||||
onClick={() => onClick(card.worktree.id)}
|
||||
>
|
||||
{/* Working/blocked glow overlay — same breathing treatment the
|
||||
worktree-level tile had, now applied per-agent. */}
|
||||
{(isWorking || isBlocked) && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 animate-pulse rounded-lg"
|
||||
style={{
|
||||
boxShadow: `inset 0 0 14px ${sc.glow}40`,
|
||||
animationDuration: isBlocked ? '1.5s' : '3s'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top row: yellow bell (done) on the left, state badge + dismiss on the right. */}
|
||||
<div className="relative flex items-start gap-1">
|
||||
{isDone && <FilledBellIcon className="size-3.5 shrink-0 text-amber-500 drop-shadow-sm" />}
|
||||
<span
|
||||
className="ml-auto rounded px-1 py-[1px] text-[8px] font-semibold uppercase tracking-wider"
|
||||
style={{ backgroundColor: `${sc.fill}22`, color: sc.fill }}
|
||||
>
|
||||
{agentStateBadge(agent.state)}
|
||||
</span>
|
||||
{isDone && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-3.5 items-center justify-center rounded text-muted-foreground/70 opacity-60 hover:bg-background/70 hover:text-foreground hover:opacity-100 group-hover:opacity-100"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
// Why: stopPropagation so the tile's own onClick (which
|
||||
// navigates to the terminal) doesn't fire.
|
||||
e.stopPropagation()
|
||||
onDismiss(card.worktree.id)
|
||||
}}
|
||||
aria-label="Dismiss completed agent"
|
||||
title="Remove from view"
|
||||
>
|
||||
No active agents
|
||||
</text>
|
||||
<X size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Body: agent icon then prompt stacked vertically at the top of the
|
||||
tile. The icon replaces the textual agent name (claude/codex/…)
|
||||
with the same SVG/favicon we render in the workspace selector. */}
|
||||
<div className="relative mt-1 min-w-0">
|
||||
<span
|
||||
className="flex size-4 shrink-0 items-center justify-center text-foreground"
|
||||
title={agentLabel}
|
||||
aria-label={agentLabel}
|
||||
>
|
||||
<AgentIcon agent={agent.agentType as TuiAgent} size={14} />
|
||||
</span>
|
||||
{/* Current prompt/status — the action in this turn. Line-clamped to
|
||||
2 rows so it never pushes the tile larger than aspect-square. */}
|
||||
{promptText && (
|
||||
<div
|
||||
className="mt-1 overflow-hidden text-[11px] font-medium leading-snug text-foreground/90"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2
|
||||
}}
|
||||
title={promptText}
|
||||
>
|
||||
{promptText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Past-turn blocks — pinned to the bottom of the tile so they form a
|
||||
consistent baseline across agents regardless of prompt length. */}
|
||||
{visibleBlocks.length > 0 && (
|
||||
<div className="relative mt-auto flex flex-wrap items-center gap-[2px] pt-1">
|
||||
{visibleBlocks.map((block, i) => {
|
||||
const bc = stateColor(block.state)
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${block.state}`}
|
||||
title={block.title}
|
||||
className="h-1.5 w-3 rounded-sm"
|
||||
style={{ backgroundColor: bc.fill, opacity: 0.8 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -42,37 +42,10 @@ export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): {
|
|||
}
|
||||
}
|
||||
|
||||
// Build a set of tab IDs that have live explicit (non-heuristic) agents.
|
||||
// Why: heuristic paneKeys are "heuristic:{tabId}"; explicit ones are
|
||||
// "{tabId}:{paneId}". When an agent starts reporting explicit status, the
|
||||
// heuristic row vanishes — but that's a promotion, not a completion. We
|
||||
// must not retain the heuristic entry as "done" when its tab still has a
|
||||
// live explicit agent.
|
||||
const tabsWithExplicitAgent = new Set<string>()
|
||||
for (const paneKey of current.keys()) {
|
||||
if (!paneKey.startsWith('heuristic:')) {
|
||||
tabsWithExplicitAgent.add(paneKey.split(':')[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Detect agents that were present last render but are gone now
|
||||
const disappeared: RetainedAgent[] = []
|
||||
for (const [paneKey, prev] of prevAgentsRef.current) {
|
||||
if (
|
||||
!current.has(paneKey) &&
|
||||
!retainedRef.current.has(paneKey) &&
|
||||
// Why: don't retain heuristic "idle" agents — they weren't doing
|
||||
// meaningful work, so showing them as "done" would be misleading.
|
||||
prev.row.state !== 'idle'
|
||||
) {
|
||||
// Why: if a heuristic agent vanished because an explicit agent for the
|
||||
// same tab took over, that's not a completion — skip retention.
|
||||
if (paneKey.startsWith('heuristic:')) {
|
||||
const tabId = paneKey.slice('heuristic:'.length)
|
||||
if (tabsWithExplicitAgent.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!current.has(paneKey) && !retainedRef.current.has(paneKey) && prev.row.state !== 'idle') {
|
||||
disappeared.push({ ...prev.row, worktreeId: prev.worktreeId })
|
||||
}
|
||||
}
|
||||
|
|
@ -94,17 +67,6 @@ export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): {
|
|||
if (!existingWorktreeIds.has(ra.worktreeId)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
// Why: if a retained heuristic agent's tab now has an explicit agent,
|
||||
// the heuristic was superseded — evict it. This handles the case where
|
||||
// the heuristic was retained before the explicit status arrived.
|
||||
if (key.startsWith('heuristic:')) {
|
||||
const tabId = key.slice('heuristic:'.length)
|
||||
if (tabsWithExplicitAgent.has(tabId)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, PanelTopOpen } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { countWorkingAgents } from '../../lib/agent-status'
|
||||
import AgentDashboard from '../dashboard/AgentDashboard'
|
||||
|
||||
const MIN_HEIGHT = 120
|
||||
const DEFAULT_HEIGHT = 240
|
||||
const HEADER_HEIGHT = 30
|
||||
const STORAGE_KEY = 'orca.dashboardSidebarPanel'
|
||||
|
||||
type PersistedState = {
|
||||
height: number
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function loadPersistedState(): PersistedState {
|
||||
if (typeof window === 'undefined') {
|
||||
return { height: DEFAULT_HEIGHT, collapsed: false }
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return { height: DEFAULT_HEIGHT, collapsed: false }
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedState>
|
||||
return {
|
||||
height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_HEIGHT,
|
||||
collapsed: typeof parsed.collapsed === 'boolean' ? parsed.collapsed : false
|
||||
}
|
||||
} catch {
|
||||
return { height: DEFAULT_HEIGHT, collapsed: false }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a persistent bottom section of the right sidebar that always shows the
|
||||
// AgentDashboard, independent of which activity tab the user has open. Users
|
||||
// drag the top edge to resize upward (within the available sidebar height)
|
||||
// and can fully collapse to a single row. The pop-out button reuses the
|
||||
// existing detached-window flow from window.api.ui.openAgentDashboard().
|
||||
export default function DashboardBottomPanel(): React.JSX.Element {
|
||||
const initial = useMemo(loadPersistedState, [])
|
||||
const [height, setHeight] = useState<number>(initial.height)
|
||||
const [collapsed, setCollapsed] = useState<boolean>(initial.collapsed)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const resizeStateRef = useRef<{
|
||||
startY: number
|
||||
startHeight: number
|
||||
maxHeight: number
|
||||
} | null>(null)
|
||||
|
||||
const agentInputs = useAppStore(
|
||||
useShallow((s) => ({
|
||||
tabsByWorktree: s.tabsByWorktree,
|
||||
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
|
||||
worktreesByRepo: s.worktreesByRepo
|
||||
}))
|
||||
)
|
||||
const activeAgentCount = useMemo(() => countWorkingAgents(agentInputs), [agentInputs])
|
||||
|
||||
// Why: persist height + collapsed via localStorage (renderer-only) so the
|
||||
// layout survives reloads without threading through the main-process UI
|
||||
// store. Debounce writes so continuous drag doesn't spam localStorage.
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ height, collapsed }))
|
||||
} catch {
|
||||
// ignore quota / privacy-mode errors
|
||||
}
|
||||
}, 150)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [height, collapsed])
|
||||
|
||||
const onResizeMove = useCallback((event: MouseEvent) => {
|
||||
const state = resizeStateRef.current
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
const deltaY = state.startY - event.clientY
|
||||
const next = Math.max(MIN_HEIGHT, Math.min(state.maxHeight, state.startHeight + deltaY))
|
||||
setHeight(next)
|
||||
}, [])
|
||||
|
||||
const onResizeEnd = useCallback(() => {
|
||||
resizeStateRef.current = null
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
window.removeEventListener('mousemove', onResizeMove)
|
||||
window.removeEventListener('mouseup', onResizeEnd)
|
||||
}, [onResizeMove])
|
||||
|
||||
const onResizeStart = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
if (collapsed) {
|
||||
setCollapsed(false)
|
||||
}
|
||||
// Why: the panel's parent sidebar column is the constraint. Cap
|
||||
// expansion so the dashboard can't push the activity bar or panel
|
||||
// content to a zero-height strip. Leave 120px for the active panel.
|
||||
const sidebarEl = containerRef.current?.parentElement
|
||||
const sidebarHeight = sidebarEl?.getBoundingClientRect().height ?? 800
|
||||
const maxHeight = Math.max(MIN_HEIGHT, sidebarHeight - 160)
|
||||
resizeStateRef.current = {
|
||||
startY: event.clientY,
|
||||
startHeight: height,
|
||||
maxHeight
|
||||
}
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
window.addEventListener('mousemove', onResizeMove)
|
||||
window.addEventListener('mouseup', onResizeEnd)
|
||||
},
|
||||
[collapsed, height, onResizeMove, onResizeEnd]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onResizeMove)
|
||||
window.removeEventListener('mouseup', onResizeEnd)
|
||||
}
|
||||
}, [onResizeMove, onResizeEnd])
|
||||
|
||||
const effectiveHeight = collapsed ? HEADER_HEIGHT : height
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex shrink-0 flex-col border-t border-border bg-sidebar"
|
||||
style={{ height: effectiveHeight }}
|
||||
>
|
||||
{/* Resize handle. Hidden while collapsed — user must expand first. */}
|
||||
{!collapsed ? (
|
||||
<div
|
||||
className="absolute left-0 right-0 h-[6px] -mt-[3px] cursor-row-resize z-10 hover:bg-ring/20 active:bg-ring/30 transition-colors"
|
||||
onMouseDown={onResizeStart}
|
||||
aria-label="Resize dashboard panel"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Header: title + controls */}
|
||||
<div
|
||||
className="flex shrink-0 items-center px-2 gap-1 cursor-pointer select-none"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-5 h-5 text-muted-foreground hover:text-foreground"
|
||||
aria-label={collapsed ? 'Expand dashboard' : 'Collapse dashboard'}
|
||||
>
|
||||
{collapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Agents
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums ml-1">
|
||||
{activeAgentCount}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-5 h-5 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void window.api.ui.openAgentDashboard()
|
||||
}}
|
||||
aria-label="Open dashboard in new window"
|
||||
>
|
||||
<PanelTopOpen size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
Open in new window
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Body: full AgentDashboard */}
|
||||
{!collapsed ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AgentDashboard />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Files, Search, GitBranch, ListChecks, LayoutDashboard, PanelRight } from 'lucide-react'
|
||||
import { Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||
|
|
@ -19,7 +19,7 @@ import FileExplorer from './FileExplorer'
|
|||
import SourceControl from './SourceControl'
|
||||
import SearchPanel from './Search'
|
||||
import ChecksPanel from './ChecksPanel'
|
||||
import AgentDashboard from '../dashboard/AgentDashboard'
|
||||
import DashboardBottomPanel from './DashboardBottomPanel'
|
||||
|
||||
const MIN_WIDTH = 220
|
||||
// Why: long file names (e.g. construction drawing sheets, multi-part document
|
||||
|
|
@ -112,12 +112,6 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
|
|||
title: 'Checks',
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`,
|
||||
gitOnly: true
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
icon: LayoutDashboard,
|
||||
title: 'Agent Dashboard',
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}D`
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -174,7 +168,6 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
{effectiveTab === 'search' && <SearchPanel />}
|
||||
{effectiveTab === 'source-control' && <SourceControl />}
|
||||
{effectiveTab === 'checks' && <ChecksPanel />}
|
||||
{effectiveTab === 'dashboard' && <AgentDashboard />}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -255,6 +248,13 @@ function RightSidebarInner(): React.JSX.Element {
|
|||
|
||||
{panelContent}
|
||||
|
||||
{/* Why: persistent bottom-docked dashboard section. Lives below
|
||||
whichever activity panel is active so users can glance at agent
|
||||
status without losing their current sidebar tab. */}
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<DashboardBottomPanel />
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Resize handle on LEFT side */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, Copy, Download, FolderOpen, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import type { AgentHookInstallStatus } from '../../../../shared/agent-hook-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -17,6 +19,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
|
|||
|
||||
type CliSectionProps = {
|
||||
currentPlatform: string
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
const ORCA_CLI_SKILL_INSTALL_COMMAND =
|
||||
|
|
@ -45,7 +49,11 @@ function getInstallDescription(platform: string): string {
|
|||
return 'CLI registration is not yet available on this platform.'
|
||||
}
|
||||
|
||||
export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Element {
|
||||
export function CliSection({
|
||||
currentPlatform,
|
||||
settings,
|
||||
updateSettings
|
||||
}: CliSectionProps): React.JSX.Element {
|
||||
const [status, setStatus] = useState<CliInstallStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
|
@ -89,6 +97,19 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
|||
}
|
||||
}
|
||||
|
||||
const handleAutoInstallHooksChange = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
await updateSettings({ autoInstallAgentHooks: enabled })
|
||||
toast.success(
|
||||
enabled
|
||||
? 'Native hook management enabled. Orca will keep hooks installed on launch.'
|
||||
: 'Native hook management disabled. Orca will no longer rewrite hook config on launch.'
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update hook setting.')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatus = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
|
|
@ -294,9 +315,34 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
Orca installs Claude and Codex global hooks so agent lifecycle updates flow into the
|
||||
sidebar automatically.
|
||||
Opt in if you want Orca to manage Claude and Codex global hooks for you. This
|
||||
updates the user-wide hook config in <code>~/.claude</code> and{' '}
|
||||
<code>~/.codex</code>.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-background/40 px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium">Keep hooks installed</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
When enabled, Orca reconciles its managed hooks on startup. Disabled by default
|
||||
because this mutates global agent config.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.autoInstallAgentHooks}
|
||||
onClick={() => void handleAutoInstallHooksChange(!settings.autoInstallAgentHooks)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.autoInstallAgentHooks ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
} ${busyHook !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
disabled={busyHook !== null}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.autoInstallAgentHooks ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{(
|
||||
[
|
||||
|
|
@ -304,7 +350,8 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
|||
['codex', hookStatuses.codex]
|
||||
] as const
|
||||
).map(([agent, status]) => {
|
||||
const installed = status?.managedHooksPresent === true
|
||||
const installed = status?.state === 'installed'
|
||||
const needsRepair = status?.state === 'partial'
|
||||
return (
|
||||
<div
|
||||
key={agent}
|
||||
|
|
@ -332,16 +379,27 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
|||
Installed
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busyHook !== null || hookStatuses.loading}
|
||||
onClick={() => void handleInstallHook(agent)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{busyHook === agent ? 'Installing…' : 'Install'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{needsRepair && (
|
||||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
||||
Partial
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busyHook !== null || hookStatuses.loading}
|
||||
onClick={() => void handleInstallHook(agent)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{busyHook === agent
|
||||
? 'Installing…'
|
||||
: needsRepair
|
||||
? 'Repair'
|
||||
: 'Install'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -438,6 +438,8 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
<CliSection
|
||||
key="cli"
|
||||
currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'}
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, GENERAL_CACHE_TIMER_SEARCH_ENTRIES) ? (
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
|||
function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
|
||||
return {
|
||||
state: overrides.state ?? 'working',
|
||||
summary: overrides.summary ?? '',
|
||||
next: overrides.next ?? '',
|
||||
statusText: overrides.statusText ?? '',
|
||||
promptText: overrides.promptText ?? '',
|
||||
stateStartedAt: overrides.stateStartedAt ?? NOW - 30_000,
|
||||
updatedAt: overrides.updatedAt ?? NOW - 30_000,
|
||||
source: overrides.source ?? 'agent',
|
||||
agentType: overrides.agentType ?? 'codex',
|
||||
|
|
@ -37,11 +38,11 @@ describe('buildAgentStatusHoverRows', () => {
|
|||
const rows = buildAgentStatusHoverRows(
|
||||
[makeTab({ id: 'tab-1', title: 'codex working' })],
|
||||
{
|
||||
'tab-1:1': makeEntry({ paneKey: 'tab-1:1', summary: 'Fix login bug' }),
|
||||
'tab-1:1': makeEntry({ paneKey: 'tab-1:1', statusText: 'Fix login bug' }),
|
||||
'tab-1:2': makeEntry({
|
||||
paneKey: 'tab-1:2',
|
||||
state: 'blocked',
|
||||
summary: 'Waiting on failing test'
|
||||
statusText: 'Waiting on failing test'
|
||||
})
|
||||
},
|
||||
NOW
|
||||
|
|
@ -58,7 +59,7 @@ describe('buildAgentStatusHoverRows', () => {
|
|||
expect(rows[0]?.kind).toBe('heuristic')
|
||||
})
|
||||
|
||||
it('keeps stale explicit summaries but orders by heuristic urgency', () => {
|
||||
it('keeps stale explicit status text but orders by heuristic urgency', () => {
|
||||
const rows = buildAgentStatusHoverRows(
|
||||
[
|
||||
makeTab({ id: 'tab-a', title: 'codex permission needed' }),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
formatElapsedAgentTime,
|
||||
formatAgentTypeLabel,
|
||||
inferAgentTypeFromTitle,
|
||||
isExplicitAgentStatusFresh
|
||||
|
|
@ -241,24 +242,27 @@ function AgentRow({ row, now }: { row: HoverRow; now: number }): React.JSX.Eleme
|
|||
{formatAgentTypeLabel(row.agentType)}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/50">
|
||||
{formatTimeAgo(row.explicit.updatedAt, now)}
|
||||
{formatElapsedAgentTime(row.explicit.stateStartedAt, now)}
|
||||
</span>
|
||||
</div>
|
||||
{row.explicit.summary && (
|
||||
{row.explicit.statusText && (
|
||||
<div className={cn('pl-4 text-[11px] leading-snug', !isFresh && 'opacity-60')}>
|
||||
{row.explicit.summary}
|
||||
{row.explicit.statusText}
|
||||
</div>
|
||||
)}
|
||||
{row.explicit.next && (
|
||||
{row.explicit.promptText && (
|
||||
<div
|
||||
className={cn(
|
||||
'pl-4 text-[10.5px] leading-snug text-muted-foreground',
|
||||
!isFresh && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
Next: {row.explicit.next}
|
||||
Prompt: {row.explicit.promptText}
|
||||
</div>
|
||||
)}
|
||||
<div className="pl-4 text-[10px] text-muted-foreground/50">
|
||||
Updated {formatTimeAgo(row.explicit.updatedAt, now)}
|
||||
</div>
|
||||
{!isFresh && (
|
||||
<div className="pl-4 text-[10px] italic text-muted-foreground/60">
|
||||
Showing last reported task details; live terminal state has taken precedence.
|
||||
|
|
@ -315,12 +319,17 @@ const AgentStatusHover = React.memo(function AgentStatusHover({
|
|||
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
|
||||
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
|
||||
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now())
|
||||
}, [agentStatusByPaneKey, agentStatusEpoch, tabs])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Why: timestamps in the hover are relative labels, so recompute "now" when
|
||||
// the source rows change or a stored freshness boundary expires, rather than
|
||||
// on an interval that would churn the sidebar every minute.
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
const now = useMemo(() => Date.now(), [agentStatusByPaneKey, agentStatusEpoch, tabs])
|
||||
const rows = useMemo(
|
||||
() => buildAgentStatusHoverRows(tabs, agentStatusByPaneKey, now),
|
||||
[tabs, agentStatusByPaneKey, now]
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ describe('useIpcEvents updater integration', () => {
|
|||
credentialResolvedListenerRef.current = listener
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
agentStatus: {
|
||||
onSet: () => () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import './assets/main.css'
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import DashboardApp from './DashboardApp'
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
import('react-grab').then(({ init }) => init())
|
||||
|
|
@ -18,8 +19,13 @@ function applySystemTheme(): void {
|
|||
applySystemTheme()
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applySystemTheme)
|
||||
|
||||
// Why: the detached agent dashboard window loads the same renderer bundle but
|
||||
// with ?view=agent-dashboard in its URL. Mount the lean DashboardApp in that
|
||||
// case so the second window does not try to claim PTYs or write workspace
|
||||
// session — it just renders AgentDashboard over live IPC-pushed state.
|
||||
const isDashboardView =
|
||||
new URLSearchParams(window.location.search).get('view') === 'agent-dashboard'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
<StrictMode>{isDashboardView ? <DashboardApp /> : <App />}</StrictMode>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -78,18 +78,27 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
|||
|
||||
setAgentStatus: (paneKey, payload, terminalTitle) => {
|
||||
set((s) => {
|
||||
const now = Date.now()
|
||||
const existing = s.agentStatusByPaneKey[paneKey]
|
||||
const effectiveTitle = terminalTitle ?? existing?.terminalTitle
|
||||
|
||||
// Why: build up a rolling log of state transitions so the dashboard can
|
||||
// render activity blocks showing what the agent has been doing. Only push
|
||||
// when the state actually changes to avoid duplicate entries from summary-
|
||||
// when the state actually changes to avoid duplicate entries from detail-
|
||||
// only updates within the same state.
|
||||
let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? []
|
||||
if (existing && existing.state !== payload.state) {
|
||||
history = [
|
||||
...history,
|
||||
{ state: existing.state, summary: existing.summary, startedAt: existing.updatedAt }
|
||||
// Why: history should capture how long the prior state actually
|
||||
// lasted. Using updatedAt here would reset durations whenever an
|
||||
// agent posts a progress-only status text update without changing state.
|
||||
{
|
||||
state: existing.state,
|
||||
statusText: existing.statusText,
|
||||
promptText: existing.promptText,
|
||||
startedAt: existing.stateStartedAt
|
||||
}
|
||||
]
|
||||
if (history.length > AGENT_STATE_HISTORY_MAX) {
|
||||
history = history.slice(history.length - AGENT_STATE_HISTORY_MAX)
|
||||
|
|
@ -98,9 +107,18 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
|||
|
||||
const entry: AgentStatusEntry = {
|
||||
state: payload.state,
|
||||
summary: payload.summary,
|
||||
next: payload.next,
|
||||
updatedAt: Date.now(),
|
||||
statusText: payload.statusText,
|
||||
// Why: only UserPromptSubmit-style hooks carry the raw prompt text.
|
||||
// Later events in the same turn (permission, stop) must retain the
|
||||
// last submitted prompt so the dashboard still shows what this turn
|
||||
// is about instead of blanking the prompt after the first transition.
|
||||
promptText: payload.promptText || existing?.promptText || '',
|
||||
// Why: keep the current state's original start time until the agent
|
||||
// transitions to a new state. Otherwise the elapsed timer would jump
|
||||
// backward on every in-state status-text refresh.
|
||||
stateStartedAt:
|
||||
existing && existing.state === payload.state ? existing.stateStartedAt : now,
|
||||
updatedAt: now,
|
||||
source: 'agent',
|
||||
// Why: the design doc requires agentType in the hover, but the OSC
|
||||
// payload may omit it. Fall back to title inference so older injected
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export type OpenFile = {
|
|||
mode: 'edit' | 'diff' | 'conflict-review'
|
||||
}
|
||||
|
||||
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'dashboard'
|
||||
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks'
|
||||
export type ActivityBarPosition = 'top' | 'side'
|
||||
|
||||
export type MarkdownViewMode = 'source' | 'rich'
|
||||
|
|
|
|||
|
|
@ -150,6 +150,12 @@ export type TerminalSlice = {
|
|||
* already have a timer. Called when the feature is enabled mid-session. */
|
||||
seedCacheTimersForIdleTabs: () => void
|
||||
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
|
||||
/** Dashboard-only hydrate: mirrors tabs from the session without nulling
|
||||
* ptyId or resetting agent-like titles. The detached dashboard window is a
|
||||
* read-only observer — it never reconnects PTYs, so stripping that state
|
||||
* (as hydrateWorkspaceSession does for cold boot) would suppress heuristic
|
||||
* agent detection in useDashboardData on every session:updated broadcast. */
|
||||
hydrateObserverSession: (session: WorkspaceSessionState) => void
|
||||
reconnectPersistedTerminals: (signal?: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -1143,6 +1149,69 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
})
|
||||
},
|
||||
|
||||
hydrateObserverSession: (session) => {
|
||||
set((s) => {
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(s.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
)
|
||||
// Why: preserve ptyId and title as written by the main window. The
|
||||
// dashboard never spawns or reconnects PTYs; its only job is to display
|
||||
// what the main window persists. Clearing ptyId here would suppress the
|
||||
// heuristic agent-detection path in useDashboardData on every
|
||||
// session:updated broadcast (~150ms while typing), causing statuses to
|
||||
// flicker or disappear.
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
|
||||
Object.entries(session.tabsByWorktree)
|
||||
.filter(([worktreeId]) => validWorktreeIds.has(worktreeId))
|
||||
.map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
[...tabs]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
|
||||
.map((tab, index) => ({ ...tab, sortOrder: index }))
|
||||
])
|
||||
.filter(([, tabs]) => tabs.length > 0)
|
||||
)
|
||||
|
||||
const validTabIds = new Set(
|
||||
Object.values(tabsByWorktree)
|
||||
.flat()
|
||||
.map((tab) => tab.id)
|
||||
)
|
||||
const activeWorktreeId =
|
||||
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
|
||||
? session.activeWorktreeId
|
||||
: null
|
||||
const activeTabId =
|
||||
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
|
||||
const activeRepoId =
|
||||
session.activeRepoId && s.repos.some((repo) => repo.id === session.activeRepoId)
|
||||
? session.activeRepoId
|
||||
: null
|
||||
|
||||
const activeTabIdByWorktree: Record<string, string | null> = {}
|
||||
if (session.activeTabIdByWorktree) {
|
||||
for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) {
|
||||
if (validWorktreeIds.has(wId) && tabId && validTabIds.has(tabId)) {
|
||||
activeTabIdByWorktree[wId] = tabId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeRepoId,
|
||||
activeWorktreeId,
|
||||
activeTabId,
|
||||
activeTabIdByWorktree,
|
||||
tabsByWorktree,
|
||||
terminalLayoutsByTabId: Object.fromEntries(
|
||||
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => validTabIds.has(tabId))
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
reconnectPersistedTerminals: async (_signal) => {
|
||||
const {
|
||||
pendingReconnectWorktreeIds,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
skipDeleteWorktreeConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
agentCmdOverrides: {},
|
||||
terminalMacOptionAsAlt: 'true'
|
||||
terminalMacOptionAsAlt: 'true',
|
||||
autoInstallAgentHooks: false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue