feat(web): add Context tab to the stage detail view (#340)

## 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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-21 20:19:55 -04:00 committed by GitHub
parent fb2174c7d0
commit 178adf15ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 241 additions and 5 deletions

View file

@ -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"]
}

View file

@ -0,0 +1,4 @@
_version = 1
[workflow]
graph = "workflow.fabro"

View file

@ -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 <CodeBlock>{value}</CodeBlock>;
}
if (typeof value === "number" || typeof value === "boolean") {
return <span className="font-mono text-sm text-fg-3">{String(value)}</span>;
}
if (value === null) {
return <span className="font-mono text-sm text-fg-muted">null</span>;
}
return <JsonBlock value={JSON.stringify(value, null, 2)} />;
}
/**
* 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 (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
{hasRouting && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Routing
</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
{preferredLabel != null && (
<>
<dt className="text-fg-muted">Preferred edge</dt>
<dd className="font-mono text-fg-3">{preferredLabel}</dd>
</>
)}
{suggestedNextIds.length > 0 && (
<>
<dt className="text-fg-muted">Suggested next</dt>
<dd className="font-mono text-fg-3">{suggestedNextIds.join(", ")}</dd>
</>
)}
</dl>
</section>
)}
{updateKeys.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Context writes
</h3>
<div className="space-y-4">
{updateKeys.map((key) => (
<div key={key}>
<div className="mb-1 font-mono text-xs text-fg-2">{key}</div>
<ContextValue value={data.updates[key]} />
</div>
))}
</div>
</section>
)}
</div>
);
}

View file

@ -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();
});
});

View file

@ -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<string, unknown>;
}
// 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<string, unknown> = {};
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;

View file

@ -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<EventKind, string> = {
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<StageRenderer, string> = {
@ -121,6 +125,7 @@ const PRIMARY_TAB_LABEL: Record<StageRenderer, string> = {
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 (
<button
@ -1144,6 +1151,7 @@ function EventsTabToggle({
function EventsToolbar({
tab,
renderer,
availableTabs,
commandTurn,
onTabChange,
selectedKinds,
@ -1159,6 +1167,7 @@ function EventsToolbar({
}: {
tab: EventsTab;
renderer: StageRenderer;
availableTabs: readonly EventsTab[];
commandTurn: CommandTurn | null;
onTabChange: (tab: EventsTab) => void;
selectedKinds: EventKind[];
@ -1194,7 +1203,12 @@ function EventsToolbar({
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<EventsTabToggle tab={tab} renderer={renderer} onTabChange={onTabChange} />
<EventsTabToggle
tab={tab}
renderer={renderer}
availableTabs={availableTabs}
onTabChange={onTabChange}
/>
{showFilters && (
<div className="flex flex-1 flex-wrap items-center gap-2">
{tab === "primary" ? (
@ -1297,7 +1311,6 @@ export default function RunStages() {
}, [selectedStageId]);
const [tab, setTab] = useState<EventsTab>("primary");
const effectiveTab: EventsTab = tab;
const [selectedKinds, setSelectedKinds] = useState<EventKind[]>([
...EVENT_KINDS,
]);
@ -1380,6 +1393,18 @@ export default function RunStages() {
});
}, [debugEvents, selectedDebugCategories, search]);
// The Context tab surfaces the workflow's deliberate per-visit outputs. It
// only exists when the stage completed and actually wrote something.
const contextData = useMemo(
() => extractStageContext(debugEvents),
[debugEvents],
);
const availableTabs = useMemo<EventsTab[]>(
() => (contextData ? [...EVENTS_TABS] : ["primary", "debug"]),
[contextData],
);
const effectiveTab: EventsTab = availableTabs.includes(tab) ? tab : "primary";
if (!id || !stages.length) {
return (
<div className="py-12">
@ -1410,6 +1435,7 @@ export default function RunStages() {
<EventsToolbar
tab={effectiveTab}
renderer={renderer}
availableTabs={availableTabs}
commandTurn={commandTurn}
onTabChange={setTab}
selectedKinds={selectedKinds}
@ -1520,6 +1546,8 @@ export default function RunStages() {
) : (
<StageSummary stage={selectedStage} events={debugEvents} />
)
) : effectiveTab === "context" ? (
contextData ? <StageContext data={contextData} /> : null
) : debugEvents.length > 0 && filteredDebugEvents.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
No events match these filters.