From deb8f27a27bb794c246de6fda9378750859071f2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 22:45:49 -0400 Subject: [PATCH] Add stage insights sidebar to agent stage pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a second left sidebar on /runs//stages/ with todos, color-coded context-window usage and breakdown, skills, MCP servers, and permission level. Data comes from the existing StageProjection and the context-window endpoint added in #378; no API changes. Also set permission_level to Full on workflow agent SessionOptions — workflow agents run with no tool_access_policy and expose the full tool registry, so Full is the honest report and avoids "Unknown" rendering in the new sidebar. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stage-insights-sidebar.test.tsx | 214 ++++++ .../app/components/stage-insights-sidebar.tsx | 610 ++++++++++++++++++ apps/fabro-web/app/routes/run-stages.tsx | 32 + .../fabro-workflow/src/handler/llm/api.rs | 8 +- 4 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 apps/fabro-web/app/components/stage-insights-sidebar.test.tsx create mode 100644 apps/fabro-web/app/components/stage-insights-sidebar.tsx diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx new file mode 100644 index 000000000..3d89e2df6 --- /dev/null +++ b/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx @@ -0,0 +1,214 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { MemoryRouter } from "react-router"; + +import { + AgentSkillActivationSource, + PermissionLevel, + StageContextWindowCategory, + StageContextWindowCountMethod, + StageContextWindowStaleness, + TodoListKind, + TodoStatus, +} from "@qltysh/fabro-api-client"; +import type { + StageContextWindow, + StageProjection, +} from "@qltysh/fabro-api-client"; + +import { StageInsightsSidebar } from "./stage-insights-sidebar"; + +function makeStage(overrides: Partial = {}): StageProjection { + return { + first_event_seq: 1, + state: "running", + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_create_tokens: 0, + total_tokens: 0, + } as StageProjection["usage"], + ...overrides, + }; +} + +function makeContextWindow(overrides: Partial = {}): StageContextWindow { + return { + stage_id: "implement@1", + available: true, + unavailable_reason: null, + provider: "anthropic", + model: "claude-opus-4-7", + context_window_tokens: 200_000, + input_tokens: 62_000, + usage_percent: 31, + count_method: StageContextWindowCountMethod.PROVIDER_API_SCALED_BREAKDOWN, + staleness: StageContextWindowStaleness.LIVE, + generated_at: new Date().toISOString(), + event_seq: 42, + breakdown: [ + { category: StageContextWindowCategory.SYSTEM_PROMPT, tokens: 8_000, usage_percent: 4 }, + { category: StageContextWindowCategory.TOOLS, tokens: 12_000, usage_percent: 6 }, + { category: StageContextWindowCategory.CONVERSATION, tokens: 42_000, usage_percent: 21 }, + ], + warnings: [], + ...overrides, + }; +} + +// bun:test runs in a node-like env without a DOM, so shim `window.localStorage` +// once — the sidebar feature-detects `typeof window` to decide whether to +// persist collapse state. Seeding the shim lets us open default-collapsed +// sections (Skills, MCPs) in the assertions below. Other test files (e.g. +// services-panel.test.tsx) install their own window and rely on +// `delete globalThis.window` cleanup, so this descriptor stays configurable. +let restoreWindow: (() => void) | null = null; +beforeAll(() => { + const store = new Map(); + for (const key of ["todos", "context", "skills", "mcps"]) { + store.set(`fabro:stage-insights-section:${key}`, "1"); + } + const stub = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }, + }; + const had = "window" in globalThis; + const prev = (globalThis as { window?: unknown }).window; + Object.defineProperty(globalThis, "window", { value: stub, writable: true, configurable: true }); + restoreWindow = () => { + if (had) { + Object.defineProperty(globalThis, "window", { value: prev, writable: true, configurable: true }); + } else { + delete (globalThis as { window?: unknown }).window; + } + }; +}); + +afterAll(() => { + restoreWindow?.(); + restoreWindow = null; +}); + +function render(stage: StageProjection | undefined, contextWindow: StageContextWindow | null): string { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + + + , + ); + }); + return JSON.stringify(renderer.toJSON()); +} + +describe("StageInsightsSidebar", () => { + test("renders todo done/total ratio", () => { + const stage = makeStage({ + todos: { + kind: TodoListKind.ANTHROPIC_TASKS, + list_id: "anthropic_tasks:root", + items: [ + { id: "1", status: TodoStatus.COMPLETED, order: 0, subject: "Plan refactor" }, + { id: "2", status: TodoStatus.COMPLETED, order: 1, subject: "Add tests" }, + { id: "3", status: TodoStatus.IN_PROGRESS, order: 2, subject: "Land migration" }, + { id: "4", status: TodoStatus.PENDING, order: 3, subject: "Review with Kieran" }, + ], + }, + }); + const dom = render(stage, null); + expect(dom).toContain("2/4"); + expect(dom).toContain("Plan refactor"); + expect(dom).toContain("Land migration"); + }); + + test("renders context window percent and breakdown labels", () => { + const dom = render(makeStage(), makeContextWindow()); + expect(dom).toContain("31%"); + expect(dom).toContain("System prompt"); + expect(dom).toContain("Conversation"); + }); + + test("hides breakdown labels in unavailable state but still renders bar", () => { + const cw = makeContextWindow({ + available: false, + usage_percent: null, + input_tokens: null, + staleness: StageContextWindowStaleness.UNAVAILABLE, + unavailable_reason: null, + }); + const dom = render(makeStage(), cw); + expect(dom).toContain("--"); + expect(dom).not.toContain("31%"); + }); + + test("renders permission badge for read-only", () => { + const dom = render(makeStage({ permission_level: PermissionLevel.READ_ONLY }), null); + expect(dom).toContain("Read-only"); + }); + + test("renders permission badge for full access", () => { + const dom = render(makeStage({ permission_level: PermissionLevel.FULL }), null); + expect(dom).toContain("Full access"); + }); + + test("renders mcp server count and tool count", () => { + const dom = render( + makeStage({ + mcp_servers: [ + { + server_name: "context7", + tool_count: 12, + status: { kind: "ready", tools: [] }, + }, + { + server_name: "atlassian", + tool_count: 0, + status: { kind: "failed", error: "auth failed" }, + }, + ], + }), + null, + ); + expect(dom).toContain("context7"); + expect(dom).toContain("12 tools"); + expect(dom).toContain("atlassian"); + expect(dom).toContain("Failed"); + }); + + test("shows skill activated/available ratio with source label", () => { + const dom = render( + makeStage({ + skills: { + activated: [ + { name: "frontend-design", source: AgentSkillActivationSource.SLASH }, + { name: "debug", source: AgentSkillActivationSource.TOOL }, + ], + available: [ + { name: "frontend-design", description: "" }, + { name: "debug", description: "" }, + { name: "tdd", description: "" }, + { name: "ce-review", description: "" }, + ], + }, + }), + null, + ); + expect(dom).toContain("2/4"); + expect(dom).toContain("frontend-design"); + expect(dom).toContain("slash"); + expect(dom).toContain("+2 more available"); + }); + + test("renders empty-friendly content when stage projection is missing", () => { + const dom = render(undefined, null); + // sidebar still renders even with no data + expect(dom).toContain("Insights"); + expect(dom).toContain("Unknown"); + }); +}); diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.tsx new file mode 100644 index 000000000..4c566a473 --- /dev/null +++ b/apps/fabro-web/app/components/stage-insights-sidebar.tsx @@ -0,0 +1,610 @@ +import { type ReactNode, useCallback, useState } from "react"; +import { + ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, + ChevronRightIcon, +} from "@heroicons/react/20/solid"; +import { + ArrowPathIcon, + CheckCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; +import { + BoltIcon, + CheckBadgeIcon, + CommandLineIcon, + ListBulletIcon, + LockClosedIcon, + PencilSquareIcon, + PuzzlePieceIcon, + ServerStackIcon, + Squares2X2Icon, +} from "@heroicons/react/24/outline"; +import { + AgentSkillActivationSource, + PermissionLevel, + StageContextWindowCategory, + StageContextWindowStaleness, + TodoStatus, +} from "@qltysh/fabro-api-client"; +import type { + ActivatedSkill, + AgentSkillSummary, + McpServerProjection, + StageContextWindow, + StageContextWindowBreakdownItem, + StageProjection, + TodoListProjection, + TodoProjection, +} from "@qltysh/fabro-api-client"; +import { formatTokenCount } from "../lib/format"; + +const COLLAPSED_STORAGE_KEY = "fabro:stage-insights-sidebar-collapsed"; +const SECTION_STORAGE_PREFIX = "fabro:stage-insights-section:"; + +type SectionKey = "todos" | "context" | "skills" | "mcps"; + +const SECTIONS_DEFAULT_OPEN: Record = { + todos: true, + context: false, + skills: false, + mcps: false, +}; + +export interface StageInsightsSidebarProps { + /** Full stage projection (undefined while loading or for non-agent stages). */ + stage: StageProjection | undefined; + /** Snapshot from `useRunStageContextWindow`. Null when unavailable. */ + contextWindow: StageContextWindow | null | undefined; +} + +export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSidebarProps) { + const [collapsed, setCollapsed] = useState(loadStoredCollapsed); + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + persistCollapsed(next); + return next; + }); + }, []); + + const todos = stage?.todos ?? null; + const skills = stage?.skills ?? { activated: [], available: [] }; + const mcpServers = stage?.mcp_servers ?? []; + const permission = stage?.permission_level ?? null; + + const todoStats = countTodoStats(todos); + const activatedSkillNames = new Set(skills.activated.map((s) => s.name)); + + return ( + + ); +} + +// ---------- Collapsible section ---------- + +interface CollapsibleSectionProps { + sectionKey: SectionKey; + title: string; + icon: IconType; + collapsed: boolean; + count: ReactNode; + empty: boolean; + /** Suppress the count badge under the icon when the sidebar is collapsed. */ + hideCountWhenCollapsed?: boolean; + children: ReactNode; +} + +function CollapsibleSection({ + sectionKey, + title, + icon: Icon, + collapsed, + count, + empty, + hideCountWhenCollapsed = false, + children, +}: CollapsibleSectionProps) { + const [open, setOpen] = useState(() => loadStoredSectionOpen(sectionKey)); + const toggle = useCallback(() => { + setOpen((prev) => { + const next = !prev; + persistSectionOpen(sectionKey, next); + return next; + }); + }, [sectionKey]); + + if (collapsed) { + return ( +
+ + {!hideCountWhenCollapsed && ( + {count} + )} +
+ ); + } + + return ( +
+ + {open && ( +
+ {empty ?

None

: children} +
+ )} +
+ ); +} + +// ---------- Todos ---------- + +interface TodoStats { + done: number; + total: number; +} + +function countTodoStats(list: TodoListProjection | null): TodoStats { + const items = list?.items ?? []; + let done = 0; + for (const item of items) { + if (item.status === TodoStatus.COMPLETED) done += 1; + } + return { done, total: items.length }; +} + +function TodoSection({ todos }: { todos: TodoListProjection | null }) { + if (!todos || (todos.items?.length ?? 0) === 0) return

