authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 02:19:20-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 23:19:20-07:00
logcf5c9948c895e120965165b7b2b643ca275c5315
tree1c18910466ad84ab87158e3ed714370feb94cc42
parent2288d416aa3bcb5f2eeb4004228872593296971e
signature Signed by PGP key B5690EEEBB952194

fix(web): keep agent panel rows stable (#5569)


5 files changed, 369 insertions(+), 144 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts created+84
...@@ -0,0 +1,84 @@
1import {
2 EventId,
3 ProviderDriverKind,
4 RuntimeTaskId,
5 ThreadId,
6 type ProviderRuntimeEvent,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts";
11
12const base = {
13 provider: ProviderDriverKind.make("codex"),
14 createdAt: "2026-08-06T00:00:00.000Z",
15 threadId: ThreadId.make("thread-1"),
16};
17
18describe("runtimeEventToActivities task progress", () => {
19 it("persists usage independently from replaceable activity", () => {
20 const taskId = RuntimeTaskId.make("agent-1");
21 const usageOnly = {
22 ...base,
23 type: "task.progress",
24 eventId: EventId.make("evt-usage"),
25 payload: {
26 taskId,
27 description: "Agent one",
28 typedUsage: { totalTokens: 73_700_000 },
29 },
30 } satisfies ProviderRuntimeEvent;
31 const command = {
32 ...base,
33 type: "task.progress",
34 eventId: EventId.make("evt-command"),
35 payload: {
36 taskId,
37 description: "Agent one",
38 summary: "Running tests",
39 lastToolName: "exec_command",
40 },
41 } satisfies ProviderRuntimeEvent;
42
43 const usageActivities = runtimeEventToActivities(usageOnly);
44 const commandActivities = runtimeEventToActivities(command);
45
46 expect(usageActivities.map((activity) => activity.id)).toEqual(["task-usage:thread-1:agent-1"]);
47 expect(commandActivities.map((activity) => activity.id)).toEqual([
48 "task-progress:thread-1:agent-1",
49 ]);
50 const usagePayload = usageActivities[0]?.payload as Record<string, unknown> | undefined;
51 expect(usagePayload?.typedUsage).toEqual({ totalTokens: 73_700_000 });
52 expect(usagePayload?.usageSnapshot).toBe(true);
53 });
54
55 it("splits combined progress and usage into their independent snapshots", () => {
56 const event = {
57 ...base,
58 type: "task.progress",
59 eventId: EventId.make("evt-combined"),
60 payload: {
61 taskId: RuntimeTaskId.make("agent-2"),
62 description: "Agent two",
63 summary: "Inspecting the panel",
64 typedUsage: { totalTokens: 4_200, toolUses: 7 },
65 status: "running",
66 },
67 } satisfies ProviderRuntimeEvent;
68
69 const activities = runtimeEventToActivities(event);
70 const progressPayload = activities[0]?.payload as Record<string, unknown>;
71 const usagePayload = activities[1]?.payload as Record<string, unknown>;
72
73 expect(activities.map((activity) => activity.id)).toEqual([
74 "task-progress:thread-1:agent-2",
75 "task-usage:thread-1:agent-2",
76 ]);
77 expect(progressPayload.summary).toBe("Inspecting the panel");
78 expect(progressPayload.status).toBe("running");
79 expect(progressPayload).not.toHaveProperty("typedUsage");
80 expect(usagePayload.typedUsage).toEqual({ totalTokens: 4_200, toolUses: 7 });
81 expect(usagePayload.usageSnapshot).toBe(true);
82 expect(usagePayload).not.toHaveProperty("status");
83 });
84});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+73-32
...@@ -563,39 +563,80 @@ export function runtimeEventToActivities(...@@ -563,39 +563,80 @@ export function runtimeEventToActivities(
563 }563 }
564564
565 case "task.progress": {565 case "task.progress": {
566 const linkage = taskLinkageActivityFields(event.payload as Record<string, unknown>);
567 // Usage and activity are independent latest-state streams. Keeping them
568 // under separate stable ids prevents a command/reasoning update from
569 // replacing the last known token count (and prevents a usage-only tick
570 // from blanking the last meaningful activity).
571 const identityLinkage = { ...linkage };
572 delete identityLinkage.typedUsage;
573 delete identityLinkage.status;
574 delete identityLinkage.error;
575 const title =
576 event.payload.description.trim().length > 0
577 ? { title: truncateDetail(event.payload.description, 120) }
578 : {};
579 const hasProgressState =
580 event.payload.typedUsage === undefined ||
581 event.payload.summary !== undefined ||
582 event.payload.lastToolName !== undefined ||
583 event.payload.status !== undefined ||
584 event.payload.error !== undefined;
566 return [585 return [
567 {586 ...(hasProgressState
568 // Stable per-task id: progress is "latest state", not history, so587 ? [
569 // each tick REPLACES the last via the activity upsert (PK + the588 {
570 // replace-by-id apply in projector and client reducer). Keeps one589 // Stable per-task id: activity is "latest state", not
571 // progress row per task instead of thousands, so a large fleet's590 // history, so each meaningful tick replaces the last. This
572 // ticks can no longer evict its own start/terminal rows out of591 // bounds a large fleet to one activity row per task.
573 // the 500-row retention window. Thread-scoped: activity_id is a592 id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
574 // GLOBAL primary key and Claude task ids are session-local, so a593 createdAt: event.createdAt,
575 // bare taskId could collide across threads and steal another594 tone: "info" as const,
576 // thread's row (review finding).595 kind: "task.progress" as const,
577 id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),596 summary:
578 createdAt: event.createdAt,597 event.payload.description.trim().length > 0
579 tone: "info",598 ? truncateDetail(event.payload.description, 120)
580 kind: "task.progress",599 : "Reasoning update",
581 summary:600 payload: {
582 event.payload.description.trim().length > 0601 taskId: event.payload.taskId,
583 ? truncateDetail(event.payload.description, 120)602 ...title,
584 : "Reasoning update",603 detail: truncateDetail(event.payload.summary ?? event.payload.description),
585 payload: {604 ...(event.payload.summary
586 taskId: event.payload.taskId,605 ? { summary: truncateDetail(event.payload.summary) }
587 ...(event.payload.description.trim().length > 0606 : {}),
588 ? { title: truncateDetail(event.payload.description, 120) }607 ...(event.payload.lastToolName
589 : {}),608 ? { lastToolName: event.payload.lastToolName }
590 detail: truncateDetail(event.payload.summary ?? event.payload.description),609 : {}),
591 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),610 ...(event.payload.status ? { status: event.payload.status } : {}),
592 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),611 ...(event.payload.error ? { error: event.payload.error } : {}),
593 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),612 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
594 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),613 ...identityLinkage,
595 },614 },
596 turnId: toTurnId(event.turnId) ?? null,615 turnId: toTurnId(event.turnId) ?? null,
597 ...maybeSequence,616 ...maybeSequence,
598 },617 },
618 ]
619 : []),
620 ...(event.payload.typedUsage !== undefined
621 ? [
622 {
623 id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`),
624 createdAt: event.createdAt,
625 tone: "info" as const,
626 kind: "task.progress" as const,
627 summary: "Task usage updated",
628 payload: {
629 taskId: event.payload.taskId,
630 ...title,
631 ...identityLinkage,
632 usageSnapshot: true,
633 typedUsage: event.payload.typedUsage,
634 },
635 turnId: toTurnId(event.turnId) ?? null,
636 ...maybeSequence,
637 },
638 ]
639 : []),
599 ];640 ];
600 }641 }
601642
apps/web/src/components/AgentsPanel.tsx+121-108
...@@ -4,13 +4,11 @@...@@ -4,13 +4,11 @@
4 * spawn batch).4 * spawn batch).
5 *5 *
6 * Visualization rules (from live-test feedback):6 * Visualization rules (from live-test feedback):
7 * - Live work first: running workflows and direct spawns sort above settled.7 * - Spawn order is stable. Activity and completion update rows in place.
8 * - Rows are flat status lines — no expansion, no per-agent tool feeds. The8 * - Agent rows reserve three fixed lines for identity, activity, and metrics;
9 * row answers "who / what phase / how much"; anything deeper is a future9 * changing data must never change their height.
10 * drill-in, not an unfold.10 * - Workflow expansion is presentation state. A live run stays expanded when
11 * - A settled workflow run collapses to a single summary line; click it to11 * it settles; older collapsed runs can still be opened at run granularity.
12 * show its member list inline (the one allowed toggle — run granularity,
13 * not agent granularity).
14 * - Static status dots, DOM-write elapsed timers, plain token counters.12 * - Static status dots, DOM-write elapsed timers, plain token counters.
15 */13 */
16import { useAtomValue } from "@effect/atom-react";14import { useAtomValue } from "@effect/atom-react";
...@@ -143,54 +141,50 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {...@@ -143,54 +141,50 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {
143 const visuals = STATUS_VISUALS[agent.status];141 const visuals = STATUS_VISUALS[agent.status];
144 const activity = agentActivityText(agent);142 const activity = agentActivityText(agent);
145 const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);143 const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);
144 const role =
145 agent.role?.trim().toLocaleLowerCase() === agent.title.trim().toLocaleLowerCase()
146 ? null
147 : agent.role;
148 const metadata = [
149 modelLabel,
150 agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok",
151 agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null,
152 agent.activationCount > 1 ? `run ${agent.activationCount}` : null,
153 ].filter((value): value is string => value !== null);
146154
147 return (155 return (
148 <div className="rounded-md px-1.5 py-1">156 <div className="grid h-[3.875rem] grid-cols-[0.375rem_minmax(0,1fr)_auto] grid-rows-[1.25rem_1.125rem_1rem] items-center gap-x-2 rounded-md px-1.5 py-1">
149 <div className="flex items-start gap-2">157 <span className="col-start-1 row-start-1 flex items-center">
150 <span className="flex h-5 items-center">158 <StatusDot status={agent.status} />
151 <StatusDot status={agent.status} />159 </span>
152 </span>160 <span className="col-start-2 row-start-1 flex min-w-0 items-baseline gap-2">
153 <span className="min-w-0 flex-1">161 <span className="min-w-0 truncate text-sm font-medium">{agent.title}</span>
154 <span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">162 {role ? (
155 <span className="truncate text-sm font-medium">{agent.title}</span>163 <span className="max-w-28 shrink-0 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground">
156 {agent.role ? (164 {role}
157 <span className="max-w-28 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground">
158 {agent.role}
159 </span>
160 ) : null}
161 <span className="ml-auto flex items-center gap-1 font-mono text-[.7rem] text-muted-foreground/80">
162 <AgentElapsed agent={agent} />
163 {agent.status === "completed" ? (
164 <Check aria-hidden className="size-3 text-success" />
165 ) : null}
166 </span>
167 </span>165 </span>
168 {activity ? (166 ) : null}
169 <span167 </span>
170 className={cn(168 <span className="col-start-3 row-start-1 min-w-14 text-right font-mono text-[.7rem] text-muted-foreground/80">
171 "mt-0.5 block truncate text-xs",169 <span className="inline-flex items-center gap-1">
172 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",170 <AgentElapsed agent={agent} />
173 )}171 {agent.status === "completed" ? (
174 >172 <Check aria-hidden className="size-3 text-success" />
175 {activity}
176 </span>
177 ) : null}173 ) : null}
178 <span className="mt-0.5 flex items-center gap-1 font-mono text-[.7rem] text-muted-foreground/70">
179 {modelLabel ? <span className="truncate">{modelLabel}</span> : null}
180 {agent.usage ? (
181 <span className="tabular-nums">
182 {modelLabel ? "· " : ""}
183 {formatSubagentTokenCount(agent.usage.totalTokens)} tok
184 </span>
185 ) : null}
186 {agent.usage?.toolUses !== undefined ? (
187 <span>· {agent.usage.toolUses} tools</span>
188 ) : null}
189 {agent.activationCount > 1 ? <span>· run {agent.activationCount}</span> : null}
190 <span className="sr-only">{visuals.label}</span>
191 </span>
192 </span>174 </span>
193 </div>175 </span>
176 <span
177 className={cn(
178 "col-start-2 col-end-4 row-start-2 block truncate text-xs",
179 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",
180 )}
181 >
182 {activity ?? visuals.label}
183 </span>
184 <span className="col-start-2 col-end-4 row-start-3 truncate font-mono text-[.7rem] tabular-nums text-muted-foreground/70">
185 {metadata.join(" · ")}
186 </span>
187 <span className="sr-only">{visuals.label}</span>
194 </div>188 </div>
195 );189 );
196}190}
...@@ -314,18 +308,32 @@ function WorkflowScriptView({...@@ -314,18 +308,32 @@ function WorkflowScriptView({
314}308}
315309
316/**310/**
317 * Collapsible phase section (Claude Code Background-tasks pattern): live311 * Collapsible phase section. A phase opens when it becomes active, then keeps
318 * phases open by default, done phases collapsed to header + member dot row.312 * that shape as it settles so completion never yanks rows out from under the
319 * User toggles override the default and stick for the phase's lifetime.313 * user. Manual toggles stick until a later activation begins.
320 */314 */
321function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) {315function PhaseSection({
322 const [userOpen, setUserOpen] = useState<boolean | null>(null);316 phase,
323 const open = userOpen ?? phase.state === "running";317 defaultOpen = false,
318}: {
319 phase: AgentPanelWorkflowGroup["phases"][number];
320 defaultOpen?: boolean;
321}) {
322 const [open, setOpen] = useState(defaultOpen || phase.state === "running");
323 const previousState = useRef(phase.state);
324
325 useEffect(() => {
326 if (previousState.current !== "running" && phase.state === "running") {
327 setOpen(true);
328 }
329 previousState.current = phase.state;
330 }, [phase.state]);
331
324 return (332 return (
325 <div>333 <div>
326 <button334 <button
327 type="button"335 type="button"
328 onClick={() => setUserOpen(!open)}336 onClick={() => setOpen((value) => !value)}
329 aria-expanded={open}337 aria-expanded={open}
330 className={cn(338 className={cn(
331 "mt-2 flex w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[.65rem] font-medium uppercase tracking-wider hover:bg-accent/40",339 "mt-2 flex w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[.65rem] font-medium uppercase tracking-wider hover:bg-accent/40",
...@@ -363,15 +371,17 @@ function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][numb...@@ -363,15 +371,17 @@ function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][numb
363 );371 );
364}372}
365373
366/** Live workflow: phase rail + full phase tree. */374/** Expanded workflow: phase rail + full phase tree. */
367function LiveWorkflowSection({375function ExpandedWorkflowSection({
368 group,376 group,
369 environmentId,377 environmentId,
370 threadId,378 threadId,
379 onCollapse,
371}: {380}: {
372 group: AgentPanelWorkflowGroup;381 group: AgentPanelWorkflowGroup;
373 environmentId: EnvironmentId | null;382 environmentId: EnvironmentId | null;
374 threadId: ThreadId | null;383 threadId: ThreadId | null;
384 onCollapse: () => void;
375}) {385}) {
376 const [scriptOpen, setScriptOpen] = useState(false);386 const [scriptOpen, setScriptOpen] = useState(false);
377 const members = workflowMembers(group);387 const members = workflowMembers(group);
...@@ -387,8 +397,10 @@ function LiveWorkflowSection({...@@ -387,8 +397,10 @@ function LiveWorkflowSection({
387 return (397 return (
388 <section className="rounded-lg border border-border/50 bg-card/30 p-1.5">398 <section className="rounded-lg border border-border/50 bg-card/30 p-1.5">
389 <div className="flex items-center gap-2 px-1.5 pt-0.5 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">399 <div className="flex items-center gap-2 px-1.5 pt-0.5 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">
390 <span aria-hidden className="size-1.5 rounded-full bg-info" />400 <StatusDot status={group.workflow.status} />
391 <span>{group.workflow.workflowName ?? group.workflow.title}</span>401 <span className="min-w-0 truncate">
402 {group.workflow.workflowName ?? group.workflow.title}
403 </span>
392 {canShowScript ? (404 {canShowScript ? (
393 <button405 <button
394 type="button"406 type="button"
...@@ -405,6 +417,14 @@ function LiveWorkflowSection({...@@ -405,6 +417,14 @@ function LiveWorkflowSection({
405 <span className="ml-auto font-mono normal-case text-muted-foreground/80">417 <span className="ml-auto font-mono normal-case text-muted-foreground/80">
406 {settled}/{members.length} settled418 {settled}/{members.length} settled
407 </span>419 </span>
420 <button
421 type="button"
422 onClick={onCollapse}
423 aria-label="Collapse workflow"
424 className="text-muted-foreground hover:text-foreground"
425 >
426 <ChevronDown aria-hidden className="size-3" />
427 </button>
408 </div>428 </div>
409 <PhaseRail group={group} />429 <PhaseRail group={group} />
410 {scriptOpen && canShowScript ? (430 {scriptOpen && canShowScript ? (
...@@ -416,7 +436,7 @@ function LiveWorkflowSection({...@@ -416,7 +436,7 @@ function LiveWorkflowSection({
416 />436 />
417 ) : null}437 ) : null}
418 {group.phases.map((phase) => (438 {group.phases.map((phase) => (
419 <PhaseSection key={phase.index} phase={phase} />439 <PhaseSection key={phase.index} phase={phase} defaultOpen={!workflowIsLive(group)} />
420 ))}440 ))}
421 {group.unphasedMembers.map((member) => (441 {group.unphasedMembers.map((member) => (
422 <AgentRow key={member.id} agent={member} />442 <AgentRow key={member.id} agent={member} />
...@@ -429,11 +449,16 @@ function LiveWorkflowSection({...@@ -429,11 +449,16 @@ function LiveWorkflowSection({
429}449}
430450
431/**451/**
432 * Settled workflow: one summary line. Click toggles the member list — the452 * Collapsed workflow: one summary line. The parent owns expansion so a live
433 * only expansion in the panel, at run granularity.453 * workflow keeps its shape when it settles.
434 */454 */
435function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {455function CollapsedWorkflowSection({
436 const [open, setOpen] = useState(false);456 group,
457 onExpand,
458}: {
459 group: AgentPanelWorkflowGroup;
460 onExpand: () => void;
461}) {
437 const members = workflowMembers(group);462 const members = workflowMembers(group);
438 const failed = members.filter((member) => member.status === "failed").length;463 const failed = members.filter((member) => member.status === "failed").length;
439 // Coordinator usage may already aggregate members (panel-footer rule):464 // Coordinator usage may already aggregate members (panel-footer rule):
...@@ -450,9 +475,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {...@@ -450,9 +475,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
450 <section>475 <section>
451 <button476 <button
452 type="button"477 type="button"
453 onClick={() => setOpen((value) => !value)}478 onClick={onExpand}
454 className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-accent/40"479 className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-accent/40"
455 aria-expanded={open}480 aria-expanded={false}
456 >481 >
457 <StatusDot status={failed > 0 ? "failed" : group.workflow.status} />482 <StatusDot status={failed > 0 ? "failed" : group.workflow.status} />
458 <span className="truncate text-sm">483 <span className="truncate text-sm">
...@@ -463,24 +488,36 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {...@@ -463,24 +488,36 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
463 <span>{members.length} agents</span>488 <span>{members.length} agents</span>
464 <span className="tabular-nums">· {formatSubagentTokenCount(totalTokens)} tok</span>489 <span className="tabular-nums">· {formatSubagentTokenCount(totalTokens)} tok</span>
465 {elapsed ? <span className="tabular-nums">· {elapsed}</span> : null}490 {elapsed ? <span className="tabular-nums">· {elapsed}</span> : null}
466 {open ? (491 <ChevronRight aria-hidden className="size-3" />
467 <ChevronDown aria-hidden className="size-3" />
468 ) : (
469 <ChevronRight aria-hidden className="size-3" />
470 )}
471 </span>492 </span>
472 </button>493 </button>
473 {open ? (
474 <div className="ms-3 border-s border-border/45 ps-2">
475 {members.map((member) => (
476 <AgentRow key={member.id} agent={member} />
477 ))}
478 </div>
479 ) : null}
480 </section>494 </section>
481 );495 );
482}496}
483497
498/** A workflow's open state is presentation state, not a status derivative. */
499function WorkflowSection({
500 group,
501 environmentId,
502 threadId,
503}: {
504 group: AgentPanelWorkflowGroup;
505 environmentId: EnvironmentId | null;
506 threadId: ThreadId | null;
507}) {
508 const [open, setOpen] = useState(() => workflowIsLive(group));
509 return open ? (
510 <ExpandedWorkflowSection
511 group={group}
512 environmentId={environmentId}
513 threadId={threadId}
514 onCollapse={() => setOpen(false)}
515 />
516 ) : (
517 <CollapsedWorkflowSection group={group} onExpand={() => setOpen(true)} />
518 );
519}
520
484export function AgentsPanel({521export function AgentsPanel({
485 model,522 model,
486 environmentId = null,523 environmentId = null,
...@@ -503,48 +540,24 @@ export function AgentsPanel({...@@ -503,48 +540,24 @@ export function AgentsPanel({
503 );540 );
504 }541 }
505542
506 const liveWorkflows = model.workflows.filter(workflowIsLive);
507 const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group));
508 const liveDirect = model.directAgents.filter(
509 (agent) =>
510 agent.status === "running" || agent.status === "pending" || agent.status === "waiting",
511 );
512 const settledDirect = model.directAgents.filter(
513 (agent) =>
514 agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting",
515 );
516
517 return (543 return (
518 <div className="flex h-full min-h-0 flex-col">544 <div className="flex h-full min-h-0 flex-col">
519 <ScrollArea className="min-h-0 flex-1">545 <ScrollArea className="min-h-0 flex-1">
520 <div className="flex flex-col gap-2 p-2">546 <div className="flex flex-col gap-2 p-2">
521 {liveWorkflows.map((group) => (547 {model.workflows.map((group) => (
522 <LiveWorkflowSection548 <WorkflowSection
523 key={group.workflow.id}549 key={group.workflow.id}
524 group={group}550 group={group}
525 environmentId={environmentId}551 environmentId={environmentId}
526 threadId={threadId}552 threadId={threadId}
527 />553 />
528 ))}554 ))}
529 {liveDirect.length > 0 ? (555 {model.directAgents.length > 0 ? (
530 <section>556 <section>
531 <div className="px-1.5 pt-1 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">557 <div className="px-1.5 pt-1 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">
532 Direct spawns558 Direct spawns
533 </div>559 </div>
534 {liveDirect.map((agent) => (560 {model.directAgents.map((agent) => (
535 <AgentRow key={agent.id} agent={agent} />
536 ))}
537 </section>
538 ) : null}
539 {settledWorkflows.length > 0 || settledDirect.length > 0 ? (
540 <section>
541 <div className="px-1.5 pt-2 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground/70">
542 Earlier
543 </div>
544 {settledWorkflows.map((group) => (
545 <SettledWorkflowSection key={group.workflow.id} group={group} />
546 ))}
547 {settledDirect.map((agent) => (
548 <AgentRow key={agent.id} agent={agent} />561 <AgentRow key={agent.id} agent={agent} />
549 ))}562 ))}
550 </section>563 </section>
packages/client-runtime/src/state/subagentRuntime.test.ts+71
...@@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => {...@@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => {
186 expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 });186 expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 });
187 });187 });
188188
189 it("usage snapshots enrich an existing agent without changing its status", () => {
190 const [agent] = fold([
191 activity("task.started", { taskId: "usage-waiting", taskType: "local_agent" }),
192 activity("task.progress", { taskId: "usage-waiting", status: "waiting" }),
193 activity("task.progress", {
194 taskId: "usage-waiting",
195 usageSnapshot: true,
196 typedUsage: { totalTokens: 1_200 },
197 }),
198 ]);
199
200 expect(agent?.status).toBe("waiting");
201 expect(agent?.usage?.totalTokens).toBe(1_200);
202 });
203
204 it("a retained usage snapshot can still reconstruct a running agent", () => {
205 const [agent] = fold([
206 activity("task.progress", {
207 taskId: "usage-only",
208 usageSnapshot: true,
209 typedUsage: { totalTokens: 800 },
210 }),
211 ]);
212
213 expect(agent?.status).toBe("running");
214 expect(agent?.usage?.totalTokens).toBe(800);
215 });
216
189 it("partial terminal usage preserves known breakdown fields", () => {217 it("partial terminal usage preserves known breakdown fields", () => {
190 const agents = fold([218 const agents = fold([
191 activity("task.started", { taskId: "task-6", taskType: "local_agent" }),219 activity("task.started", { taskId: "task-6", taskType: "local_agent" }),
...@@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => {...@@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => {
362 );390 );
363 });391 });
364392
393 it("keeps direct spawns in first-seen order as their activity changes", () => {
394 const directRoster = fold([
395 activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"),
396 activity("task.started", { taskId: "direct-b", title: "Second" }, "2026-08-01T11:00:01.000Z"),
397 activity(
398 "task.progress",
399 { taskId: "direct-a", summary: "Newest activity" },
400 "2026-08-01T11:00:02.000Z",
401 ),
402 ]);
403
404 expect(
405 deriveAgentPanelModel({ agents: directRoster }).directAgents.map((agent) => agent.id),
406 ).toEqual(["direct-a", "direct-b"]);
407 });
408
409 it("keeps first-seen order after the roster retention ranking runs", () => {
410 const starts = Array.from({ length: 101 }, (_, index) =>
411 activity(
412 "task.started",
413 { taskId: `capped-${index}`, title: `Agent ${index}` },
414 `2026-08-01T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(
415 index % 60,
416 ).padStart(2, "0")}.000Z`,
417 ),
418 );
419 const cappedRoster = fold([
420 ...starts,
421 activity(
422 "task.progress",
423 { taskId: "capped-0", summary: "Newest activity" },
424 "2026-08-01T12:02:00.000Z",
425 ),
426 ]);
427
428 const ids = deriveAgentPanelModel({ agents: cappedRoster }).directAgents.map(
429 (agent) => agent.id,
430 );
431 expect(ids).toHaveLength(100);
432 expect(ids.slice(0, 3)).toEqual(["capped-0", "capped-2", "capped-3"]);
433 expect(ids.at(-1)).toBe("capped-100");
434 });
435
365 it("a phase with only pending members never reads as running", () => {436 it("a phase with only pending members never reads as running", () => {
366 const pendingRoster = fold([437 const pendingRoster = fold([
367 activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }),438 activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }),
packages/client-runtime/src/state/subagentRuntime.ts+20-4
...@@ -80,6 +80,8 @@ export interface RuntimeSubagent {...@@ -80,6 +80,8 @@ export interface RuntimeSubagent {
80 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;80 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;
81 readonly runHandles: SubagentRunHandles | null;81 readonly runHandles: SubagentRunHandles | null;
82 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;82 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;
83 /** First retained observation, used as the roster's stable display order. */
84 readonly firstSeenAt: string;
83 readonly startedAt: string | null;85 readonly startedAt: string | null;
84 readonly completedAt: string | null;86 readonly completedAt: string | null;
85 readonly updatedAt: string;87 readonly updatedAt: string;
...@@ -247,6 +249,7 @@ interface MutableAgent {...@@ -247,6 +249,7 @@ interface MutableAgent {
247 phases: ReadonlyArray<SubagentWorkflowPhase>;249 phases: ReadonlyArray<SubagentWorkflowPhase>;
248 runHandles: SubagentRunHandles | null;250 runHandles: SubagentRunHandles | null;
249 recentActivity: ReadonlyArray<SubagentActivityEntry>;251 recentActivity: ReadonlyArray<SubagentActivityEntry>;
252 firstSeenAt: string;
250 startedAt: string | null;253 startedAt: string | null;
251 completedAt: string | null;254 completedAt: string | null;
252 updatedAt: string;255 updatedAt: string;
...@@ -300,6 +303,7 @@ function getOrCreate(...@@ -300,6 +303,7 @@ function getOrCreate(
300 phases: [],303 phases: [],
301 runHandles: null,304 runHandles: null,
302 recentActivity: [],305 recentActivity: [],
306 firstSeenAt: at,
303 startedAt: null,307 startedAt: null,
304 completedAt: null,308 completedAt: null,
305 updatedAt: at,309 updatedAt: at,
...@@ -500,14 +504,19 @@ export function foldSubagentActivities(...@@ -500,14 +504,19 @@ export function foldSubagentActivities(
500 // Membership is sticky per taskId: rows after the first (terminal504 // Membership is sticky per taskId: rows after the first (terminal
501 // rows often carry only taskId+status, no marker fields) inherit the505 // rows often carry only taskId+status, no marker fields) inherit the
502 // first row's classification instead of being re-judged.506 // first row's classification instead of being re-judged.
503 if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break;507 const existed = agents.has(taskId);
508 if (!existed && isBackgroundTaskActivity(payload)) break;
504 const agent = getOrCreate(agents, taskId, payload, at);509 const agent = getOrCreate(agents, taskId, payload, at);
505 fillMetadata(agent, payload);510 fillMetadata(agent, payload);
506 if (agent.activationCount === 0) agent.activationCount = 1;511 if (agent.activationCount === 0) agent.activationCount = 1;
507 const explicitStatus = asRuntimeStatus(payload.status);512 const explicitStatus = asRuntimeStatus(payload.status);
508 if (explicitStatus) {513 if (explicitStatus) {
509 applyStatus(agent, explicitStatus, at);514 applyStatus(agent, explicitStatus, at);
510 } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") {515 } else if (
516 (payload.usageSnapshot !== true || !existed) &&
517 !isTerminalSubagentStatus(agent.status) &&
518 agent.status !== "idle"
519 ) {
511 applyStatus(agent, "running", at);520 applyStatus(agent, "running", at);
512 }521 }
513 const summary = asString(payload.summary);522 const summary = asString(payload.summary);
...@@ -726,7 +735,10 @@ export function deriveAgentPanelModel({...@@ -726,7 +735,10 @@ export function deriveAgentPanelModel({
726 return EMPTY_PANEL_MODEL;735 return EMPTY_PANEL_MODEL;
727 }736 }
728737
729 const workflows = source.filter((agent) => agent.kind === "workflow");738 const workflows = source
739 .filter((agent) => agent.kind === "workflow")
740 .slice()
741 .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id));
730 const workflowIds = new Set(workflows.map((workflow) => workflow.id));742 const workflowIds = new Set(workflows.map((workflow) => workflow.id));
731 const members = new Map<string, RuntimeSubagent[]>();743 const members = new Map<string, RuntimeSubagent[]>();
732 const direct: RuntimeSubagent[] = [];744 const direct: RuntimeSubagent[] = [];
...@@ -827,7 +839,11 @@ export function deriveAgentPanelModel({...@@ -827,7 +839,11 @@ export function deriveAgentPanelModel({
827839
828 return {840 return {
829 workflows: workflowGroups,841 workflows: workflowGroups,
830 directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),842 // Updates and the >100-agent retention ranking must never reshuffle rows
843 // that remain visible.
844 directAgents: direct
845 .slice()
846 .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)),
831 runningCount,847 runningCount,
832 waitingCount,848 waitingCount,
833 idleCount,849 idleCount,