Skip to content

Commit 87567b7

Browse files
committed
fix(vscode): stop the workflow card reporting work that is not happening
The per-agent bar was filled by `stepCount / busiest lane`. That compares agents to each other rather than measuring progress through anything, so agents doing similar amounts of work all sat near full and never visibly moved — the card read as stuck while the run was fine, and it needed a caption underneath to explain what the bar even meant. A running lane now shows the silver spinner the webview already ships, and the caption and the bar are gone. The header bar stays: agents finished over agents started is the one ratio on the card that is true and that moves when something happens. A lane also kept spinning after the workflow returned. The tool call had a result and one lane still read as running, so the card showed live work for an agent that was cancelled or cut off with the turn. Once the result is in, those lanes are marked `no result` and counted under the list instead. Rows are a table now — status, label, current activity, counts — so the counts line up in a column, and the activity line folds into the row rather than taking a second line per agent.
1 parent 24f7cc0 commit 87567b7

3 files changed

Lines changed: 91 additions & 41 deletions

File tree

‎apps/vscode/test/event-handlers.test.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { beforeEach, describe, expect, it, vi } from "vitest";
99
import { useChatStore } from "../webview-ui/src/stores/chat.store";
1010
import { useApprovalStore } from "../webview-ui/src/stores/approval.store";
11-
import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes";
11+
import { abandonedLanes, deriveWorkflowLanes, isLaneSettled } from "../webview-ui/src/lib/workflow-lanes";
1212
import type { UIStepItem } from "../webview-ui/src/stores/chat.store";
1313

1414
const boundary = vi.hoisted(() => ({
@@ -299,7 +299,7 @@ describe("Webview DynamicWorkflow per-agent lanes", () => {
299299
});
300300

301301
describe("workflow lane derivation", () => {
302-
it("groups steps by agent, orders lanes by agentIndex, and sizes the bar to the busiest lane", () => {
302+
it("groups steps by agent and orders lanes by agentIndex", () => {
303303
const steps = [
304304
{ n: 1, items: [], agentId: "b", agentLabel: "explore", agentIndex: 2 },
305305
{ n: 1, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 },
@@ -315,7 +315,31 @@ describe("workflow lane derivation", () => {
315315

316316
expect(lanes.map((l) => l.agentId)).toEqual(["a", "b", "c"]);
317317
expect(lanes.map((l) => l.stepCount)).toEqual([2, 1, 0]);
318-
expect(maxLaneStepCount(lanes)).toBe(2);
318+
expect(lanes.map((l) => l.status)).toEqual(["done", "running", "spawned"]);
319+
});
320+
321+
it("calls no lane abandoned while the workflow is still going", () => {
322+
const lanes = [
323+
{ status: "running" as const },
324+
{ status: "spawned" as const },
325+
{ status: "done" as const },
326+
];
327+
328+
expect(abandonedLanes(lanes as never, false)).toEqual([]);
329+
});
330+
331+
it("reports the lanes a finished workflow never got a result from", () => {
332+
const lanes = [
333+
{ agentId: "a", status: "done" as const },
334+
{ agentId: "b", status: "failed" as const },
335+
// The workflow returned while this one still read as running: it was
336+
// cancelled, or the turn ended under it.
337+
{ agentId: "c", status: "running" as const },
338+
{ agentId: "d", status: "spawned" as const },
339+
];
340+
341+
expect(abandonedLanes(lanes as never, true).map((l) => l.agentId)).toEqual(["c", "d"]);
342+
expect(lanes.filter((l) => isLaneSettled(l)).map((l) => l.agentId)).toEqual(["a", "b"]);
319343
});
320344
});
321345

‎apps/vscode/webview-ui/src/components/WorkflowCard.tsx‎

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useMemo, useState, type ReactNode } from "react";
22
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
33
import { 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";
55
import { getToolLabel, parseArgs } from "@/lib/tool-args";
6+
import { SilverSpinner } from "./SilverSpinner";
67
import type { UIToolCall, UIStep, UIStepItem, UISubagentStatus, UIWorkflowWarning } from "@/stores/chat.store";
78
import 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
);

‎apps/vscode/webview-ui/src/lib/workflow-lanes.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,19 @@ export function deriveWorkflowLanes(steps: UIStep[], statuses: Record<string, UI
5656
return lanes;
5757
}
5858

59-
/** The comparative-fill denominator: the busiest lane's step count. There is no
60-
* per-agent step total, so progress bars read relative to each other, not absolute. */
61-
export function maxLaneStepCount(lanes: readonly WorkflowLane[]): number {
62-
return lanes.reduce((max, lane) => Math.max(max, lane.stepCount), 0);
59+
/** A lane that reported an outcome. Anything else is still owed one. */
60+
export function isLaneSettled(lane: Pick<WorkflowLane, "status">): boolean {
61+
return lane.status === "done" || lane.status === "failed";
62+
}
63+
64+
/**
65+
* The lanes a finished workflow never got an outcome from.
66+
*
67+
* Empty while the workflow is still going — a lane that has not reported yet is
68+
* simply still working. Once the tool call has returned nothing is running any
69+
* more, so a lane still marked `running` was cancelled or cut off with the turn,
70+
* and showing it as live is the card reporting work that already stopped.
71+
*/
72+
export function abandonedLanes(lanes: readonly WorkflowLane[], workflowEnded: boolean): WorkflowLane[] {
73+
return workflowEnded ? lanes.filter((lane) => !isLaneSettled(lane)) : [];
6374
}

0 commit comments

Comments
 (0)