Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/client/app/KannaTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AccountInfoMessage } from "../components/messages/AccountInfoMessage"
import { TextMessage } from "../components/messages/TextMessage"
import { AskUserQuestionMessage } from "../components/messages/AskUserQuestionMessage"
import { ExitPlanModeMessage } from "../components/messages/ExitPlanModeMessage"
import { WorkflowMessage } from "../components/messages/WorkflowMessage"
import { TodoWriteMessage } from "../components/messages/TodoWriteMessage"
import { ToolCallMessage } from "../components/messages/ToolCallMessage"
import { ResultMessage } from "../components/messages/ResultMessage"
Expand Down Expand Up @@ -335,6 +336,15 @@ function sameMessage(left: HydratedTranscriptMessage, right: HydratedTranscriptM
return right.kind === "handoff_boundary"
&& left.fromProvider === right.fromProvider
&& left.toProvider === right.toProvider
case "workflow_state":
// lastSnapshotId is the _id of the newest folded snapshot: any
// state/usage/agent change produces a new snapshot entry, so comparing
// it is sufficient (and far cheaper than deep-comparing agents).
// createdAt-based revisions are NOT safe here — two lifecycle snapshots
// can land in the same millisecond.
return right.kind === "workflow_state"
&& left.taskId === right.taskId
&& left.lastSnapshotId === right.lastSnapshotId
case "unknown":
return right.kind === "unknown" && left.json === right.json
}
Expand Down Expand Up @@ -511,6 +521,9 @@ const TranscriptSingleRow = memo(function TranscriptSingleRow({
case "interrupted":
rendered = <InterruptedMessage key={message.id} message={message} />
break
case "workflow_state":
rendered = <WorkflowMessage key={message.id} message={message} />
break
case "compact_boundary":
rendered = <CompactBoundaryMessage key={message.id} />
break
Expand Down
213 changes: 213 additions & 0 deletions src/client/components/messages/WorkflowMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { useEffect, useMemo, useState } from "react"
import { ChevronRight, CircleCheck, CircleX, Pause, Workflow as WorkflowIcon } from "lucide-react"
import type { HydratedTranscriptMessage, WorkflowAgentSnapshot, WorkflowRunStatus } from "../../../shared/types"
import { cn } from "../../lib/utils"
import { AnimatedShinyText } from "../ui/animated-shiny-text"

type WorkflowStateMessage = Extract<HydratedTranscriptMessage, { kind: "workflow_state" }>

interface Props {
message: WorkflowStateMessage
}

// Local copy: upstream has no shared formatDuration export.
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts: string[] = []
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (seconds > 0) parts.push(`${seconds}s`)
return parts.join(" ") || "0s"
}

function formatTokens(tokens: number): string {
if (tokens < 1_000) return `${Math.round(tokens)}`
if (tokens < 1_000_000) return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`
return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`
}

function formatShortDuration(ms: number | undefined): string {
if (ms === undefined || ms < 0) return "—"
if (ms < 1000) return "<1s"
return formatDuration(ms)
}

function statusIcon(status: WorkflowRunStatus) {
if (status === "completed") return <CircleCheck className="size-4 shrink-0 text-emerald-500" />
if (status === "failed" || status === "killed") return <CircleX className="size-4 shrink-0 text-destructive" />
if (status === "paused") return <Pause className="size-4 shrink-0 text-muted-icon" />
return <WorkflowIcon className="size-4 shrink-0 text-muted-icon" />
}

function agentDotClass(state: WorkflowAgentSnapshot["state"]): string {
switch (state) {
case "done": return "bg-emerald-500"
case "error": return "bg-destructive"
case "running": return "bg-amber-400 animate-pulse"
default: return "bg-muted-foreground/25"
}
}

const MAX_GRID_DOTS = 200

/** Live elapsed while running; frozen at the last snapshot's age once terminal. */
function useElapsedMs(message: WorkflowStateMessage): number {
const running = message.status === "running" || message.status === "pending"
const [now, setNow] = useState(() => Date.now())

useEffect(() => {
if (!running) return
const timer = setInterval(() => setNow(Date.now()), 1_000)
return () => clearInterval(timer)
}, [running])

if (!running) return Math.max(0, message.revision - message.startedAtMs)
return Math.max(0, now - message.startedAtMs)
}

function AgentRow({ agent }: { agent: WorkflowAgentSnapshot }) {
return (
<tr className="border-t border-border/50">
<td className="flex min-w-0 items-center gap-1.5 py-1 pr-2">
<span className={cn("size-1.5 shrink-0 rounded-full", agentDotClass(agent.state))} />
<span className="min-w-0 truncate" title={agent.error ?? agent.promptPreview}>
{agent.label}
</span>
{agent.error ? <span className="min-w-0 truncate text-destructive/80" title={agent.error}>{agent.error}</span> : null}
</td>
<td className="whitespace-nowrap py-1 pr-2 text-right tabular-nums text-muted-foreground">
{agent.tokens !== undefined && agent.tokens > 0 ? formatTokens(agent.tokens) : "—"}
</td>
<td className="whitespace-nowrap py-1 pr-2 text-right tabular-nums text-muted-foreground">
{agent.toolCalls ?? 0}
</td>
<td className="whitespace-nowrap py-1 text-right tabular-nums text-muted-foreground">
{formatShortDuration(agent.durationMs)}
</td>
</tr>
)
}

export function WorkflowMessage({ message }: Props) {
const [expanded, setExpanded] = useState(false)
const elapsedMs = useElapsedMs(message)
const running = message.status === "running" || message.status === "pending"

const agents = message.agents
const totalTokens = useMemo(() => {
const agentSum = agents.reduce((sum, agent) => sum + (agent.tokens ?? 0), 0)
return Math.max(message.usage?.totalTokens ?? 0, agentSum)
}, [agents, message.usage?.totalTokens])

const phaseGroups = useMemo(() => {
const groups = new Map<number, { title: string | null; agents: WorkflowAgentSnapshot[] }>()
for (const phase of message.phases) {
groups.set(phase.index, { title: phase.title, agents: [] })
}
for (const agent of agents) {
const key = agent.phaseIndex ?? 0
let group = groups.get(key)
if (!group) {
group = { title: agent.phaseTitle ?? null, agents: [] }
groups.set(key, group)
}
group.agents.push(agent)
}
return [...groups.entries()]
.sort(([left], [right]) => left - right)
.map(([, group]) => group)
.filter((group) => group.agents.length > 0)
}, [agents, message.phases])

const name = message.workflowName ?? "workflow"
const metaParts = [
"Workflow",
agents.length > 0 ? `${agents.length} agent${agents.length === 1 ? "" : "s"}` : null,
elapsedMs > 0 ? formatShortDuration(elapsedMs) : null,
totalTokens > 0 ? `${formatTokens(totalTokens)} tokens` : null,
].filter(Boolean)

return (
<div className="my-1 w-full max-w-xl rounded-xl border border-border bg-card/60 px-3 py-2.5">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="group/workflow flex w-full min-w-0 cursor-pointer items-center gap-2.5 text-left"
aria-expanded={expanded}
>
<span className="flex size-5 shrink-0 items-center justify-center">
{statusIcon(message.status)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
<AnimatedShinyText animate={running}>{name}</AnimatedShinyText>
</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{metaParts.map((part, index) => (
<span key={part} className="flex shrink-0 items-center gap-1.5 whitespace-nowrap tabular-nums">
{index > 0 ? <span className="text-muted-foreground/50">·</span> : null}
{part}
</span>
))}
</span>
</span>
<ChevronRight
className={cn(
"size-4 shrink-0 text-muted-icon transition-transform duration-200",
expanded && "rotate-90",
)}
/>
</button>

{agents.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-[3px] pl-[30px]" aria-hidden>
{agents.slice(0, MAX_GRID_DOTS).map((agent) => (
<span
key={agent.index}
className={cn("size-[7px] rounded-[2px]", agentDotClass(agent.state))}
title={`${agent.label}: ${agent.state}`}
/>
))}
{agents.length > MAX_GRID_DOTS ? (
<span className="text-[9px] leading-none text-muted-foreground">+{agents.length - MAX_GRID_DOTS}</span>
) : null}
</div>
) : null}

{expanded ? (
<div className="mt-2.5 border-t border-border/60 pt-2 pl-[30px]">
{message.description ? (
<p className="mb-2 text-xs text-muted-foreground">{message.description}</p>
) : null}
{phaseGroups.map((group, groupIndex) => (
<div key={group.title ?? groupIndex} className={cn(groupIndex > 0 && "mt-3")}>
{group.title ? (
<div className="mb-1 text-xs font-medium text-foreground/80">{group.title}</div>
) : null}
<table className="w-full table-fixed text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="w-auto pb-1 font-medium">Agent</th>
<th className="w-16 pb-1 pr-2 text-right font-medium">Tokens</th>
<th className="w-12 pb-1 pr-2 text-right font-medium">Tools</th>
<th className="w-14 pb-1 text-right font-medium">Time</th>
</tr>
</thead>
<tbody>
{group.agents.map((agent) => <AgentRow key={agent.index} agent={agent} />)}
</tbody>
</table>
</div>
))}
{message.summary ? (
<p className="mt-2 text-xs text-muted-foreground">{message.summary}</p>
) : null}
</div>
) : null}
</div>
)
}
49 changes: 49 additions & 0 deletions src/client/lib/parseTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,4 +342,53 @@ describe("getLatestToolIds", () => {
TodoWrite: null,
})
})