No todos.

; + const items = [...(todos.items ?? [])].sort((a, b) => a.order - b.order); + return ( +
    + {items.map((item) => ( + + ))} +
+ ); +} + +function TodoRow({ todo }: { todo: TodoProjection }) { + const { Icon, color, srLabel } = todoStatusVisual(todo.status); + const muted = todo.status === TodoStatus.COMPLETED; + return ( +
  • + + {todo.subject} +
  • + ); +} + +function todoStatusVisual(status: TodoStatus): { Icon: IconType; color: string; srLabel: string } { + switch (status) { + case TodoStatus.COMPLETED: + return { Icon: CheckCircleIcon, color: "text-mint", srLabel: "Completed" }; + case TodoStatus.IN_PROGRESS: + return { Icon: ArrowPathIcon, color: "text-teal-500", srLabel: "In progress" }; + case TodoStatus.DELETED: + return { Icon: XCircleIcon, color: "text-fg-muted", srLabel: "Deleted" }; + case TodoStatus.PENDING: + default: + return { Icon: TodoPendingIcon, color: "text-fg-muted", srLabel: "Pending" }; + } +} + +/** Empty circle for pending todos (matches Tailwind sizing). */ +function TodoPendingIcon({ className }: { className?: string }) { + return ; +} + +// ---------- Context window ---------- + +interface ContextWindowSectionProps { + collapsed: boolean; + snapshot: StageContextWindow | null; +} + +function ContextWindowSection({ collapsed, snapshot }: ContextWindowSectionProps) { + const [open, setOpen] = useState(() => loadStoredSectionOpen("context")); + const toggle = useCallback(() => { + setOpen((prev) => { + const next = !prev; + persistSectionOpen("context", next); + return next; + }); + }, []); + + const pct = snapshot?.usage_percent ?? null; + const pctLabel = pct == null ? "--" : `${Math.round(pct)}%`; + + if (collapsed) { + return ( +
    + + {pctLabel} +
    + ); + } + + return ( +
    + + + {open && ( + <> +
    + +
    + + + )} +
    + ); +} + +function ContextBar({ snapshot }: { snapshot: StageContextWindow | null }) { + if (!snapshot || snapshot.usage_percent == null) { + return ( + + {isAgentStage && ( + <> +
    + +
    +
    + + + )} +
    diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 553114de8..42dc0e1f5 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -21,7 +21,7 @@ use fabro_mcp::config::McpServerSettings; use fabro_model::catalog::LlmCatalogSettings; use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ModelRef, ProviderId}; use fabro_types::settings::run::RunModelControls; -use fabro_types::{RunId, SessionCapability, StageId}; +use fabro_types::{PermissionLevel, RunId, SessionCapability, StageId}; use serde::de::DeserializeOwned; use tokio::sync::Mutex as TokioMutex; use tokio::task::JoinHandle; @@ -732,6 +732,12 @@ impl AgentApiBackend { speed: controls.speed, tool_hooks, mcp_servers, + // Workflow agents run with no `tool_access_policy`, which exposes + // the entire tool registry (read, write, shell, subagent, MCP) and + // skips approval gating. Report that truthfully so the UI doesn't + // render "Unknown" for every workflow stage. Override per-stage if + // a future workflow attribute narrows the scope. + permission_level: Some(PermissionLevel::Full), ..SessionOptions::default() };