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(
563563 }
564564
565565 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;
566585 return [
567 {
568 // Stable per-task id: progress is "latest state", not history, so
569 // each tick REPLACES the last via the activity upsert (PK + the
570 // replace-by-id apply in projector and client reducer). Keeps one
571 // progress row per task instead of thousands, so a large fleet's
572 // ticks can no longer evict its own start/terminal rows out of
573 // the 500-row retention window. Thread-scoped: activity_id is a
574 // GLOBAL primary key and Claude task ids are session-local, so a
575 // bare taskId could collide across threads and steal another
576 // thread's row (review finding).
577 id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
578 createdAt: event.createdAt,
579 tone: "info",
580 kind: "task.progress",
581 summary:
582 event.payload.description.trim().length > 0
583 ? truncateDetail(event.payload.description, 120)
584 : "Reasoning update",
585 payload: {
586 taskId: event.payload.taskId,
587 ...(event.payload.description.trim().length > 0
588 ? { title: truncateDetail(event.payload.description, 120) }
589 : {}),
590 detail: truncateDetail(event.payload.summary ?? event.payload.description),
591 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
592 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
593 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
594 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
595 },
596 turnId: toTurnId(event.turnId) ?? null,
597 ...maybeSequence,
598 },
586 ...(hasProgressState
587 ? [
588 {
589 // Stable per-task id: activity is "latest state", not
590 // history, so each meaningful tick replaces the last. This
591 // bounds a large fleet to one activity row per task.
592 id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
593 createdAt: event.createdAt,
594 tone: "info" as const,
595 kind: "task.progress" as const,
596 summary:
597 event.payload.description.trim().length > 0
598 ? truncateDetail(event.payload.description, 120)
599 : "Reasoning update",
600 payload: {
601 taskId: event.payload.taskId,
602 ...title,
603 detail: truncateDetail(event.payload.summary ?? event.payload.description),
604 ...(event.payload.summary
605 ? { summary: truncateDetail(event.payload.summary) }
606 : {}),
607 ...(event.payload.lastToolName
608 ? { lastToolName: event.payload.lastToolName }
609 : {}),
610 ...(event.payload.status ? { status: event.payload.status } : {}),
611 ...(event.payload.error ? { error: event.payload.error } : {}),
612 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
613 ...identityLinkage,
614 },
615 turnId: toTurnId(event.turnId) ?? null,
616 ...maybeSequence,
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 : []),
599640 ];
600641 }
601642
apps/web/src/components/AgentsPanel.tsx+121-108
......@@ -4,13 +4,11 @@
44 * spawn batch).
55 *
66 * Visualization rules (from live-test feedback):
7 * - Live work first: running workflows and direct spawns sort above settled.
8 * - Rows are flat status lines — no expansion, no per-agent tool feeds. The
9 * row answers "who / what phase / how much"; anything deeper is a future
10 * drill-in, not an unfold.
11 * - A settled workflow run collapses to a single summary line; click it to
12 * show its member list inline (the one allowed toggle — run granularity,
13 * not agent granularity).
7 * - Spawn order is stable. Activity and completion update rows in place.
8 * - Agent rows reserve three fixed lines for identity, activity, and metrics;
9 * changing data must never change their height.
10 * - Workflow expansion is presentation state. A live run stays expanded when
11 * it settles; older collapsed runs can still be opened at run granularity.
1412 * - Static status dots, DOM-write elapsed timers, plain token counters.
1513 */
1614import { useAtomValue } from "@effect/atom-react";
......@@ -143,54 +141,50 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {
143141 const visuals = STATUS_VISUALS[agent.status];
144142 const activity = agentActivityText(agent);
145143 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
147155 return (
148 <div className="rounded-md px-1.5 py-1">
149 <div className="flex items-start gap-2">
150 <span className="flex h-5 items-center">
151 <StatusDot status={agent.status} />
152 </span>
153 <span className="min-w-0 flex-1">
154 <span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
155 <span className="truncate text-sm font-medium">{agent.title}</span>
156 {agent.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>
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">
157 <span className="col-start-1 row-start-1 flex items-center">
158 <StatusDot status={agent.status} />
159 </span>
160 <span className="col-start-2 row-start-1 flex min-w-0 items-baseline gap-2">
161 <span className="min-w-0 truncate text-sm font-medium">{agent.title}</span>
162 {role ? (
163 <span className="max-w-28 shrink-0 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground">
164 {role}
167165 </span>
168 {activity ? (
169 <span
170 className={cn(
171 "mt-0.5 block truncate text-xs",
172 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",
173 )}
174 >
175 {activity}
176 </span>
166 ) : null}
167 </span>
168 <span className="col-start-3 row-start-1 min-w-14 text-right font-mono text-[.7rem] text-muted-foreground/80">
169 <span className="inline-flex items-center gap-1">
170 <AgentElapsed agent={agent} />
171 {agent.status === "completed" ? (
172 <Check aria-hidden className="size-3 text-success" />
177173 ) : 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>
192174 </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>
194188 </div>
195189 );
196190}
......@@ -314,18 +308,32 @@ function WorkflowScriptView({
314308}
315309
316310/**
317 * Collapsible phase section (Claude Code Background-tasks pattern): live
318 * phases open by default, done phases collapsed to header + member dot row.
319 * User toggles override the default and stick for the phase's lifetime.
311 * Collapsible phase section. A phase opens when it becomes active, then keeps
312 * that shape as it settles so completion never yanks rows out from under the
313 * user. Manual toggles stick until a later activation begins.
320314 */
321function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) {
322 const [userOpen, setUserOpen] = useState<boolean | null>(null);
323 const open = userOpen ?? phase.state === "running";
315function PhaseSection({
316 phase,
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
324332 return (
325333 <div>
326334 <button
327335 type="button"
328 onClick={() => setUserOpen(!open)}
336 onClick={() => setOpen((value) => !value)}
329337 aria-expanded={open}
330338 className={cn(
331339 "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
363371 );
364372}
365373
366/** Live workflow: phase rail + full phase tree. */
367function LiveWorkflowSection({
374/** Expanded workflow: phase rail + full phase tree. */
375function ExpandedWorkflowSection({
368376 group,
369377 environmentId,
370378 threadId,
379 onCollapse,
371380}: {
372381 group: AgentPanelWorkflowGroup;
373382 environmentId: EnvironmentId | null;
374383 threadId: ThreadId | null;
384 onCollapse: () => void;
375385}) {
376386 const [scriptOpen, setScriptOpen] = useState(false);
377387 const members = workflowMembers(group);
......@@ -387,8 +397,10 @@ function LiveWorkflowSection({
387397 return (
388398 <section className="rounded-lg border border-border/50 bg-card/30 p-1.5">
389399 <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" />
391 <span>{group.workflow.workflowName ?? group.workflow.title}</span>
400 <StatusDot status={group.workflow.status} />
401 <span className="min-w-0 truncate">
402 {group.workflow.workflowName ?? group.workflow.title}
403 </span>
392404 {canShowScript ? (
393405 <button
394406 type="button"
......@@ -405,6 +417,14 @@ function LiveWorkflowSection({
405417 <span className="ml-auto font-mono normal-case text-muted-foreground/80">
406418 {settled}/{members.length} settled
407419 </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>
408428 </div>
409429 <PhaseRail group={group} />
410430 {scriptOpen && canShowScript ? (
......@@ -416,7 +436,7 @@ function LiveWorkflowSection({
416436 />
417437 ) : null}
418438 {group.phases.map((phase) => (
419 <PhaseSection key={phase.index} phase={phase} />
439 <PhaseSection key={phase.index} phase={phase} defaultOpen={!workflowIsLive(group)} />
420440 ))}
421441 {group.unphasedMembers.map((member) => (
422442 <AgentRow key={member.id} agent={member} />
......@@ -429,11 +449,16 @@ function LiveWorkflowSection({
429449}
430450
431451/**
432 * Settled workflow: one summary line. Click toggles the member list — the
433 * only expansion in the panel, at run granularity.
452 * Collapsed workflow: one summary line. The parent owns expansion so a live
453 * workflow keeps its shape when it settles.
434454 */
435function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
436 const [open, setOpen] = useState(false);
455function CollapsedWorkflowSection({
456 group,
457 onExpand,
458}: {
459 group: AgentPanelWorkflowGroup;
460 onExpand: () => void;
461}) {
437462 const members = workflowMembers(group);
438463 const failed = members.filter((member) => member.status === "failed").length;
439464 // Coordinator usage may already aggregate members (panel-footer rule):
......@@ -450,9 +475,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
450475 <section>
451476 <button
452477 type="button"
453 onClick={() => setOpen((value) => !value)}
478 onClick={onExpand}
454479 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}
456481 >
457482 <StatusDot status={failed > 0 ? "failed" : group.workflow.status} />
458483 <span className="truncate text-sm">
......@@ -463,24 +488,36 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
463488 <span>{members.length} agents</span>
464489 <span className="tabular-nums">· {formatSubagentTokenCount(totalTokens)} tok</span>
465490 {elapsed ? <span className="tabular-nums">· {elapsed}</span> : null}
466 {open ? (
467 <ChevronDown aria-hidden className="size-3" />
468 ) : (
469 <ChevronRight aria-hidden className="size-3" />
470 )}
491 <ChevronRight aria-hidden className="size-3" />
471492 </span>
472493 </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}
480494 </section>
481495 );
482496}
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
484521export function AgentsPanel({
485522 model,
486523 environmentId = null,
......@@ -503,48 +540,24 @@ export function AgentsPanel({
503540 );
504541 }
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
517543 return (
518544 <div className="flex h-full min-h-0 flex-col">
519545 <ScrollArea className="min-h-0 flex-1">
520546 <div className="flex flex-col gap-2 p-2">
521 {liveWorkflows.map((group) => (
522 <LiveWorkflowSection
547 {model.workflows.map((group) => (
548 <WorkflowSection
523549 key={group.workflow.id}
524550 group={group}
525551 environmentId={environmentId}
526552 threadId={threadId}
527553 />
528554 ))}
529 {liveDirect.length > 0 ? (
555 {model.directAgents.length > 0 ? (
530556 <section>
531557 <div className="px-1.5 pt-1 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">
532558 Direct spawns
533559 </div>
534 {liveDirect.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) => (
560 {model.directAgents.map((agent) => (
548561 <AgentRow key={agent.id} agent={agent} />
549562 ))}
550563 </section>
packages/client-runtime/src/state/subagentRuntime.test.ts+71
......@@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => {
186186 expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 });
187187 });
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
189217 it("partial terminal usage preserves known breakdown fields", () => {
190218 const agents = fold([
191219 activity("task.started", { taskId: "task-6", taskType: "local_agent" }),
......@@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => {
362390 );
363391 });
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
365436 it("a phase with only pending members never reads as running", () => {
366437 const pendingRoster = fold([
367438 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 {
8080 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;
8181 readonly runHandles: SubagentRunHandles | null;
8282 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;
83 /** First retained observation, used as the roster's stable display order. */
84 readonly firstSeenAt: string;
8385 readonly startedAt: string | null;
8486 readonly completedAt: string | null;
8587 readonly updatedAt: string;
......@@ -247,6 +249,7 @@ interface MutableAgent {
247249 phases: ReadonlyArray<SubagentWorkflowPhase>;
248250 runHandles: SubagentRunHandles | null;
249251 recentActivity: ReadonlyArray<SubagentActivityEntry>;
252 firstSeenAt: string;
250253 startedAt: string | null;
251254 completedAt: string | null;
252255 updatedAt: string;
......@@ -300,6 +303,7 @@ function getOrCreate(
300303 phases: [],
301304 runHandles: null,
302305 recentActivity: [],
306 firstSeenAt: at,
303307 startedAt: null,
304308 completedAt: null,
305309 updatedAt: at,
......@@ -500,14 +504,19 @@ export function foldSubagentActivities(
500504 // Membership is sticky per taskId: rows after the first (terminal
501505 // rows often carry only taskId+status, no marker fields) inherit the
502506 // 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;
504509 const agent = getOrCreate(agents, taskId, payload, at);
505510 fillMetadata(agent, payload);
506511 if (agent.activationCount === 0) agent.activationCount = 1;
507512 const explicitStatus = asRuntimeStatus(payload.status);
508513 if (explicitStatus) {
509514 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 ) {
511520 applyStatus(agent, "running", at);
512521 }
513522 const summary = asString(payload.summary);
......@@ -726,7 +735,10 @@ export function deriveAgentPanelModel({
726735 return EMPTY_PANEL_MODEL;
727736 }
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));
730742 const workflowIds = new Set(workflows.map((workflow) => workflow.id));
731743 const members = new Map<string, RuntimeSubagent[]>();
732744 const direct: RuntimeSubagent[] = [];
......@@ -827,7 +839,11 @@ export function deriveAgentPanelModel({
827839
828840 return {
829841 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)),
831847 runningCount,
832848 waitingCount,
833849 idleCount,