test("folds same-millisecond workflow snapshots without reusing stale state", () => {
const createdAt = Date.now()
const first: TranscriptEntry = {
_id: "workflow-snapshot-1",
createdAt,
kind: "workflow_state",
taskId: "workflow-task-1",
toolId: "workflow-tool-1",
workflowName: "demo",
status: "running",
phases: [],
agents: [{ index: 1, label: "agent-1", state: "running" }],
}
const second: TranscriptEntry = {
...first,
_id: "workflow-snapshot-2",
status: "completed",
agents: [{ index: 1, label: "agent-1", state: "done", tokens: 500 }],
}

const messages = processTranscriptMessages([
entry({
kind: "tool_call",
tool: {
kind: "tool",
toolKind: "unknown_tool",
toolName: "Workflow",
toolId: "workflow-tool-1",
input: {},
},
}),
first,
second,
])

const toolCall = messages.find((message) => message.kind === "tool")
expect(toolCall?.hidden).toBe(true)

const workflowMessages = messages.filter((message) => message.kind === "workflow_state")
expect(workflowMessages).toHaveLength(1)
const workflow = workflowMessages[0]
if (workflow?.kind !== "workflow_state") throw new Error("unexpected message")
expect(workflow.status).toBe("completed")
expect(workflow.agents[0]).toMatchObject({ state: "done", tokens: 500 })
expect(workflow.lastSnapshotId).toBe("workflow-snapshot-2")
expect(workflow.startedAtMs).toBe(createdAt)
expect(workflow.id).toBe("workflow-snapshot-1")
})
})
41 changes: 41 additions & 0 deletions src/client/lib/parseTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ function getStructuredToolResultFromDebug(entry: Extract<TranscriptEntry, { kind
}
}

