Implement Claude workflow detail panel

Adds the Claude workflow detail panel described in docs/reference/claude-workflow-detail-panel.md, including main/preload IPC, renderer state, UI, and focused tests.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-30 04:01:37 -07:00
co-authored by Orca
parent b8b1ad0b55
commit 51595bb66e
26 changed files with 1541 additions and 6 deletions
@@ -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.
+20
View File
@@ -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<string, unknown>
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)
+70
View File
@@ -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<ClaudeWorkflowDetail> {
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)
}
+14 -1
View File
@@ -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
+1 -1
View File
@@ -103,7 +103,7 @@ export function registerCoreHandlers(
registerCodexUsageHandlers(codexUsage)
registerOpenCodeUsageHandlers(openCodeUsage)
registerCodexAccountHandlers(codexAccounts)
registerAgentHookHandlers()
registerAgentHookHandlers(store)
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
registerRateLimitHandlers(rateLimits)
+7
View File
@@ -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<ClaudeWorkflowDetail>
}
mobile: {
listNetworkInterfaces: () => Promise<{
interfaces: { name: string; address: string }[]
+9
View File
@@ -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<ClaudeWorkflowDetail> =>
ipcRenderer.invoke('claudeWorkflows:getDetail', request)
},
speech: {
getCatalog: (): Promise<SpeechModelManifest[]> => ipcRenderer.invoke('speech:getCatalog'),
getModelStates: (): Promise<SpeechModelState[]> => ipcRenderer.invoke('speech:getModelStates'),
+2
View File
@@ -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}
<DictationController />
<RecentTabSwitcher />
<ClaudeWorkflowDetailPanel />
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
@@ -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 (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
title={label}
onClick={() => void window.api.ui.writeClipboardText(value)}
>
<Copy className="size-3" />
</Button>
)
}
function WarningList({ warnings }: { warnings: string[] }) {
if (warnings.length === 0) {
return null
}
return (
<div className="space-y-1 border-b border-border/60 px-4 py-2 text-xs text-muted-foreground">
{warnings.map((warning) => (
<div key={warning} className="flex min-w-0 items-start gap-2">
<AlertCircle className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 break-words">{warning}</span>
</div>
))}
</div>
)
}
function TimelineTab({ detail }: { detail: ClaudeWorkflowDetail }) {
return (
<div className="space-y-3 p-4">
{detail.timeline.map((item) => (
<div key={item.id} className="grid grid-cols-[5rem_minmax(0,1fr)] gap-3 text-xs">
<div className="text-muted-foreground tabular-nums">{formatTime(item.startedAt)}</div>
<div className="min-w-0 border-l border-border pl-3">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-medium text-foreground">
{item.state ? agentStateLabel(item.state as AgentDotState) : 'Event'}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={item.label}>
{item.label}
</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{formatDuration(item.durationMs)}
</div>
</div>
</div>
))}
{detail.timeline.length === 0 && (
<p className="text-sm text-muted-foreground">No timeline events were available.</p>
)}
</div>
)
}
function AgentsTab({ detail }: { detail: ClaudeWorkflowDetail }) {
return (
<div className="divide-y divide-border/60">
{detail.agents.map((agent) => (
<div key={agent.id} className="grid gap-1 px-4 py-3 text-xs">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="min-w-0 truncate font-medium text-foreground" title={agent.label}>
{agent.label}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{agent.state}</span>
</div>
{agent.prompt && (
<p className="line-clamp-3 min-w-0 whitespace-pre-wrap break-words text-muted-foreground">
{agent.prompt}
</p>
)}
{agent.lastMessage && (
<p className="line-clamp-2 min-w-0 break-words text-muted-foreground/80">
{agent.lastMessage}
</p>
)}
</div>
))}
{detail.agents.length === 0 && (
<p className="p-4 text-sm text-muted-foreground">
No subagent transcript preview was available.
</p>
)}
</div>
)
}
function ScriptTab({ detail }: { detail: ClaudeWorkflowDetail }) {
const script = detail.scriptPreview
const isLocal = detail.target.connectionId === null
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-w-0 items-center gap-2 border-b border-border/60 px-4 py-2 text-xs">
<FileCode2 className="size-3.5 shrink-0 text-muted-foreground" />
<span
className="min-w-0 flex-1 truncate font-mono text-muted-foreground"
title={script?.path}
>
{script?.path ?? 'No generated script path was discovered.'}
</span>
<CopyButton value={script?.path} label="Copy script path" />
<CopyButton value={script?.content} label="Copy script preview" />
{isLocal && script?.path && (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Open script"
title="Open script"
onClick={() => void window.api.shell.openPath(script.path!)}
>
<ExternalLink className="size-3" />
</Button>
)}
</div>
<pre
className={cn(
'min-h-0 flex-1 overflow-auto scrollbar-sleek p-4 font-mono text-xs leading-relaxed',
'whitespace-pre-wrap break-words text-muted-foreground'
)}
>
{script?.binary
? 'Script preview looked binary and was hidden.'
: script?.content || 'No script preview available.'}
</pre>
</div>
)
}
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 (
<Sheet open={target !== null} onOpenChange={(open) => !open && close()}>
<SheetContent side="right" className="w-[min(92vw,720px)] sm:max-w-[720px]">
<SheetHeader className="border-b border-border/60 pr-12">
<SheetTitle className="truncate text-sm">
{target?.prompt?.trim() || 'Claude workflow'}
</SheetTitle>
<SheetDescription className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs">
{headerMeta.map((part) => (
<span key={part} className="min-w-0 truncate">
{part}
</span>
))}
</SheetDescription>
</SheetHeader>
<WarningList warnings={status.detail?.warnings ?? []} />
{status.loading && !status.detail ? (
<div className="flex flex-1 items-center gap-2 p-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading workflow detail…
</div>
) : status.error ? (
<div className="p-4 text-sm text-muted-foreground">{status.error}</div>
) : status.detail ? (
<Tabs value={tab} onValueChange={setTab} className="min-h-0 flex-1 gap-0">
<div className="border-b border-border/60 px-4 py-2">
<TabsList className="h-8">
<TabsTrigger value="timeline" className="text-xs">
Timeline
</TabsTrigger>
<TabsTrigger value="agents" className="text-xs">
Agents
</TabsTrigger>
<TabsTrigger value="script" className="text-xs">
Script
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="timeline" className="min-h-0 overflow-auto scrollbar-sleek">
<TimelineTab detail={status.detail} />
</TabsContent>
<TabsContent value="agents" className="min-h-0 overflow-auto scrollbar-sleek">
<AgentsTab detail={status.detail} />
</TabsContent>
<TabsContent value="script" className="min-h-0 overflow-hidden">
<ScriptTab detail={status.detail} />
</TabsContent>
</Tabs>
) : null}
</SheetContent>
</Sheet>
)
}
@@ -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<HTMLButtonElement>) => {
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. */}
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{onOpenDetails ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={handleOpenDetails}
onMouseDown={stopMouseDown}
onKeyDown={stopKeyDown}
aria-label="Open Claude workflow details"
className="opacity-0 transition-opacity duration-150 group-hover/agent-row:opacity-100 focus-visible:opacity-100"
>
<PanelRightOpen className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Details
</TooltipContent>
</Tooltip>
) : 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
@@ -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) =>
+2 -1
View File
@@ -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'
+2
View File
@@ -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<AppState>()((...a) => ({
...createRateLimitSlice(...a),
...createSshSlice(...a),
...createAgentStatusSlice(...a),
...createClaudeWorkflowDetailSlice(...a),
...createDiffCommentsSlice(...a),
...createDetectedAgentsSlice(...a),
...createWorktreeNavHistorySlice(...a),
@@ -257,6 +257,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// worker completes and the active dispatch closes. Preserve the last
// known parent-child link so done/retained rows stay grouped.
orchestration: payload.orchestration ?? existing?.orchestration,
claudeWorkflow: payload.claudeWorkflow ?? existing?.claudeWorkflow,
// Why: interrupted lives on `done` only. parseAgentStatusPayload
// already clamps it to `undefined` for non-done states, so writing
// the field through directly preserves truth for done and resets
@@ -0,0 +1,93 @@
import { create } from 'zustand'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createClaudeWorkflowDetailSlice } from './claude-workflow-detail'
import type { ClaudeWorkflowDetailTarget } from '../../../../shared/claude-workflow-detail'
function makeTarget(
overrides: Partial<ClaudeWorkflowDetailTarget> = {}
): 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<ReturnType<typeof createClaudeWorkflowDetailSlice>>()((...a) =>
createClaudeWorkflowDetailSlice(...(a as Parameters<typeof createClaudeWorkflowDetailSlice>))
)
}
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'])
})
})
@@ -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<string, ClaudeWorkflowDetail>
claudeWorkflowDetailStatus: ClaudeWorkflowDetailStatus
openClaudeWorkflowDetail: (target: ClaudeWorkflowDetailTarget) => void
closeClaudeWorkflowDetail: () => void
loadClaudeWorkflowDetail: () => Promise<void>
}
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
}
})
}
}
})
@@ -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),
@@ -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),
@@ -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),
@@ -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),
+2
View File
@@ -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 &
+11
View File
@@ -517,6 +517,17 @@ function createWebPreloadApi(): Partial<PreloadApi> {
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 }),
+35
View File
@@ -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<string, unknown>
): 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<string, unknown> | 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
+34 -1
View File
@@ -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<string, unknown>
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)
}
}
+113
View File
@@ -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> = {}): 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<string, string>): 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')
})
})
+498
View File
@@ -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<ReadPreviewResult>
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readString(record: Record<string, unknown>, 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, unknown>): 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<string, unknown>): 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<string, ClaudeWorkflowAgentDetail>()
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<ClaudeWorkflowDetail> {
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
}
}