mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 00:02:48 +00:00
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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -103,7 +103,7 @@ export function registerCoreHandlers(
|
||||
registerCodexUsageHandlers(codexUsage)
|
||||
registerOpenCodeUsageHandlers(openCodeUsage)
|
||||
registerCodexAccountHandlers(codexAccounts)
|
||||
registerAgentHookHandlers()
|
||||
registerAgentHookHandlers(store)
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
|
||||
@@ -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 }[]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user