From 089f6befe86ee2478da218f51caa1df8aed6d7a5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 7 May 2026 16:11:09 -0700 Subject: [PATCH] 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) --- apps/fabro-web/app/routes/run-stages.test.ts | 59 +++++++++++++++++++- apps/fabro-web/app/routes/run-stages.tsx | 41 ++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 742910b94..e8639b0ee 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -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 { 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, { diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 5d7b7e578..d8991a604 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -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 )} + {model && ( + + + )} ); } @@ -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(() => { 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} />