| author | |
| committer | |
| log | cf5c9948c895e120965165b7b2b643ca275c5315 |
| tree | 1c18910466ad84ab87158e3ed714370feb94cc42 |
| parent | 2288d416aa3bcb5f2eeb4004228872593296971e |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 369 insertions(+), 144 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts created+84| ... | @@ -0,0 +1,84 @@ | ||
| 1 | import { | ||
| 2 | EventId, | ||
| 3 | ProviderDriverKind, | ||
| 4 | RuntimeTaskId, | ||
| 5 | ThreadId, | ||
| 6 | type ProviderRuntimeEvent, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts"; | ||
| 11 | |||
| 12 | const base = { | ||
| 13 | provider: ProviderDriverKind.make("codex"), | ||
| 14 | createdAt: "2026-08-06T00:00:00.000Z", | ||
| 15 | threadId: ThreadId.make("thread-1"), | ||
| 16 | }; | ||
| 17 | |||
| 18 | describe("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 | } |
| 564 | 564 | ||
| 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, so | 587 | ? [ |
| 569 | // each tick REPLACES the last via the activity upsert (PK + the | 588 | { |
| 570 | // replace-by-id apply in projector and client reducer). Keeps one | 589 | // Stable per-task id: activity is "latest state", not |
| 571 | // progress row per task instead of thousands, so a large fleet's | 590 | // history, so each meaningful tick replaces the last. This |
| 572 | // ticks can no longer evict its own start/terminal rows out of | 591 | // bounds a large fleet to one activity row per task. |
| 573 | // the 500-row retention window. Thread-scoped: activity_id is a | 592 | id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), |
| 574 | // GLOBAL primary key and Claude task ids are session-local, so a | 593 | createdAt: event.createdAt, |
| 575 | // bare taskId could collide across threads and steal another | 594 | 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 > 0 | 601 | 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 > 0 | 606 | : {}), |
| 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 | } |
| 601 | 642 |
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. The | 8 | * - Agent rows reserve three fixed lines for identity, activity, and metrics; |
| 9 | * row answers "who / what phase / how much"; anything deeper is a future | 9 | * 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 to | 11 | * 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 | */ |
| 16 | import { useAtomValue } from "@effect/atom-react"; | 14 | import { 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); | ||
| 146 | 154 | ||
| 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 | <span | 167 | </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 | } |
| 315 | 309 | ||
| 316 | /** | 310 | /** |
| 317 | * Collapsible phase section (Claude Code Background-tasks pattern): live | 311 | * 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 | */ |
| 321 | function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { | 315 | function 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 | <button | 334 | <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 | } |
| 365 | 373 | ||
| 366 | /** Live workflow: phase rail + full phase tree. */ | 374 | /** Expanded workflow: phase rail + full phase tree. */ |
| 367 | function LiveWorkflowSection({ | 375 | function 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 | <button | 405 | <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} settled | 418 | {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 | } |
| 430 | 450 | ||
| 431 | /** | 451 | /** |
| 432 | * Settled workflow: one summary line. Click toggles the member list — the | 452 | * 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 | */ |
| 435 | function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { | 455 | function 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 | <button | 476 | <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 | } |
| 483 | 497 | ||
| 498 | /** A workflow's open state is presentation state, not a status derivative. */ | ||
| 499 | function 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 | |||
| 484 | export function AgentsPanel({ | 521 | export 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 | } |
| 505 | 542 | ||
| 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 | <LiveWorkflowSection | 548 | <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 spawns | 558 | 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 | }); |
| 188 | 188 | ||
| 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 | }); |
| 364 | 392 | ||
| 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 (terminal | 504 | // Membership is sticky per taskId: rows after the first (terminal |
| 501 | // rows often carry only taskId+status, no marker fields) inherit the | 505 | // 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 | } |
| 728 | 737 | ||
| 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({ |
| 827 | 839 | ||
| 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, |