feat(web): show LLM model name in stage view toolbar

Extracts the model from `stage.prompt`, `agent.session.activated`, and
`agent.cli.started` events that the stage already loads, and renders it
on the right side of the events toolbar with a CpuChipIcon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 16:11:09 -07:00
parent 9e54643a7e
commit 089f6befe8
No known key found for this signature in database
2 changed files with 99 additions and 1 deletions

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { eventsToActivity } from "./run-stages";
import { eventsToActivity, extractStageModel } from "./run-stages";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
@ -163,6 +163,63 @@ describe("eventsToActivity", () => {
}
});
test("extractStageModel pulls model from agent.session.activated, ignoring other stages", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.session.activated",
stage_id: "simplify@1",
node_id: "simplify",
properties: { provider: "anthropic", model: "claude-sonnet-4-5" },
}),
envelope(2, {
event: "agent.session.activated",
stage_id: "verify@1",
node_id: "verify",
properties: { provider: "openai", model: "gpt-5" },
}),
];
expect(extractStageModel(events, "simplify@1")).toBe("claude-sonnet-4-5");
expect(extractStageModel(events, "verify@1")).toBe("gpt-5");
expect(extractStageModel(events, "fmt@1")).toBe(null);
});
test("extractStageModel uses latest stage event with a model", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
stage_id: "agent@1",
node_id: "agent",
properties: { model: "claude-opus-4-5" },
}),
envelope(2, {
event: "agent.cli.started",
stage_id: "agent@1",
node_id: "agent",
properties: {
provider: "anthropic",
model: "claude-sonnet-4-6",
command: "claude",
},
}),
];
expect(extractStageModel(events, "agent@1")).toBe("claude-sonnet-4-6");
});
test("extractStageModel ignores model from unrelated event types", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.message",
stage_id: "agent@1",
node_id: "agent",
properties: { text: "hi", model: "should-be-ignored" },
}),
];
expect(extractStageModel(events, "agent@1")).toBe(null);
});
test("ignores unknown event types and events for other stages", () => {
const events: EventEnvelope[] = [
envelope(1, {

View file

@ -10,6 +10,7 @@ import { XMarkIcon } from "@heroicons/react/24/outline";
import {
CheckIcon,
ChevronUpDownIcon,
CpuChipIcon,
FunnelIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/16/solid";
@ -195,6 +196,26 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn
return turns;
}
const STAGE_MODEL_EVENT_NAMES = new Set([
"stage.prompt",
"agent.session.activated",
"agent.cli.started",
]);
export function extractStageModel(
events: EventEnvelope[],
stageId: string,
): string | null {
let model: string | null = null;
for (const e of events) {
if (activityEventStageId(e) !== stageId) continue;
if (!e.event || !STAGE_MODEL_EVENT_NAMES.has(e.event)) continue;
const candidate = getString(e.properties ?? {}, "model");
if (candidate) model = candidate;
}
return model;
}
function turnLabel(turn: TurnType): string {
switch (turn.kind) {
case "system":
@ -816,6 +837,7 @@ function EventsToolbar({
onSearchChange,
filteredCount,
totalCount,
model,
}: {
tab: EventsTab;
onTabChange: (tab: EventsTab) => void;
@ -828,6 +850,7 @@ function EventsToolbar({
onSearchChange: (value: string) => void;
filteredCount: number;
totalCount: number;
model: string | null;
}) {
const transcriptAllSelected = selectedKinds.length === EVENT_KINDS.length;
const debugAllSelected =
@ -879,6 +902,15 @@ function EventsToolbar({
{filteredCount.toLocaleString()} of {totalCount.toLocaleString()} events
</span>
)}
{model && (
<span
className="inline-flex items-center gap-1.5 text-xs text-fg-muted"
title="LLM model used for this stage"
>
<CpuChipIcon className="size-3.5" aria-hidden="true" />
<span className="font-mono">{model}</span>
</span>
)}
</div>
);
}
@ -949,6 +981,14 @@ export default function RunStages() {
}
return Array.from(set).sort();
}, [debugEvents]);
const stageModel = useMemo(
() =>
selectedStageId
? extractStageModel(stageEventsQuery.data ?? [], selectedStageId)
: null,
[stageEventsQuery.data, selectedStageId],
);
const filteredDebugEvents = useMemo<EventEnvelope[]>(() => {
const useCategoryFilter = selectedDebugCategories.length > 0;
const cats = new Set(selectedDebugCategories);
@ -1008,6 +1048,7 @@ export default function RunStages() {
onSearchChange={setSearch}
filteredCount={tab === "transcript" ? filteredTurns.length : filteredDebugEvents.length}
totalCount={tab === "transcript" ? turns.length : debugEvents.length}
model={stageModel}
/>
</div>
</div>