type WorkflowStateMessage = Extract<HydratedTranscriptMessage, { kind: "workflow_state" }>

export function processTranscriptMessages(entries: TranscriptEntry[]): HydratedTranscriptMessage[] {
const pendingToolCalls = new Map<string, { hydrated: HydratedToolCall; normalized: NormalizedToolCall }>()
// Latest workflow snapshot per background-task id: the server appends
// snapshots, the client keeps last-write-wins anchored at first occurrence.
const workflowMessages = new Map<string, WorkflowStateMessage>()
const messages: HydratedTranscriptMessage[] = []

for (const entry of entries) {
Expand Down Expand Up @@ -161,6 +166,42 @@ export function processTranscriptMessages(entries: TranscriptEntry[]): HydratedT
kind: "interrupted",
})
break
case "workflow_state": {
// The raw Workflow tool card is superseded by the workflow card once
// lifecycle snapshots exist for its tool-use id.
const spawningCall = entry.toolId ? pendingToolCalls.get(entry.toolId) : undefined
if (spawningCall) spawningCall.hydrated.hidden = true

Comment on lines +172 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Completed Tool Card Stays Visible

If the Workflow tool result is processed before the first lifecycle snapshot, its call is no longer in pendingToolCalls. This lookup then cannot hide the completed generic tool card, so the transcript displays both that card and the structured workflow card for the same run.

Fix in Codex

const fields = {
status: entry.status,
usage: entry.usage,
phases: entry.phases,
agents: entry.agents,
workflowName: entry.workflowName,
description: entry.description,
summary: entry.summary,
// _id is the change marker (unique per snapshot); createdAt feeds
// elapsed-time math but can collide within a millisecond.
lastSnapshotId: entry._id,
revision: entry.createdAt,
}
const existing = workflowMessages.get(entry.taskId)
if (existing) {
Object.assign(existing, fields)
} else {
const message: WorkflowStateMessage = {
...createBaseMessage(entry),
kind: "workflow_state",
taskId: entry.taskId,
toolId: entry.toolId,
startedAtMs: entry.createdAt,
...fields,
}
workflowMessages.set(entry.taskId, message)
messages.push(message)
}
break
}
default:
messages.push({
...createBaseMessage(entry),
Expand Down
9 changes: 9 additions & 0 deletions src/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { fallbackTitleFromMessage } from "./generate-title"
import { asNumber, asRecord } from "../shared/json"
import { buildHandoffContext, buildHandoffMessageContent, type HandoffContext } from "./handoff"
import { timestamped } from "./transcript"
import { WorkflowTracker } from "./workflow-tracker"

const CLAUDE_TOOLSET = [
"Skill",
Expand Down Expand Up @@ -503,6 +504,7 @@ async function* createClaudeHarnessStream(
let seenAssistantUsageIds = new Set<string>()
let latestUsageSnapshot: ContextWindowUsageSnapshot | null = null
let lastKnownContextWindow: number | undefined
const workflowTracker = new WorkflowTracker()

for await (const sdkMessage of q as AsyncIterable<any>) {
const sessionToken = typeof sdkMessage.session_id === "string" ? sdkMessage.session_id : null
Expand Down Expand Up @@ -609,6 +611,13 @@ async function* createClaudeHarnessStream(
for (const entry of normalizeClaudeStreamMessage(sdkMessage)) {
yield { type: "transcript", entry }
}

// Background-task lifecycle (system/task_*) is invisible to the
// normalizer; the workflow tracker folds it into canonical
// workflow_state snapshots.
for (const entry of workflowTracker.process(sdkMessage)) {
yield { type: "transcript", entry }
}
}
}

Expand Down
Loading