11import { useMemo , useState , type ReactNode } from "react" ;
22import { IconChevronDown , IconChevronRight } from "@tabler/icons-react" ;
33import { cn } from "@/lib/utils" ;
4- import { deriveWorkflowLanes , maxLaneStepCount , type WorkflowLane } from "@/lib/workflow-lanes" ;
4+ import { abandonedLanes , deriveWorkflowLanes , isLaneSettled , type WorkflowLane } from "@/lib/workflow-lanes" ;
55import { getToolLabel , parseArgs } from "@/lib/tool-args" ;
6+ import { SilverSpinner } from "./SilverSpinner" ;
67import type { UIToolCall , UIStep , UIStepItem , UISubagentStatus , UIWorkflowWarning } from "@/stores/chat.store" ;
78import type { ToolResult } from "shared/legacy-sdk" ;
89
@@ -40,47 +41,52 @@ function laneMostRecentToolLabel(lane: WorkflowLane): string | null {
4041 return null ;
4142}
4243
43- function StatusDot ( { status } : { status : WorkflowLane [ "status" ] } ) {
44- const color =
45- status === "running" ? "bg-brand" : status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground" ;
46- return < span className = { cn ( "inline-block size-2 rounded-full shrink-0" , color ) } /> ;
44+ /**
45+ * A running lane spins; every other state is a dot.
46+ *
47+ * There used to be a per-lane bar here, filled by `stepCount / busiest lane`.
48+ * That is a comparison between agents, not progress through anything: agents
49+ * doing similar amounts of work all sat near full and never visibly moved, so
50+ * the bar read as stuck while the work was fine — and it needed a caption under
51+ * the card to explain what it even meant. A spinner claims only what is true,
52+ * that the lane is still going.
53+ */
54+ function LaneStatus ( { status, spin } : { status : WorkflowLane [ "status" ] ; spin : boolean } ) {
55+ if ( spin ) return < SilverSpinner className = "size-3" /> ;
56+ const color = status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground/50" ;
57+ return < span aria-hidden className = { cn ( "size-2 rounded-full" , color ) } /> ;
4758}
4859
49- function LaneBar ( { fraction, done } : { fraction : number ; done : boolean } ) {
50- return (
51- < div className = "h-[3px] w-24 rounded-full bg-muted overflow-hidden shrink-0" >
52- < div className = { cn ( "h-full rounded-full" , done ? "bg-success" : "bg-brand" ) } style = { { width : `${ Math . round ( fraction * 100 ) } %` } } />
53- </ div >
54- ) ;
55- }
56-
57- function LaneRow ( { lane, maxSteps, renderStepItem } : { lane : WorkflowLane ; maxSteps : number ; renderStepItem : ( item : UIStepItem ) => ReactNode } ) {
60+ function LaneRow ( { lane, workflowEnded, renderStepItem } : { lane : WorkflowLane ; workflowEnded : boolean ; renderStepItem : ( item : UIStepItem ) => ReactNode } ) {
5861 const [ expanded , setExpanded ] = useState ( false ) ;
59- const done = lane . status === "done" ;
60- // A finished lane always reads full: the bar is relative to the busiest agent,
61- // so a lane that did fewer steps than the busiest one would otherwise show a
62- // gap after it completed.
63- const fraction = done ? 1 : maxSteps > 0 ? lane . stepCount / maxSteps : 0 ;
64- const queued = lane . status === "spawned" && lane . stepCount === 0 ;
62+ // The workflow returned without this lane ever reporting an outcome — it was
63+ // cancelled, or the turn ended under it. It is not running, whatever its last
64+ // status said, and spinning here is the card claiming work that stopped.
65+ const abandoned = workflowEnded && ! isLaneSettled ( lane ) ;
66+ const queued = ! workflowEnded && lane . status === "spawned" && lane . stepCount === 0 ;
6567 const runningToolLabel = lane . status === "running" ? laneMostRecentToolLabel ( lane ) : null ;
6668 const duration = lane . startedAt !== undefined && lane . endedAt !== undefined ? formatDuration ( lane . endedAt - lane . startedAt ) : null ;
6769
6870 return (
6971 < div className = "text-xs" >
70- < button onClick = { ( ) => setExpanded ( ! expanded ) } aria-expanded = { expanded } className = "w-full flex items-center gap-2 py-1 hover:bg-muted/50 transition-colors text-left" disabled = { lane . stepCount === 0 } >
71- < StatusDot status = { lane . status } />
72+ < button onClick = { ( ) => setExpanded ( ! expanded ) } aria-expanded = { expanded } className = "w-full flex items-center gap-2 py-1 rounded hover:bg-muted/50 transition-colors text-left" disabled = { lane . stepCount === 0 } >
73+ { /* Fixed box so the labels line up whichever indicator the lane is showing. */ }
74+ < span className = "grid size-3 place-items-center shrink-0" >
75+ < LaneStatus status = { lane . status } spin = { lane . status === "running" && ! abandoned } />
76+ </ span >
7277 < span className = "font-mono text-[11px] shrink-0" > { laneLabel ( lane ) } </ span >
73- < LaneBar fraction = { fraction } done = { done } />
74- < span className = "text-muted-foreground tabular-nums shrink-0" >
75- { queued ? "queued" : `${ lane . status === "done" ? "done · " : "" } ${ lane . stepCount } step${ lane . stepCount === 1 ? "" : "s" } ` }
78+ { /* What the lane is doing now takes the slack, so the counts stay in a
79+ column instead of drifting with the width of each label. */ }
80+ < span className = "flex-1 min-w-0 truncate text-muted-foreground" > { abandoned ? "" : ( runningToolLabel ?? "" ) } </ span >
81+ < span className = { cn ( "tabular-nums shrink-0" , abandoned ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground" ) } >
82+ { queued ? "queued" : `${ abandoned ? "no result · " : lane . status === "done" ? "done · " : "" } ${ lane . stepCount } step${ lane . stepCount === 1 ? "" : "s" } ` }
7683 { duration && ` · ${ duration } ` }
7784 </ span >
78- { lane . stepCount > 0 && ( expanded ? < IconChevronDown className = "size-3 text-muted-foreground" /> : < IconChevronRight className = "size-3 text-muted-foreground" /> ) }
85+ { lane . stepCount > 0 && ( expanded ? < IconChevronDown className = "size-3 text-muted-foreground shrink-0 " /> : < IconChevronRight className = "size-3 text-muted-foreground shrink-0 " /> ) }
7986 </ button >
80- { lane . error && < div className = "pl-6 pb-1 text-[11px] text-destructive" > { lane . error } </ div > }
81- { runningToolLabel && < div className = "pl-6 pb-1 text-[11px] text-muted-foreground truncate" > { runningToolLabel } </ div > }
87+ { lane . error && < div className = "pl-5 pb-1 text-[11px] text-destructive" > { lane . error } </ div > }
8288 { expanded && (
83- < div className = "pl-6 pb-2 space-y-3" >
89+ < div className = "pl-5 pb-2 space-y-3" >
8490 { lane . steps . map ( ( step ) => (
8591 < div key = { `${ lane . agentId } -${ step . n } ` } className = "space-y-2" >
8692 < div className = "text-[0.7rem] text-muted-foreground uppercase tracking-wider" > Step { step . n } </ div >
@@ -103,10 +109,13 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work
103109 ( ) => deriveWorkflowLanes ( subagentSteps , subagentStatus ) ,
104110 [ subagentSteps , subagentStatus ] ,
105111 ) ;
106- const maxSteps = maxLaneStepCount ( lanes ) ;
107112 const doneCount = lanes . filter ( ( lane ) => lane . status === "done" ) . length ;
108113 const totalSteps = lanes . reduce ( ( sum , lane ) => sum + lane . stepCount , 0 ) ;
109114 const showBatchBar = lanes . length > 1 ;
115+ // The tool call returned, so no lane can still be doing work — whatever the
116+ // last status event said about one that never reported an outcome.
117+ const workflowEnded = result !== undefined ;
118+ const abandonedCount = abandonedLanes ( lanes , workflowEnded ) . length ;
110119
111120 return (
112121 < div className = "rounded-lg border border-border overflow-hidden" >
@@ -116,10 +125,12 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work
116125 < span >
117126 { lanes . length } agent{ lanes . length !== 1 ? "s" : "" } · { doneCount } done · { totalSteps } steps
118127 </ span >
128+ { /* The one honest bar on the card: agents finished over agents started.
129+ It only moves when a lane actually completes. */ }
119130 { showBatchBar && (
120131 < >
121132 < div className = "h-[3px] flex-1 rounded-full bg-muted overflow-hidden" >
122- < div className = "h-full rounded-full bg-success" style = { { width : `${ lanes . length > 0 ? Math . round ( ( doneCount / lanes . length ) * 100 ) : 0 } %` } } />
133+ < div className = "h-full rounded-full bg-success transition-[width] duration-500 " style = { { width : `${ Math . round ( ( doneCount / lanes . length ) * 100 ) } %` } } />
123134 </ div >
124135 < span >
125136 { doneCount } /{ lanes . length }
@@ -133,12 +144,16 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work
133144 { workflowWarning . message }
134145 </ div >
135146 ) }
136- < div className = "px-3 pb-2 border-t border-border max-h-96 overflow-y-auto" >
147+ < div className = "px-3 py-1 border-t border-border max-h-96 overflow-y-auto" >
137148 { lanes . map ( ( lane ) => (
138- < LaneRow key = { lane . agentId } lane = { lane } maxSteps = { maxSteps } renderStepItem = { renderStepItem } />
149+ < LaneRow key = { lane . agentId } lane = { lane } workflowEnded = { workflowEnded } renderStepItem = { renderStepItem } />
139150 ) ) }
140151 </ div >
141- { maxSteps > 0 && < div className = "px-3 pb-2 text-[10px] text-muted-foreground" > bar = steps relative to busiest agent ({ maxSteps } )</ div > }
152+ { abandonedCount > 0 && (
153+ < div className = "px-3 pb-2 text-[11px] text-amber-600 dark:text-amber-400" >
154+ { abandonedCount } agent{ abandonedCount === 1 ? "" : "s" } stopped without reporting a result.
155+ </ div >
156+ ) }
142157 { result ?. is_error && < div className = "px-3 pb-2 text-[11px] text-destructive" > { typeof result . output === "string" ? result . output : "Workflow failed" } </ div > }
143158 </ div >
144159 ) ;
0 commit comments