From 178adf15eaacc3dc52419a52354afd9a6dbbc026 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Thu, 21 May 2026 20:19:55 -0400 Subject: [PATCH] feat(web): add Context tab to the stage detail view (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a **Context** tab to the stage detail view (`/runs/:id/stages/:stageId`), beside the existing primary tab (Thread / Logs / …) and Debug tab. It surfaces a stage's *deliberate per-visit outputs* — the data the workflow author makes a stage write into shared context, plus the routing hints it emitted: - **Routing** — `preferred_label` and `suggested_next_ids` - **Context writes** — author-set `context_updates` keys This data flow was previously invisible in the UI, which made it hard to debug "why did the next stage get the wrong input / take the wrong edge". ## Why no backend change The per-visit `stage.completed` event already carries `context_updates`, `preferred_label`, and `suggested_next_ids`, and the web UI already fetches it via `useRunStageEvents`. The checkpoint's `node_outcomes` map was rejected as a source: it is keyed by `node_id` only, so it is lossy across visits (`implement@2` would overwrite `implement@1`). ## How - `extractStageContext` (in `stage-renderers/helpers.ts`) reads the `stage.completed` event and filters `context_updates` through an engine-key denylist: `last_stage`, `last_response`, `response.*`, `internal.*`, `current.*`, `command.output`, `human.gate.*`, `parallel.*`. Those are bookkeeping or already shown in the stage's primary tab. - It returns `null` when nothing is left, so the tab stays **conditional** — same pattern as Thread/Logs. It only appears when a stage actually wrote something deliberate. - New `stage-context.tsx` renders the result, reusing `CodeBlock` / `JsonBlock`. - `run-stages.tsx` gains a dynamic `availableTabs` list; `effectiveTab` falls back to `primary` gracefully when the Context tab is absent. ## Verification - `bun run typecheck` clean, `bun test` — 412 pass / 0 fail (4 new tests for the denylist + routing extraction). - Live run `01KS5WBZAE7K8321NHR7KFAHF9` (`context-demo` workflow): the `emit@1` stage emitted `demo.greeting` / `demo.answer` / `demo.payload` plus `preferred_label: "Done"` and `suggested_next_ids: ["exit"]`, and the Context tab rendered them correctly. ## Notes - The second commit adds a small `context-demo` workflow used for that verification — kept separate so it can be dropped independently. - Known limitations (acceptable for v1): data is read from `stage.completed` only, so a stage ending in `stage.failed` shows no tab; parallel stages write `parallel.*` directly to context (denylisted), so they show no tab. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .fabro/workflows/context-demo/workflow.fabro | 16 +++++ .fabro/workflows/context-demo/workflow.toml | 4 ++ .../app/components/stage-context.tsx | 68 +++++++++++++++++++ .../stage-renderers/helpers.test.ts | 63 +++++++++++++++++ .../app/components/stage-renderers/helpers.ts | 57 ++++++++++++++++ apps/fabro-web/app/routes/run-stages.tsx | 38 +++++++++-- 6 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 .fabro/workflows/context-demo/workflow.fabro create mode 100644 .fabro/workflows/context-demo/workflow.toml create mode 100644 apps/fabro-web/app/components/stage-context.tsx diff --git a/.fabro/workflows/context-demo/workflow.fabro b/.fabro/workflows/context-demo/workflow.fabro new file mode 100644 index 000000000..017795744 --- /dev/null +++ b/.fabro/workflows/context-demo/workflow.fabro @@ -0,0 +1,16 @@ +digraph ContextDemo { + graph [goal="Emit structured outputs to demonstrate the stage Context tab"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + emit [ + shape=tab, + label="Emit Context", + prompt="Reply with ONLY the following JSON object and nothing else — no prose, no markdown, no code fences. The exact text of your entire reply must be: {\"context_updates\": {\"demo.greeting\": \"Hello from the Context tab\", \"demo.answer\": 42, \"demo.payload\": {\"nested\": true, \"items\": [1, 2, 3]}}, \"preferred_next_label\": \"Done\", \"suggested_next_ids\": [\"exit\"]}" + ] + + start -> emit + emit -> exit [label="Done"] +} diff --git a/.fabro/workflows/context-demo/workflow.toml b/.fabro/workflows/context-demo/workflow.toml new file mode 100644 index 000000000..0cdce6b94 --- /dev/null +++ b/.fabro/workflows/context-demo/workflow.toml @@ -0,0 +1,4 @@ +_version = 1 + +[workflow] +graph = "workflow.fabro" diff --git a/apps/fabro-web/app/components/stage-context.tsx b/apps/fabro-web/app/components/stage-context.tsx new file mode 100644 index 000000000..8360c11f4 --- /dev/null +++ b/apps/fabro-web/app/components/stage-context.tsx @@ -0,0 +1,68 @@ +import type { StageContextData } from "./stage-renderers/helpers"; +import { CodeBlock, JsonBlock } from "./stage-renderers/primitives"; + +function ContextValue({ value }: { value: unknown }) { + if (typeof value === "string") { + return {value}; + } + if (typeof value === "number" || typeof value === "boolean") { + return {String(value)}; + } + if (value === null) { + return null; + } + return ; +} + +/** + * Renders the workflow's deliberate outputs for a single stage visit: the + * routing hints it emitted and the context keys it set. Engine bookkeeping + * keys are already filtered out by `extractStageContext`. + */ +export function StageContext({ data }: { data: StageContextData }) { + const { preferredLabel, suggestedNextIds } = data.routing; + const hasRouting = preferredLabel != null || suggestedNextIds.length > 0; + const updateKeys = Object.keys(data.updates).sort(); + + return ( +
+ {hasRouting && ( +
+

+ Routing +

+
+ {preferredLabel != null && ( + <> +
Preferred edge
+
{preferredLabel}
+ + )} + {suggestedNextIds.length > 0 && ( + <> +
Suggested next
+
{suggestedNextIds.join(", ")}
+ + )} +
+
+ )} + + {updateKeys.length > 0 && ( +
+

+ Context writes +

+
+ {updateKeys.map((key) => ( +
+
{key}
+ +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts index f4b6a3158..6c03541c0 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { + extractStageContext, extractStageNotes, parseFanInOutcome, parseHumanInterviewPairs, @@ -206,3 +207,65 @@ describe("extractStageNotes", () => { expect(extractStageNotes([])).toBeNull(); }); }); + +describe("extractStageContext", () => { + test("keeps author-set keys and drops engine bookkeeping keys", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "stage.completed", + properties: { + context_updates: { + "plan.summary": "ship the thing", + review_score: 8, + last_stage: "implement", + last_response: "done", + "response.implement": "full text", + "internal.run_id": "run-1", + "current.preamble": "...", + "command.output": "blob:abc", + "human.gate.selected": "A", + "parallel.results": [], + }, + }, + }), + ]; + const ctx = extractStageContext(events); + expect(ctx).not.toBeNull(); + expect(ctx?.updates).toEqual({ + "plan.summary": "ship the thing", + review_score: 8, + }); + }); + + test("extracts routing hints from preferred_label and suggested_next_ids", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "stage.completed", + properties: { + preferred_label: "approve", + suggested_next_ids: ["review", "merge", 7], + }, + }), + ]; + const ctx = extractStageContext(events); + expect(ctx?.routing.preferredLabel).toBe("approve"); + expect(ctx?.routing.suggestedNextIds).toEqual(["review", "merge"]); + expect(ctx?.updates).toEqual({}); + }); + + test("returns null when the stage only wrote engine keys", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "stage.completed", + properties: { + context_updates: { last_stage: "implement", "command.output": "blob:x" }, + }, + }), + ]; + expect(extractStageContext(events)).toBeNull(); + }); + + test("returns null when the stage has not completed", () => { + expect(extractStageContext([])).toBeNull(); + }); +}); diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts index b1d157f68..561779a55 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts @@ -248,6 +248,63 @@ export function extractStageNotes(events: EventEnvelope[]): string | null { return null; } +export interface StageContextData { + routing: { preferredLabel: string | null; suggestedNextIds: string[] }; + /** `context_updates` keys the workflow deliberately set (engine keys removed). */ + updates: Record; +} + +// Engine/auto-populated context keys. These are bookkeeping or already shown in +// a stage's primary tab (command output, human answers, fan-in results), so the +// Context tab hides them and surfaces only what the workflow deliberately wrote. +const ENGINE_CONTEXT_KEYS = new Set(["last_stage", "last_response", "command.output"]); +const ENGINE_CONTEXT_PREFIXES = [ + "response.", + "internal.", + "current.", + "human.gate.", + "parallel.", +]; + +function isEngineContextKey(key: string): boolean { + if (ENGINE_CONTEXT_KEYS.has(key)) return true; + return ENGINE_CONTEXT_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +/** + * Extract the workflow's deliberate outputs from the `stage.completed` event: + * author-set `context_updates` (minus engine keys) plus the routing hints + * (`preferred_label`, `suggested_next_ids`). Returns null when the stage hasn't + * finished or produced nothing worth showing — which hides the Context tab. + */ +export function extractStageContext(events: EventEnvelope[]): StageContextData | null { + for (const event of events) { + if (event.event !== "stage.completed") continue; + const props: UnknownRecord = event.properties ?? {}; + + const rawUpdates = getObject(props, "context_updates") ?? {}; + const updates: Record = {}; + for (const [key, value] of Object.entries(rawUpdates)) { + if (!isEngineContextKey(key)) updates[key] = value; + } + + const preferredLabel = getString(props, "preferred_label") ?? null; + const suggestedNextIds = (getArray(props, "suggested_next_ids") ?? []).filter( + (v): v is string => typeof v === "string", + ); + + if ( + Object.keys(updates).length === 0 && + !preferredLabel && + suggestedNextIds.length === 0 + ) { + return null; + } + return { routing: { preferredLabel, suggestedNextIds }, updates }; + } + return null; +} + export interface EdgeSelection { fromNode: string; toNode: string; diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 33d753091..6087955d5 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -24,13 +24,17 @@ import type { ThreadDnaItem, ThreadDnaSelection, } from "../components/event-debug"; +import { StageContext } from "../components/stage-context"; import { StageSidebar } from "../components/stage-sidebar"; import type { Stage } from "../components/stage-sidebar"; import { EmptyState } from "../components/state"; import { Tooltip } from "../components/ui"; import { ConditionalDecision } from "../components/stage-renderers/conditional-decision"; import { FanInResults } from "../components/stage-renderers/fan-in-results"; -import { extractStageNotes } from "../components/stage-renderers/helpers"; +import { + extractStageContext, + extractStageNotes, +} from "../components/stage-renderers/helpers"; import { HumanQA } from "../components/stage-renderers/human-qa"; import { ParallelChildren } from "../components/stage-renderers/parallel-children"; import { @@ -105,7 +109,7 @@ const EVENT_KIND_LABEL: Record = { command: "Command", }; -const EVENTS_TABS = ["primary", "debug"] as const; +const EVENTS_TABS = ["primary", "context", "debug"] as const; type EventsTab = (typeof EVENTS_TABS)[number]; const PRIMARY_TAB_LABEL: Record = { @@ -121,6 +125,7 @@ const PRIMARY_TAB_LABEL: Record = { export function eventsTabLabel(tab: EventsTab, renderer: StageRenderer): string { if (tab === "debug") return "Debug"; + if (tab === "context") return "Context"; return PRIMARY_TAB_LABEL[renderer]; } @@ -1107,10 +1112,12 @@ function ToolGroupDetailsPanel({ function EventsTabToggle({ tab, renderer, + availableTabs, onTabChange, }: { tab: EventsTab; renderer: StageRenderer; + availableTabs: readonly EventsTab[]; onTabChange: (tab: EventsTab) => void; }) { return ( @@ -1119,7 +1126,7 @@ function EventsTabToggle({ aria-label="View" className="inline-flex rounded-md bg-panel p-0.5 outline-1 -outline-offset-1 outline-line-strong" > - {EVENTS_TABS.map((value) => { + {availableTabs.map((value) => { const active = tab === value; return (