mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
fb2174c7d0
commit
178adf15ea
6 changed files with 241 additions and 5 deletions
16
.fabro/workflows/context-demo/workflow.fabro
Normal file
16
.fabro/workflows/context-demo/workflow.fabro
Normal 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"]
|
||||
}
|
||||
4
.fabro/workflows/context-demo/workflow.toml
Normal file
4
.fabro/workflows/context-demo/workflow.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
68
apps/fabro-web/app/components/stage-context.tsx
Normal file
68
apps/fabro-web/app/components/stage-context.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue