diff --git a/docs/reference/claude-workflow-detail-panel.md b/docs/reference/claude-workflow-detail-panel.md new file mode 100644 index 00000000000..7da4ddc3829 --- /dev/null +++ b/docs/reference/claude-workflow-detail-panel.md @@ -0,0 +1,157 @@ +# Claude Workflow Detail Panel + +## Problem + +The existing agent-status integration tells the user that a Claude turn or orchestration branch exists, but not why it reached its current state. Claude hook payloads and private run/transcript files can contain richer state: phase-like events, subagent transcript previews, generated JS script references, elapsed time, and token/cost data where present. Orca already has dense row surfaces for agent status (`src/renderer/src/components/dashboard/DashboardAgentRow.tsx`, rendered inline by `src/renderer/src/components/sidebar/WorktreeCardAgents.tsx`) but no read-only drill-in surface for Claude workflow internals. + +## Goal + +Add a read-only detail panel that opens from a selected Claude agent-status row/workflow target and shows a readable timeline: row summary, state history, orchestration children, transcript previews, generated script path/preview, and basic usage metrics when those fields are present. The panel should help the user audit a Claude workflow without leaving Orca. + +## Non-goals + +- Do not edit or execute the workflow JS. +- Do not stream full transcript bodies into the sidebar row. +- Do not replace Claude Code's native transcript/session view. +- Do not implement a new workflow grouping model in this branch; reuse the existing orchestration lineage where child rows are already represented. +- Do not add stale/resume actions. The only new row behavior is opening detail for the selected Claude row/workflow target. + +## Design + +1. Add detail data APIs. + - Summary data stays in the existing agent-status row data (`agentStatusByPaneKey`/retained entries via `useWorktreeAgentRows`); detail data is fetched lazily when the panel opens. Do not add transcript/script payloads to high-frequency agent-status updates. + - Add a typed preload/main API such as `claudeWorkflows.getDetail({ target })`. The target is the selected row's current snapshot plus `worktreeId`, derived `connectionId`, and any optional file selectors, not a group lookup. + - Main must validate that any renderer-provided path belongs to the selected target's worktree before reading it. Treat target paths as selectors, not authority. + - Return state history, orchestration children, phases when derivable, agents, transcript previews, script preview, file paths, token/cost/elapsed metrics, and non-fatal warnings when present. Missing private Claude fields should produce summary-only detail, not an error. + - Cap previews in main before IPC. Do not call generic `fs.readFile` for transcript/script previews because it can serialize up to the editor-sized file cap. + - Put project-owned shared result/request types in a `.ts` file under `src/shared/`, not a `.d.ts`. + +2. Add a renderer detail state. + - Store only selected detail target, loading/error/detail cache, and an epoch. + - Key cache by stable target identity: `paneKey` plus `worktreeId`/derived `connectionId`, and workflow run id or file path if the reader discovers one. + - Derive `connectionId` from the owning worktree/repo (`getConnectionId(worktreeId)` pattern); `AgentStatusEntry` does not retain the IPC `connectionId`. + - Invalidate cached detail when the selected row `updatedAt` or `stateStartedAt` advances, the worktree/repo changes, the derived connection id changes, or the file mtime returned by detail read changes. + - Drop stale in-flight responses when the selected target or epoch changes. + - Do not persist panel-open state across app relaunch. + +3. Open the panel from Claude agent rows. + - Add a compact icon-only details action to `DashboardAgentRow` through an optional prop, and pass it from `WorktreeCardAgents` only for Claude rows that have a detail target. + - Use `Tooltip` for the icon label and `Button` `ghost`/`icon-xs` or `icon-sm` sizing to match `DashboardAgentRow` density. + - Keep existing row clicks focusing the agent pane. Panel open is an explicit action so navigation behavior does not change. + - Stop click, mousedown, and Enter/Space propagation on the details button, matching the existing nested-button pattern in `DashboardAgentRow`. + +4. Build `ClaudeWorkflowDetailPanel`. + - Use a right-side `Sheet` because this is a drawer/panel from an existing workspace surface. + - Reuse `SheetContent`, `SheetTitle`, and `SheetDescription`; do not add custom overlay/shadow/color tiers. + - Header: workflow/turn label, state, elapsed, last updated, and owning or parent terminal. + - Tabs or segmented control: Timeline, Agents, Script. Keep the control compact; long labels must not resize the header. + - Timeline: state-history list with phase names/durations when derivable, and child agents under each phase when the data supports it. + - Agents: table/list of subagents with prompt, state, last message, token count, transcript preview. + - Script: read-only code preview with script path. Allow copy path/content when available. Reveal/open-local actions only appear for local paths; remote paths show copy only. + - Error/empty/loading states use existing muted text and buttons. + +5. Keep data bounded. + - Cap transcript preview events per agent and cap total returned preview bytes across the whole detail response. + - Cap script preview bytes and mark truncation explicitly. + - Avoid rendering markdown for raw transcript JSON; show concise plain text previews. + - For large workflows, render only the first page/window of agents initially and provide "show more" pagination; do not add virtualization unless the list is proven large enough to need it. + +6. Support SSH explicitly. + - Detail reads for SSH workflows must use the SSH filesystem provider path keyed by `connectionId`, never local `fs`. + - If an active remote runtime owns the worktree, only read files inside that runtime worktree. Use the same containment rules as `runtime-file-client.ts`/`shared/cross-platform-path.ts`; do not fall back to local paths for remote-owned worktrees. + - If remote detail read is unsupported for v1 or the connection drops, return summary-only detail with a warning. The panel should not throw or close. + - Use `path`/cross-platform path helpers for path joins and containment. Do not split on `/` or assume POSIX paths when checking Windows/local paths. + +## Data flow + +```text +User clicks Claude row details + -> renderer stores selected row/workflow target snapshot + epoch + -> preload claudeWorkflows.getDetail({ target }) + -> main validates target paths and resolves local/SSH/runtime source + -> main reads bounded previews and tolerant metadata + -> renderer drops stale response if epoch/updatedAt changed + -> detail cache keyed by target identity + -> Sheet with Timeline/Agents/Script +``` + +## Edge cases + +- Workflow files are still being written while the panel is open. +- Detail fetch races with selected row invalidation. +- Script preview is too large or binary-looking. +- Transcript JSONL contains malformed lines. +- Missing optional metrics or unknown/private Claude file fields. +- Remote workflow detail is unsupported or connection dropped. +- Workflow was dismissed while the panel is open. +- The workflow has many subagents and long labels. +- Renderer-supplied path is outside the owning worktree. +- Selected row has no discoverable Claude workflow file; panel shows row summary/state history with a warning. +- Windows drive-letter paths and SSH POSIX paths appear in the same app session. + +## Test plan + +- Unit: detail reader caps script/transcript previews and skips malformed JSONL lines. +- Unit: detail reader validates path containment for local, Windows-shaped, SSH, and outside-worktree paths. +- Unit: detail cache invalidates on row `updatedAt`/`stateStartedAt`. +- Unit: stale in-flight detail response is ignored after selected target/epoch changes. +- Unit: remote unsupported returns summary-only detail with warnings, not a thrown exception. +- Component: panel loading, error, summary-only, full detail, and large-agent states. +- Component: tabs/segmented control preserve state and do not overflow narrow widths; long paths/prompts truncate or wrap predictably. +- Electron: open detail from a Claude agent row, switch Timeline/Agents/Script, verify long labels and loading/error states. +- Electron/SSH: remote summary-only or full-detail path does not attempt local reveal/open actions. + +## UI quality bar + +- This should feel like an operational inspection panel, not a marketing page. +- Follow `docs/STYLEGUIDE.md`: existing tokens, shadcn primitives, lucide icons, quiet monochrome chrome, state color only where meaningful. +- Dense, scannable lists; no nested cards, no decorative backgrounds, no oversized headings, no custom shadows. +- The first viewport should show workflow identity, state, and the top timeline section without scrolling. +- Long paths and labels must truncate/wrap predictably. +- The panel must not obscure or break the existing worktree card list interaction. +- Icon-only actions need tooltips. Loading states longer than a spinner should include a short stage label. + +## Review screenshots + +1. Detail panel Timeline tab for a running workflow. +2. Agents tab with mixed states and transcript previews. +3. Script tab with generated JS preview and local path actions. +4. Error/summary-only remote state. +5. Narrow width with long labels and paths. +6. SSH/remote workflow state showing no local reveal/open action. + +## Rollout + +1. Add detail reader and tests with real observed Claude workflow fixtures. +2. Add preload/API and renderer detail store. +3. Add row action/open behavior. +4. Build `ClaudeWorkflowDetailPanel` using existing Sheet and UI primitives. +5. Add component tests and Electron screenshots. + +## Lightweight Eng Review + +- Scope: Kept to lazy read-only inspection. Resume actions, terminal spawning, and script edits are out of scope. +- Architecture/data flow: Agent rows stay cheap; detail data is fetched on demand through main/preload using the selected target snapshot. This avoids pushing transcript/script payloads through high-frequency status updates. +- IPC/security: Main validates paths and caps payloads before IPC. Renderer targets identify the desired workflow but do not authorize filesystem reads. +- SSH/Windows: Remote reads must route through the SSH/runtime provider and path containment must use cross-platform helpers. +- Failure modes covered: + - Partial writes and malformed JSONL yield partial detail with warnings. + - Row/target invalidation refetches detail without stale panel data. + - Remote unsupported state is explicit and non-crashing. + - Large scripts/transcripts are capped before IPC. + - Dismissed workflow closes or resets the panel cleanly. +- Test coverage required: + - Reader cap/malformed-line/path-validation tests. + - Store cache invalidation and stale-response tests. + - Component tests for tabs, loading, error, summary-only, long text. + - Electron screenshots for all required panel states, including remote behavior. +- Performance/blast radius: Lazy bounded reads only. No row-level transcript payloads. Consider virtualization/pagination if agent count is high. +- UI quality bar: Right-side Sheet, quiet tokens, dense list/table patterns, no nested cards, no overlap or text clipping. +- Required review screenshots: + 1. Timeline tab. + 2. Agents tab. + 3. Script tab. + 4. Remote/error summary-only. + 5. Narrow long-label state. + 6. SSH/remote state with local-only actions hidden. +- Residual risks: Detail fidelity depends on Claude's private workflow file shape. Use tolerant parsing and observed fixtures; never treat missing optional metrics as an error. diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 636b1e4affb..91ed17ce455 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -44,6 +44,7 @@ import { type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import type { ClaudeWorkflowFileSelectors } from '../../shared/claude-workflow-detail' import { isAgentInterruptInputIntent, type AgentInterruptInferenceRequest @@ -144,6 +145,21 @@ function equivalentInterruptAgentType( return normalizedActual === normalizedBaseline } +function sanitizeClaudeWorkflowSelectors(value: unknown): ClaudeWorkflowFileSelectors | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const record = value as Record + const selectors: ClaudeWorkflowFileSelectors = {} + for (const key of ['transcriptPath', 'scriptPath', 'cwd', 'sessionId'] as const) { + const candidate = record[key] + if (typeof candidate === 'string' && candidate.trim().length > 0) { + selectors[key] = candidate.trim() + } + } + return Object.keys(selectors).length > 0 ? selectors : undefined +} + // Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix // at write/hydrate time so legacy numeric rows fail closed. export function isValidPaneKey(value: unknown): value is string { @@ -216,6 +232,7 @@ function sanitizeHydratedEntry( toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + claudeWorkflow: sanitizeClaudeWorkflowSelectors(record.claudeWorkflow), payload, receivedAt, stateStartedAt @@ -230,6 +247,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, + claudeWorkflow: entry.claudeWorkflow, ...entry.payload } } @@ -866,6 +884,7 @@ export class AgentHookServer { toolAgentId?: string toolAgentType?: string isReplay?: boolean + claudeWorkflow?: unknown payload: unknown }, connectionId: string @@ -969,6 +988,7 @@ export class AgentHookServer { toolAgentId, toolAgentType, isReplay: envelope.isReplay === true ? true : undefined, + claudeWorkflow: sanitizeClaudeWorkflowSelectors(envelope.claudeWorkflow), payload: normalizedPayload } this.applyNormalizedStatus(event) diff --git a/src/main/claude-workflow-detail-reader.ts b/src/main/claude-workflow-detail-reader.ts new file mode 100644 index 00000000000..977ebd06bab --- /dev/null +++ b/src/main/claude-workflow-detail-reader.ts @@ -0,0 +1,70 @@ +import { open, stat } from 'fs/promises' +import type { FileHandle } from 'fs/promises' +import type { Store } from './persistence' +import { + readClaudeWorkflowDetail, + type ClaudeWorkflowDetail, + type ClaudeWorkflowDetailTarget +} from '../shared/claude-workflow-detail' +import { splitWorktreeIdForFilesystem } from '../shared/worktree-id' + +async function readLocalPreview( + filePath: string, + maxBytes: number +): Promise<{ content: string; bytesRead: number; truncated: boolean; mtimeMs?: number }> { + let handle: FileHandle | null = null + try { + const info = await stat(filePath) + const bytesToRead = Math.min(Math.max(maxBytes, 0), info.size) + const offset = Math.max(0, info.size - bytesToRead) + handle = await open(filePath, 'r') + const buffer = Buffer.alloc(bytesToRead) + const result = await handle.read(buffer, 0, bytesToRead, offset) + return { + content: buffer.subarray(0, result.bytesRead).toString('utf8'), + bytesRead: result.bytesRead, + truncated: info.size > bytesToRead, + mtimeMs: info.mtimeMs + } + } finally { + await handle?.close() + } +} + +function resolveWorktreeTarget( + store: Store, + target: ClaudeWorkflowDetailTarget +): ClaudeWorkflowDetailTarget { + const parsed = splitWorktreeIdForFilesystem(target.worktreeId) + if (!parsed) { + throw new Error('Invalid worktree target.') + } + const repo = store.getRepo(parsed.repoId) + if (!repo) { + throw new Error('Unknown worktree target.') + } + const expectedConnectionId = repo.connectionId ?? null + if (expectedConnectionId !== target.connectionId) { + throw new Error('Workflow target connection changed.') + } + // Why: renderer-provided paths are selectors only. The owning worktree path + // is re-derived from the durable worktree id before any file read. + return { ...target, worktreePath: parsed.worktreePath, connectionId: expectedConnectionId } +} + +export async function getClaudeWorkflowDetail( + store: Store, + target: ClaudeWorkflowDetailTarget +): Promise { + const resolvedTarget = resolveWorktreeTarget(store, target) + const io = resolvedTarget.connectionId + ? null + : { + readPreview: readLocalPreview, + stat: async (filePath: string) => { + const info = await stat(filePath) + return { mtimeMs: info.mtimeMs, size: info.size } + } + } + return readClaudeWorkflowDetail(resolvedTarget, io) +} diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 2287db0ea4e..59651bb56d0 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -1,11 +1,14 @@ import { ipcMain } from 'electron' +import type { Store } from '../persistence' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' +import type { ClaudeWorkflowDetailRequest } from '../../shared/claude-workflow-detail' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { getClaudeWorkflowDetail } from '../claude-workflow-detail-reader' import { ampHookService } from '../amp/hook-service' import { clearMigrationUnsupportedPtysForPaneKey, @@ -26,7 +29,7 @@ import { hermesHookService } from '../hermes/hook-service' // auto-installs managed hooks at app startup (see src/main/index.ts), so a // renderer-triggered remove would be silently reverted on the next launch // and mislead the user. -export function registerAgentHookHandlers(): void { +export function registerAgentHookHandlers(store?: Store): void { // Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration // never throws "Attempted to register a second handler..." if this function is // ever invoked more than once (e.g. the macOS app re-activation path that @@ -47,6 +50,7 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') + ipcMain.removeHandler('claudeWorkflows:getDetail') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't // round-trip a response. Removing first keeps re-registration safe even @@ -82,6 +86,15 @@ export function registerAgentHookHandlers(): void { 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() ) + ipcMain.handle('claudeWorkflows:getDetail', async (_event, request: unknown) => { + if (!store) { + throw new Error('Claude workflow detail store is unavailable.') + } + if (typeof request !== 'object' || request === null) { + throw new Error('Invalid Claude workflow detail request.') + } + return getClaudeWorkflowDetail(store, (request as ClaudeWorkflowDetailRequest).target) + }) // Why: errors from getStatus() (fs permission denied, homedir resolution // failure, etc.) must be reported inline via state:'error' so the sidebar can diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 53e676f6106..2e775a45b5d 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -103,7 +103,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) - registerAgentHookHandlers() + registerAgentHookHandlers(store) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fce1dc1d607..0a2c85fc19f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -173,6 +173,10 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { + ClaudeWorkflowDetail, + ClaudeWorkflowDetailRequest +} from '../shared/claude-workflow-detail' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { RuntimeBrowserDriverState, @@ -2158,6 +2162,9 @@ export type PreloadApi = { * last-status file. Fire-and-forget. */ drop: (paneKey: string) => void } + claudeWorkflows: { + getDetail: (request: ClaudeWorkflowDetailRequest) => Promise + } mobile: { listNetworkInterfaces: () => Promise<{ interfaces: { name: string; address: string }[] diff --git a/src/preload/index.ts b/src/preload/index.ts index 26e016048c7..c4ecbf5218c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -109,6 +109,10 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { + ClaudeWorkflowDetail, + ClaudeWorkflowDetailRequest +} from '../shared/claude-workflow-detail' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { SpeechErrorEvent, @@ -3366,6 +3370,11 @@ const api = { } }, + claudeWorkflows: { + getDetail: (request: ClaudeWorkflowDetailRequest): Promise => + ipcRenderer.invoke('claudeWorkflows:getDetail', request) + }, + speech: { getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f775133e582..9840da55b0e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -65,6 +65,7 @@ import { import { DictationController } from './components/dictation/DictationController' import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' import { CrashReportDialog } from './components/crash-report/CrashReportDialog' +import ClaudeWorkflowDetailPanel from './components/claude-workflow/ClaudeWorkflowDetailPanel' import { ConfirmationDialogProvider } from './components/confirmation-dialog' import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' @@ -1811,6 +1812,7 @@ function App(): React.JSX.Element { ) : null} + diff --git a/src/renderer/src/components/claude-workflow/ClaudeWorkflowDetailPanel.tsx b/src/renderer/src/components/claude-workflow/ClaudeWorkflowDetailPanel.tsx new file mode 100644 index 00000000000..94a576e0812 --- /dev/null +++ b/src/renderer/src/components/claude-workflow/ClaudeWorkflowDetailPanel.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from 'react' +import { AlertCircle, Copy, ExternalLink, FileCode2, Loader2 } from 'lucide-react' +import { useAppStore } from '@/store' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' +import type { ClaudeWorkflowDetail } from '../../../../shared/claude-workflow-detail' + +function formatDuration(ms: number | undefined): string { + if (!ms || ms < 0) { + return 'unknown' + } + const seconds = Math.round(ms / 1000) + if (seconds < 60) { + return `${seconds}s` + } + const minutes = Math.floor(seconds / 60) + const remaining = seconds % 60 + return remaining ? `${minutes}m ${remaining}s` : `${minutes}m` +} + +function formatTime(value: number | undefined): string { + return value ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' +} + +function CopyButton({ value, label }: { value: string | undefined; label: string }) { + if (!value) { + return null + } + return ( + + ) +} + +function WarningList({ warnings }: { warnings: string[] }) { + if (warnings.length === 0) { + return null + } + return ( +
+ {warnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ ) +} + +function TimelineTab({ detail }: { detail: ClaudeWorkflowDetail }) { + return ( +
+ {detail.timeline.map((item) => ( +
+
{formatTime(item.startedAt)}
+
+
+ + {item.state ? agentStateLabel(item.state as AgentDotState) : 'Event'} + + + {item.label} + +
+
+ {formatDuration(item.durationMs)} +
+
+
+ ))} + {detail.timeline.length === 0 && ( +

No timeline events were available.

+ )} +
+ ) +} + +function AgentsTab({ detail }: { detail: ClaudeWorkflowDetail }) { + return ( +
+ {detail.agents.map((agent) => ( +
+
+ + {agent.label} + + {agent.state} +
+ {agent.prompt && ( +

+ {agent.prompt} +

+ )} + {agent.lastMessage && ( +

+ {agent.lastMessage} +

+ )} +
+ ))} + {detail.agents.length === 0 && ( +

+ No subagent transcript preview was available. +

+ )} +
+ ) +} + +function ScriptTab({ detail }: { detail: ClaudeWorkflowDetail }) { + const script = detail.scriptPreview + const isLocal = detail.target.connectionId === null + return ( +
+
+ + + {script?.path ?? 'No generated script path was discovered.'} + + + + {isLocal && script?.path && ( + + )} +
+
+        {script?.binary
+          ? 'Script preview looked binary and was hidden.'
+          : script?.content || 'No script preview available.'}
+      
+
+ ) +} + +export default function ClaudeWorkflowDetailPanel(): React.JSX.Element { + const target = useAppStore((s) => s.selectedClaudeWorkflowTarget) + const status = useAppStore((s) => s.claudeWorkflowDetailStatus) + const liveEntry = useAppStore((s) => + target ? s.agentStatusByPaneKey[target.paneKey] : undefined + ) + const retainedEntry = useAppStore((s) => + target ? s.retainedAgentsByPaneKey[target.paneKey] : undefined + ) + const close = useAppStore((s) => s.closeClaudeWorkflowDetail) + const load = useAppStore((s) => s.loadClaudeWorkflowDetail) + const [tab, setTab] = useState('timeline') + + useEffect(() => { + if (target) { + void load() + } + }, [target, load]) + + useEffect(() => { + if (target && !liveEntry && !retainedEntry) { + close() + } + }, [close, liveEntry, retainedEntry, target]) + + const headerMeta = useMemo(() => { + const detail = status.detail + const elapsed = detail?.metrics?.elapsedMs + return [ + target?.state ? agentStateLabel(target.state as AgentDotState) : null, + elapsed ? formatDuration(elapsed) : null, + target?.terminalTitle ?? target?.tabTitle ?? null + ].filter(Boolean) + }, [status.detail, target]) + + return ( + !open && close()}> + + + + {target?.prompt?.trim() || 'Claude workflow'} + + + {headerMeta.map((part) => ( + + {part} + + ))} + + + + {status.loading && !status.detail ? ( +
+ + Loading workflow detail… +
+ ) : status.error ? ( +
{status.error}
+ ) : status.detail ? ( + +
+ + + Timeline + + + Agents + + + Script + + +
+ + + + + + + + + +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index ebfe16de9ba..62ac33e2c9c 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -1,11 +1,16 @@ +/* eslint-disable max-lines -- Why: this dense row owns the inline agent + hierarchy, attention, nested actions, and expand/collapse layout together; + splitting the new detail action out would separate it from the propagation + safeguards it must share with the existing nested buttons. */ import React, { useState, useCallback } from 'react' -import { X, Wrench, ChevronDown } from 'lucide-react' +import { X, Wrench, ChevronDown, PanelRightOpen } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' @@ -121,6 +126,7 @@ type Props = { reserveDisclosureGutter?: boolean // Why: chevron indentation replaces fixed-offset lineage connector art. hideLineageConnectors?: boolean + onOpenDetails?: (agent: DashboardAgentRowData) => void } const DashboardAgentRow = React.memo(function DashboardAgentRow({ @@ -137,7 +143,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ childAgentsExpanded = false, onToggleChildAgents, reserveDisclosureGutter = false, - hideLineageConnectors = false + hideLineageConnectors = false, + onOpenDetails }: Props) { const hasChildDisclosure = typeof childAgentCount === 'number' && @@ -162,6 +169,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ e.stopPropagation() setExpanded((prev) => !prev) }, []) + const handleOpenDetails = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + onOpenDetails?.(agent) + }, + [agent, onOpenDetails] + ) const stopMouseDown = useCallback((e: React.MouseEvent) => { e.stopPropagation() }, []) @@ -370,6 +385,27 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ place. State belongs in the leading gutter; repeating it here as text makes interrupted rows look like the old badge treatment. */} + {onOpenDetails ? ( + + + + + + Details + + + ) : null} {/* Why: timestamp and dismiss-X share a single slot so passive rows show "time ago" and hovered rows swap in the X — no reserved-space gap, no competing columns. Grid stacks both diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 16fea6e98d1..509ae051943 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -10,6 +10,8 @@ import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/da import { parsePaneKey } from '../../../../shared/stable-pane-id' import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row' import { useFocusedAgentPaneKey } from './focused-agent-row-highlight' +import { getConnectionId } from '@/lib/connection-context' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' type Props = { worktreeId: string @@ -119,6 +121,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent) const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId) + const openClaudeWorkflowDetail = useAppStore((s) => s.openClaudeWorkflowDetail) // Why: subscribe to the ack map reference (Object.is equality) and derive // per-agent unvisited flags locally. Keeps the inline list's bold/mute @@ -184,6 +187,33 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [worktreeId] ) + const handleOpenClaudeDetails = useCallback( + (agent: DashboardAgentRowData) => { + const connectionId = getConnectionId(worktreeId) + const parsedWorktree = splitWorktreeIdForFilesystem(worktreeId) + if (connectionId === undefined || !parsedWorktree) { + return + } + openClaudeWorkflowDetail({ + paneKey: agent.paneKey, + worktreeId, + connectionId, + worktreePath: parsedWorktree.worktreePath, + state: agent.state, + prompt: agent.entry.prompt, + updatedAt: agent.entry.updatedAt, + stateStartedAt: agent.entry.stateStartedAt, + stateHistory: agent.entry.stateHistory, + agentType: agent.agentType, + terminalTitle: agent.entry.terminalTitle, + orchestration: agent.entry.orchestration, + selectors: agent.entry.claudeWorkflow, + tabTitle: agent.tab.title + }) + }, + [openClaudeWorkflowDetail, worktreeId] + ) + // Why: own one 30s tick per non-empty inline list. Cards with zero agents // never mount this component (see WorktreeCardAgents), so idle worktrees // don't pay any timer cost. @@ -273,6 +303,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ // are pinned to a fixed left offset that doesn't match the // chevron-shifted column and read as floating fragments. hideLineageConnectors + onOpenDetails={agent.agentType === 'claude' ? handleOpenClaudeDetails : undefined} /> {hasChildAgents && expanded ? childAgents.map((childAgent) => diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5424f6d696d..2f630f3478e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2061,7 +2061,8 @@ export function useIpcEvents(): void { toolName: data.toolName, toolInput: data.toolInput, lastAssistantMessage: data.lastAssistantMessage, - interrupted: data.interrupted + interrupted: data.interrupted, + claudeWorkflow: data.claudeWorkflow }) if (!payload) { return 'dropped' diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 57bfcb274e4..4e0a91ee1c0 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -23,6 +23,7 @@ import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' import { createAgentStatusSlice } from './slices/agent-status' +import { createClaudeWorkflowDetailSlice } from './slices/claude-workflow-detail' import { createDiffCommentsSlice } from './slices/diffComments' import { createDetectedAgentsSlice } from './slices/detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' @@ -55,6 +56,7 @@ export const useAppStore = create()((...a) => ({ ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowDetailSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index bc69374dace..dda36dffcca 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -257,6 +257,7 @@ export const createAgentStatusSlice: StateCreator = {} +): ClaudeWorkflowDetailTarget { + return { + paneKey: 'tab-1:leaf-1', + worktreeId: 'repo::/repo/work', + connectionId: null, + worktreePath: '/repo/work', + state: 'working', + prompt: 'Prompt', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function createDetailStore() { + return create>()((...a) => + createClaudeWorkflowDetailSlice(...(a as Parameters)) + ) +} + +describe('claude workflow detail slice', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('invalidates cached detail when row timestamps advance', async () => { + const getDetail = vi.fn(async ({ target }: { target: ClaudeWorkflowDetailTarget }) => ({ + target, + summaryOnly: true, + source: 'summary-only' as const, + warnings: [], + timeline: [], + agents: [] + })) + vi.stubGlobal('window', { api: { claudeWorkflows: { getDetail } } }) + const store = createDetailStore() + + store.getState().openClaudeWorkflowDetail(makeTarget({ updatedAt: 1 })) + await Promise.resolve() + store.getState().openClaudeWorkflowDetail(makeTarget({ updatedAt: 2 })) + await Promise.resolve() + + expect(getDetail).toHaveBeenCalledTimes(2) + }) + + it('drops stale in-flight responses after selected target changes', async () => { + let resolveFirst: (() => void) | undefined + const getDetail = vi + .fn() + .mockImplementationOnce( + ({ target }: { target: ClaudeWorkflowDetailTarget }) => + new Promise((resolve) => { + resolveFirst = () => { + resolve({ + target, + summaryOnly: true, + source: 'summary-only', + warnings: [], + timeline: [], + agents: [] + }) + } + }) + ) + .mockImplementationOnce(async ({ target }: { target: ClaudeWorkflowDetailTarget }) => ({ + target, + summaryOnly: true, + source: 'summary-only', + warnings: ['second'], + timeline: [], + agents: [] + })) + vi.stubGlobal('window', { api: { claudeWorkflows: { getDetail } } }) + const store = createDetailStore() + + store.getState().openClaudeWorkflowDetail(makeTarget({ paneKey: 'tab-1:leaf-1' })) + store.getState().openClaudeWorkflowDetail(makeTarget({ paneKey: 'tab-1:leaf-2' })) + await Promise.resolve() + resolveFirst?.() + await Promise.resolve() + + expect(store.getState().claudeWorkflowDetailStatus.detail?.warnings).toEqual(['second']) + }) +}) diff --git a/src/renderer/src/store/slices/claude-workflow-detail.ts b/src/renderer/src/store/slices/claude-workflow-detail.ts new file mode 100644 index 00000000000..a5e0313d2b6 --- /dev/null +++ b/src/renderer/src/store/slices/claude-workflow-detail.ts @@ -0,0 +1,136 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { + ClaudeWorkflowDetail, + ClaudeWorkflowDetailTarget +} from '../../../../shared/claude-workflow-detail' + +export type ClaudeWorkflowDetailStatus = { + loading: boolean + error: string | null + detail: ClaudeWorkflowDetail | null +} + +export type ClaudeWorkflowDetailSlice = { + selectedClaudeWorkflowTarget: ClaudeWorkflowDetailTarget | null + claudeWorkflowDetailEpoch: number + claudeWorkflowDetailCache: Record + claudeWorkflowDetailStatus: ClaudeWorkflowDetailStatus + openClaudeWorkflowDetail: (target: ClaudeWorkflowDetailTarget) => void + closeClaudeWorkflowDetail: () => void + loadClaudeWorkflowDetail: () => Promise +} + +export function getClaudeWorkflowDetailCacheKey(target: ClaudeWorkflowDetailTarget): string { + return [ + target.paneKey, + target.worktreeId, + target.connectionId ?? 'local', + target.selectors?.transcriptPath ?? '', + target.selectors?.scriptPath ?? '', + target.updatedAt, + target.stateStartedAt + ].join('\0') +} + +export const createClaudeWorkflowDetailSlice: StateCreator< + AppState, + [], + [], + ClaudeWorkflowDetailSlice +> = (set, get) => ({ + selectedClaudeWorkflowTarget: null, + claudeWorkflowDetailEpoch: 0, + claudeWorkflowDetailCache: {}, + claudeWorkflowDetailStatus: { + loading: false, + error: null, + detail: null + }, + + openClaudeWorkflowDetail: (target) => { + const key = getClaudeWorkflowDetailCacheKey(target) + const cached = get().claudeWorkflowDetailCache[key] ?? null + set((state) => ({ + selectedClaudeWorkflowTarget: target, + claudeWorkflowDetailEpoch: state.claudeWorkflowDetailEpoch + 1, + claudeWorkflowDetailStatus: { + loading: !cached, + error: null, + detail: cached + } + })) + if (!cached) { + void get().loadClaudeWorkflowDetail() + } + }, + + closeClaudeWorkflowDetail: () => { + set((state) => ({ + selectedClaudeWorkflowTarget: null, + claudeWorkflowDetailEpoch: state.claudeWorkflowDetailEpoch + 1, + claudeWorkflowDetailStatus: { + loading: false, + error: null, + detail: null + } + })) + }, + + loadClaudeWorkflowDetail: async () => { + const target = get().selectedClaudeWorkflowTarget + if (!target) { + return + } + const epoch = get().claudeWorkflowDetailEpoch + const key = getClaudeWorkflowDetailCacheKey(target) + const cached = get().claudeWorkflowDetailCache[key] + if (cached) { + set({ claudeWorkflowDetailStatus: { loading: false, error: null, detail: cached } }) + return + } + set((state) => ({ + claudeWorkflowDetailStatus: { + ...state.claudeWorkflowDetailStatus, + loading: true, + error: null + } + })) + try { + const detail = await window.api.claudeWorkflows.getDetail({ target }) + const current = get().selectedClaudeWorkflowTarget + if ( + !current || + epoch !== get().claudeWorkflowDetailEpoch || + getClaudeWorkflowDetailCacheKey(current) !== key + ) { + return + } + const nextKey = detail.mtimeMs !== undefined ? `${key}\0mtime:${detail.mtimeMs}` : key + set((state) => ({ + claudeWorkflowDetailCache: { + ...state.claudeWorkflowDetailCache, + [key]: detail, + [nextKey]: detail + }, + claudeWorkflowDetailStatus: { loading: false, error: null, detail } + })) + } catch (error) { + const current = get().selectedClaudeWorkflowTarget + if ( + !current || + epoch !== get().claudeWorkflowDetailEpoch || + getClaudeWorkflowDetailCacheKey(current) !== key + ) { + return + } + set({ + claudeWorkflowDetailStatus: { + loading: false, + error: error instanceof Error ? error.message : 'Failed to load Claude workflow detail.', + detail: null + } + }) + } + } +}) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index a7bf5b3d4f0..aada3e686b2 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -130,6 +130,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowDetailSlice } from './claude-workflow-detail' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -161,6 +162,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowDetailSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index cc6bd4bdde2..bb88cfd2a1a 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -131,6 +131,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowDetailSlice } from './claude-workflow-detail' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -162,6 +163,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowDetailSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index bb0f813ee5a..009696355db 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -31,6 +31,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowDetailSlice } from './claude-workflow-detail' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -70,6 +71,7 @@ export function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowDetailSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 95d021baf40..b45d744f9e9 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -127,6 +127,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowDetailSlice } from './claude-workflow-detail' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -160,6 +161,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowDetailSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 46b4aa4e6e7..052bc286772 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -21,6 +21,7 @@ import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' import type { AgentStatusSlice } from './slices/agent-status' +import type { ClaudeWorkflowDetailSlice } from './slices/claude-workflow-detail' import type { DiffCommentsSlice } from './slices/diffComments' import type { DetectedAgentsSlice } from './slices/detected-agents' import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' @@ -50,6 +51,7 @@ export type AppState = RepoSlice & RateLimitSlice & SshSlice & AgentStatusSlice & + ClaudeWorkflowDetailSlice & DiffCommentsSlice & DetectedAgentsSlice & WorktreeNavHistorySlice & diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 608f7fbe940..7b745770435 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -517,6 +517,17 @@ function createWebPreloadApi(): Partial { getMigrationUnsupportedSnapshot: () => Promise.resolve([]), drop: () => {} }, + claudeWorkflows: { + getDetail: ({ target }) => + Promise.resolve({ + target, + summaryOnly: true, + source: 'remote-unsupported', + warnings: ['Claude workflow details are unavailable in the paired web client.'], + timeline: [], + agents: [] + }) + }, mobile: { listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), getPairingQR: () => Promise.resolve({ available: false }), diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3d79cd9cd06..65cf0f4e01d 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -31,6 +31,7 @@ import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent- import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' import { parsePaneKey } from './stable-pane-id' +import type { ClaudeWorkflowFileSelectors } from './claude-workflow-detail' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -179,6 +180,7 @@ export type AgentHookEventPayload = { toolAgentType?: string /** True when this event is a relay cache replay rather than a live hook. */ isReplay?: boolean + claudeWorkflow?: ClaudeWorkflowFileSelectors payload: ParsedAgentStatusPayload } @@ -550,6 +552,38 @@ function readFirstString( return undefined } +function extractClaudeWorkflowSelectors( + source: AgentHookSource, + record: Record +): ClaudeWorkflowFileSelectors | undefined { + if (source !== 'claude') { + return undefined + } + const selectors: ClaudeWorkflowFileSelectors = {} + const transcriptPath = readFirstString(record, ['transcript_path', 'transcriptPath']) + const scriptPath = readFirstString(record, [ + 'script_path', + 'scriptPath', + 'generated_script_path', + 'generatedScriptPath' + ]) + const cwd = readFirstString(record, ['cwd', 'workspaceRoot', 'workspace_root']) + const sessionId = readFirstString(record, ['session_id', 'sessionId']) + if (transcriptPath) { + selectors.transcriptPath = transcriptPath + } + if (scriptPath) { + selectors.scriptPath = scriptPath + } + if (cwd) { + selectors.cwd = cwd + } + if (sessionId) { + selectors.sessionId = sessionId + } + return Object.keys(selectors).length > 0 ? selectors : undefined +} + function parseJsonObjectString(value: unknown): Record | undefined { if (typeof value !== 'string' || value.trim().length === 0) { return undefined @@ -2858,6 +2892,7 @@ export function normalizeHookPayload( toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), toolAgentType: readString(hookPayloadRecord, 'agent_type'), + claudeWorkflow: extractClaudeWorkflowSelectors(source, hookPayloadRecord), payload } : null diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 05f0d8c6b96..62a65851c65 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -1,3 +1,5 @@ +import type { ClaudeWorkflowFileSelectors } from './claude-workflow-detail' + // ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // These types define the normalized status that Orca receives from Claude, // Codex, and other explicit integrations. Agent state normally comes from @@ -102,6 +104,9 @@ export type AgentStatusEntry = { * Why: parent/child agent hierarchy is pane-level state, not worktree * lineage; workers often run in the same worktree as their coordinator. */ orchestration?: AgentStatusOrchestrationContext + /** Lightweight file selectors for lazy Claude workflow inspection. Payload + * bodies are intentionally read through the detail IPC only. */ + claudeWorkflow?: ClaudeWorkflowFileSelectors } export type MigrationUnsupportedPtyEntry = { @@ -129,6 +134,7 @@ export type AgentStatusPayload = { toolInput?: string lastAssistantMessage?: string interrupted?: boolean + claudeWorkflow?: ClaudeWorkflowFileSelectors } /** @@ -160,6 +166,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Timestamp (ms) when the current state first appeared for this pane. */ stateStartedAt: number orchestration?: AgentStatusOrchestrationContext + claudeWorkflow?: ClaudeWorkflowFileSelectors } /** Maximum character length for the prompt field. Truncated on parse. */ @@ -258,6 +265,31 @@ function normalizeOptionalField(value: unknown, maxLength: number): string | und return normalized.length > 0 ? normalized : undefined } +function normalizeClaudeWorkflowSelectors(value: unknown): ClaudeWorkflowFileSelectors | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const record = value as Record + const selectors: ClaudeWorkflowFileSelectors = {} + const transcriptPath = normalizeOptionalField(record.transcriptPath, 4096) + const scriptPath = normalizeOptionalField(record.scriptPath, 4096) + const cwd = normalizeOptionalField(record.cwd, 4096) + const sessionId = normalizeOptionalField(record.sessionId, 512) + if (transcriptPath) { + selectors.transcriptPath = transcriptPath + } + if (scriptPath) { + selectors.scriptPath = scriptPath + } + if (cwd) { + selectors.cwd = cwd + } + if (sessionId) { + selectors.sessionId = sessionId + } + return Object.keys(selectors).length > 0 ? selectors : undefined +} + function normalizeOptionalMultilineField(value: unknown, maxLength: number): string | undefined { if (typeof value !== 'string') { return undefined @@ -305,7 +337,8 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | ), // Why: only meaningful on `done`. Coerce to undefined on other states so // the field doesn't leak stale truth through state transitions. - interrupted: obj.interrupted === true && state === 'done' ? true : undefined + interrupted: obj.interrupted === true && state === 'done' ? true : undefined, + claudeWorkflow: normalizeClaudeWorkflowSelectors(obj.claudeWorkflow) } } diff --git a/src/shared/claude-workflow-detail.test.ts b/src/shared/claude-workflow-detail.test.ts new file mode 100644 index 00000000000..074e998eb2a --- /dev/null +++ b/src/shared/claude-workflow-detail.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { + readClaudeWorkflowDetail, + type ClaudeWorkflowDetailReaderIo, + type ClaudeWorkflowDetailTarget +} from './claude-workflow-detail' + +function target(overrides: Partial = {}): ClaudeWorkflowDetailTarget { + return { + paneKey: 'tab-1:leaf-1', + worktreeId: 'repo::/repo/work', + connectionId: null, + worktreePath: '/repo/work', + state: 'working', + prompt: 'Implement feature', + updatedAt: 1000, + stateStartedAt: 900, + stateHistory: [], + agentType: 'claude', + selectors: { transcriptPath: '/repo/work/.claude/transcript.jsonl' }, + ...overrides + } +} + +function io(files: Record): ClaudeWorkflowDetailReaderIo { + return { + readPreview: vi.fn(async (filePath: string, maxBytes: number) => { + const content = files[filePath] ?? '' + const truncated = Buffer.byteLength(content, 'utf8') > maxBytes + return { + content: truncated ? content.slice(0, maxBytes) : content, + bytesRead: Math.min(Buffer.byteLength(content, 'utf8'), maxBytes), + truncated, + mtimeMs: 123 + } + }), + stat: vi.fn(async () => ({ mtimeMs: 123, size: 10 })) + } +} + +describe('readClaudeWorkflowDetail', () => { + it('skips malformed transcript lines and returns bounded parsed detail', async () => { + const detail = await readClaudeWorkflowDetail( + target(), + io({ + '/repo/work/.claude/transcript.jsonl': [ + '{"timestamp":"2026-05-30T10:00:00Z","message":{"usage":{"input_tokens":3,"output_tokens":4},"content":[{"type":"tool_use","id":"tool-1","name":"Task","input":{"description":"Research agent","prompt":"Find context"}}]}}', + 'not json', + '{"timestamp":"2026-05-30T10:01:00Z","tool_use_id":"tool-1","content":"done"}' + ].join('\n') + }) + ) + + expect(detail.summaryOnly).toBe(false) + expect(detail.agents[0]).toMatchObject({ id: 'tool-1', label: 'Research agent', state: 'done' }) + expect(detail.metrics?.totalTokens).toBe(7) + expect(detail.warnings.some((warning) => warning.includes('malformed'))).toBe(true) + }) + + it('rejects renderer-supplied paths outside the owning worktree', async () => { + const reader = io({ '/tmp/outside.jsonl': '{}' }) + const detail = await readClaudeWorkflowDetail( + target({ selectors: { transcriptPath: '/tmp/outside.jsonl' } }), + reader + ) + + expect(detail.summaryOnly).toBe(true) + expect(reader.readPreview).not.toHaveBeenCalled() + expect(detail.warnings.join('\n')).toContain('outside') + }) + + it('handles Windows drive-letter containment without POSIX slash assumptions', async () => { + const reader = io({ 'C:\\repo\\work\\.claude\\transcript.jsonl': '{}' }) + const detail = await readClaudeWorkflowDetail( + target({ + worktreeId: 'repo::C:\\repo\\work', + worktreePath: 'C:\\repo\\work', + selectors: { transcriptPath: 'C:\\repo\\work\\.claude\\transcript.jsonl' } + }), + reader + ) + + expect(detail.summaryOnly).toBe(false) + expect(reader.readPreview).toHaveBeenCalled() + }) + + it('hides binary-looking script previews and reports truncation', async () => { + const detail = await readClaudeWorkflowDetail( + target({ + selectors: { + transcriptPath: '/repo/work/.claude/transcript.jsonl', + scriptPath: '/repo/work/generated.js' + } + }), + io({ + '/repo/work/.claude/transcript.jsonl': '{}', + '/repo/work/generated.js': `\0${'x'.repeat(40_000)}` + }) + ) + + expect(detail.scriptPreview?.binary).toBe(true) + expect(detail.scriptPreview?.content).toBe('') + expect(detail.warnings.join('\n')).toContain('binary') + }) + + it('returns summary-only detail for unsupported remote reads', async () => { + const detail = await readClaudeWorkflowDetail(target({ connectionId: 'ssh-1' }), null) + + expect(detail.summaryOnly).toBe(true) + expect(detail.source).toBe('remote-unsupported') + expect(detail.warnings.join('\n')).toContain('Remote workflow detail reads are not supported') + }) +}) diff --git a/src/shared/claude-workflow-detail.ts b/src/shared/claude-workflow-detail.ts new file mode 100644 index 00000000000..c28935e4384 --- /dev/null +++ b/src/shared/claude-workflow-detail.ts @@ -0,0 +1,498 @@ +/* eslint-disable max-lines -- Why: this module keeps the public detail IPC + contract, bounded preview reader, and tolerant Claude JSONL extraction in + one place so security caps and warning semantics stay in lockstep. */ +import type { + AgentStateHistoryEntry, + AgentStatusOrchestrationContext, + AgentStatusState +} from './agent-status-types' +import { isPathInsideOrEqual } from './cross-platform-path' + +export type ClaudeWorkflowFileSelectors = { + transcriptPath?: string + scriptPath?: string + cwd?: string + sessionId?: string +} + +export type ClaudeWorkflowDetailTarget = { + paneKey: string + worktreeId: string + connectionId: string | null + worktreePath: string + state: AgentStatusState | 'idle' + prompt: string + updatedAt: number + stateStartedAt: number + stateHistory: AgentStateHistoryEntry[] + agentType?: string + terminalTitle?: string + orchestration?: AgentStatusOrchestrationContext + selectors?: ClaudeWorkflowFileSelectors + tabTitle?: string +} + +export type ClaudeWorkflowPreview = { + path?: string + content: string + truncated: boolean + binary: boolean + bytesRead: number +} + +export type ClaudeWorkflowTimelineItem = { + id: string + label: string + state?: AgentStatusState | 'idle' + startedAt?: number + endedAt?: number + durationMs?: number + detail?: string +} + +export type ClaudeWorkflowAgentDetail = { + id: string + label: string + state: AgentStatusState | 'unknown' + prompt?: string + lastMessage?: string + transcriptPreview?: string + tokenCount?: number +} + +export type ClaudeWorkflowMetrics = { + inputTokens?: number + outputTokens?: number + totalTokens?: number + costUsd?: number + elapsedMs?: number +} + +export type ClaudeWorkflowDetail = { + target: ClaudeWorkflowDetailTarget + summaryOnly: boolean + source: 'local' | 'remote-unsupported' | 'summary-only' + warnings: string[] + mtimeMs?: number + timeline: ClaudeWorkflowTimelineItem[] + agents: ClaudeWorkflowAgentDetail[] + transcriptPreview?: ClaudeWorkflowPreview + scriptPreview?: ClaudeWorkflowPreview + metrics?: ClaudeWorkflowMetrics +} + +export type ClaudeWorkflowDetailRequest = { + target: ClaudeWorkflowDetailTarget +} + +export const CLAUDE_WORKFLOW_TRANSCRIPT_PREVIEW_BYTES = 96 * 1024 +export const CLAUDE_WORKFLOW_SCRIPT_PREVIEW_BYTES = 32 * 1024 +export const CLAUDE_WORKFLOW_TOTAL_PREVIEW_BYTES = 128 * 1024 +export const CLAUDE_WORKFLOW_AGENT_PREVIEW_LIMIT = 40 + +type ReadPreviewResult = { + content: string + bytesRead: number + truncated: boolean + mtimeMs?: number +} + +export type ClaudeWorkflowDetailReaderIo = { + readPreview: (filePath: string, maxBytes: number) => Promise + stat: (filePath: string) => Promise<{ mtimeMs: number; size: number }> +} + +function compactText(value: unknown, maxLength = 500): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const compacted = value.trim().replace(/[\r\n\u2028\u2029]+/g, ' ') + if (!compacted) { + return undefined + } + return compacted.length > maxLength ? `${compacted.slice(0, maxLength - 1)}…` : compacted +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readString(record: Record, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + return undefined +} + +function extractTextFromContent(content: unknown): string | undefined { + if (typeof content === 'string') { + return compactText(content, 800) + } + if (!Array.isArray(content)) { + return undefined + } + const parts: string[] = [] + for (const part of content) { + if (!isRecord(part)) { + continue + } + const text = compactText(part.text ?? part.content, 300) + if (text) { + parts.push(text) + } + } + return compactText(parts.join(' '), 800) +} + +function extractMessageText(record: Record): string | undefined { + const direct = extractTextFromContent(record.content) + if (direct) { + return direct + } + const message = record.message + if (isRecord(message)) { + return extractTextFromContent(message.content) + } + return undefined +} + +function addUsage(metrics: ClaudeWorkflowMetrics, usage: unknown): void { + if (!isRecord(usage)) { + return + } + const input = + typeof usage.input_tokens === 'number' + ? usage.input_tokens + : typeof usage.inputTokens === 'number' + ? usage.inputTokens + : 0 + const output = + typeof usage.output_tokens === 'number' + ? usage.output_tokens + : typeof usage.outputTokens === 'number' + ? usage.outputTokens + : 0 + if (input > 0) { + metrics.inputTokens = (metrics.inputTokens ?? 0) + input + } + if (output > 0) { + metrics.outputTokens = (metrics.outputTokens ?? 0) + output + } + const cost = typeof usage.cost_usd === 'number' ? usage.cost_usd : usage.costUsd + if (typeof cost === 'number' && Number.isFinite(cost)) { + metrics.costUsd = (metrics.costUsd ?? 0) + cost + } +} + +function extractTimestamp(record: Record): number | undefined { + const value = record.timestamp ?? record.created_at ?? record.createdAt + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 10_000_000_000 ? value : value * 1000 + } + if (typeof value === 'string') { + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function detectBinaryLooking(content: string): boolean { + if (content.includes('\0')) { + return true + } + const sample = content.slice(0, 4096) + if (!sample) { + return false + } + let control = 0 + for (let i = 0; i < sample.length; i += 1) { + const code = sample.charCodeAt(i) + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + control += 1 + } + } + return control / sample.length > 0.08 +} + +function safeSelectorPath( + worktreePath: string, + candidatePath: string | undefined, + warnings: string[], + label: string +): string | undefined { + if (!candidatePath) { + return undefined + } + if (candidatePath.includes('\0') || !isPathInsideOrEqual(worktreePath, candidatePath)) { + warnings.push(`${label} is outside the selected worktree and was not read.`) + return undefined + } + return candidatePath +} + +function initialTimeline(target: ClaudeWorkflowDetailTarget): ClaudeWorkflowTimelineItem[] { + const history = [ + ...target.stateHistory, + { state: target.state, prompt: target.prompt, startedAt: target.stateStartedAt } + ] + return history.map((entry, index) => { + const next = history[index + 1] + const startedAt = entry.startedAt + const endedAt = next?.startedAt + return { + id: `${target.paneKey}:${index}`, + label: entry.prompt.trim() || entry.state, + state: entry.state, + startedAt, + endedAt, + durationMs: endedAt && startedAt ? Math.max(0, endedAt - startedAt) : undefined + } + }) +} + +function parseTranscript( + text: string, + target: ClaudeWorkflowDetailTarget, + warnings: string[] +): { + agents: ClaudeWorkflowAgentDetail[] + metrics: ClaudeWorkflowMetrics + scriptPath?: string + firstTimestamp?: number + lastTimestamp?: number +} { + const agents = new Map() + const metrics: ClaudeWorkflowMetrics = {} + let malformed = 0 + let firstTimestamp: number | undefined + let lastTimestamp: number | undefined + let scriptPath: string | undefined + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line) { + continue + } + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + malformed += 1 + continue + } + if (!isRecord(parsed)) { + continue + } + const timestamp = extractTimestamp(parsed) + if (timestamp !== undefined) { + firstTimestamp = + firstTimestamp === undefined ? timestamp : Math.min(firstTimestamp, timestamp) + lastTimestamp = lastTimestamp === undefined ? timestamp : Math.max(lastTimestamp, timestamp) + } + addUsage(metrics, parsed.usage) + if (isRecord(parsed.message)) { + addUsage(metrics, parsed.message.usage) + } + + const content = isRecord(parsed.message) ? parsed.message.content : parsed.content + if (Array.isArray(content)) { + for (const part of content) { + if (!isRecord(part)) { + continue + } + const name = readString(part, ['name']) + const input = isRecord(part.input) ? part.input : undefined + const partId = readString(part, ['id', 'tool_use_id', 'toolUseId']) + if (name === 'Task' || name === 'delegate_task') { + const id = partId ?? `agent-${agents.size + 1}` + const prompt = input ? compactText(input.prompt, 900) : undefined + agents.set(id, { + id, + label: + (input && compactText(input.description ?? input.subagent_type, 120)) ?? 'Subagent', + state: 'working', + prompt, + transcriptPreview: prompt + }) + } + const candidateScriptPath = + input && readString(input, ['scriptPath', 'script_path', 'generatedScriptPath', 'path']) + if (!scriptPath && candidateScriptPath && /\.(?:m?js|cjs)$/i.test(candidateScriptPath)) { + scriptPath = candidateScriptPath + } + } + } + + const toolUseId = readString(parsed, ['tool_use_id', 'toolUseId']) + if (toolUseId && agents.has(toolUseId)) { + const current = agents.get(toolUseId)! + agents.set(toolUseId, { + ...current, + state: 'done', + lastMessage: extractMessageText(parsed) ?? current.lastMessage + }) + } + } + + if (malformed > 0) { + warnings.push(`${malformed} malformed transcript line${malformed === 1 ? '' : 's'} skipped.`) + } + if (target.prompt.trim() && agents.size === 0) { + agents.set(target.paneKey, { + id: target.paneKey, + label: target.terminalTitle || 'Claude turn', + state: target.state === 'idle' ? 'unknown' : target.state, + prompt: target.prompt + }) + } + metrics.totalTokens = (metrics.inputTokens ?? 0) + (metrics.outputTokens ?? 0) || undefined + if ( + firstTimestamp !== undefined && + lastTimestamp !== undefined && + lastTimestamp >= firstTimestamp + ) { + metrics.elapsedMs = lastTimestamp - firstTimestamp + } + return { + agents: [...agents.values()].slice(0, CLAUDE_WORKFLOW_AGENT_PREVIEW_LIMIT), + metrics, + scriptPath, + firstTimestamp, + lastTimestamp + } +} + +export async function readClaudeWorkflowDetail( + target: ClaudeWorkflowDetailTarget, + io: ClaudeWorkflowDetailReaderIo | null +): Promise { + const warnings: string[] = [] + const timeline = initialTimeline(target) + if (target.connectionId && !io) { + warnings.push( + 'Remote workflow detail reads are not supported in this build; showing row summary only.' + ) + return { + target, + summaryOnly: true, + source: 'remote-unsupported', + warnings, + timeline, + agents: [], + metrics: undefined + } + } + if (!io) { + warnings.push('No workflow file reader is available; showing row summary only.') + return { + target, + summaryOnly: true, + source: 'summary-only', + warnings, + timeline, + agents: [], + metrics: undefined + } + } + + const transcriptPath = safeSelectorPath( + target.worktreePath, + target.selectors?.transcriptPath, + warnings, + 'Transcript path' + ) + if (!transcriptPath) { + warnings.push('No Claude workflow transcript was discovered for this row.') + return { + target, + summaryOnly: true, + source: 'summary-only', + warnings, + timeline, + agents: [], + metrics: undefined + } + } + + let transcript: ReadPreviewResult + try { + transcript = await io.readPreview(transcriptPath, CLAUDE_WORKFLOW_TRANSCRIPT_PREVIEW_BYTES) + } catch (error) { + warnings.push(error instanceof Error ? error.message : 'Could not read Claude transcript.') + return { + target, + summaryOnly: true, + source: 'summary-only', + warnings, + timeline, + agents: [], + metrics: undefined + } + } + + const transcriptPreview: ClaudeWorkflowPreview = { + path: transcriptPath, + content: transcript.content, + truncated: transcript.truncated, + binary: detectBinaryLooking(transcript.content), + bytesRead: transcript.bytesRead + } + if (transcriptPreview.binary) { + transcriptPreview.content = '' + warnings.push('Transcript preview looked binary and was hidden.') + } + if (transcript.truncated) { + warnings.push('Transcript preview was truncated.') + } + + const parsed = transcriptPreview.binary + ? { agents: [], metrics: {}, scriptPath: undefined } + : parseTranscript(transcript.content, target, warnings) + let scriptPath = safeSelectorPath( + target.worktreePath, + target.selectors?.scriptPath ?? parsed.scriptPath, + warnings, + 'Script path' + ) + let scriptPreview: ClaudeWorkflowPreview | undefined + let mtimeMs = transcript.mtimeMs + if (scriptPath) { + try { + const script = await io.readPreview(scriptPath, CLAUDE_WORKFLOW_SCRIPT_PREVIEW_BYTES) + const binary = detectBinaryLooking(script.content) + scriptPreview = { + path: scriptPath, + content: binary ? '' : script.content, + truncated: script.truncated, + binary, + bytesRead: script.bytesRead + } + if (binary) { + warnings.push('Script preview looked binary and was hidden.') + } + if (script.truncated) { + warnings.push('Script preview was truncated.') + } + mtimeMs = Math.max(mtimeMs ?? 0, script.mtimeMs ?? 0) || mtimeMs + } catch (error) { + scriptPath = undefined + warnings.push(error instanceof Error ? error.message : 'Could not read generated script.') + } + } + + return { + target, + summaryOnly: false, + source: 'local', + warnings, + mtimeMs, + timeline, + agents: parsed.agents, + transcriptPreview, + scriptPreview, + metrics: parsed.metrics + } +}