Add stage insights sidebar to agent stage pages

Renders a second left sidebar on /runs/<id>/stages/<agent-stage> 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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-23 22:45:49 -04:00
parent 8ceb246b5a
commit deb8f27a27
No known key found for this signature in database
4 changed files with 863 additions and 1 deletions

View file

@ -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> = {}): 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> = {}): 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<string, string>();
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(
<MemoryRouter>
<StageInsightsSidebar stage={stage} contextWindow={contextWindow} />
</MemoryRouter>,
);
});
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");
});
});

View file

@ -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<SectionKey, boolean> = {
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 (
<aside
className={`${collapsed ? "w-12" : "w-60"} shrink-0 transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]`}
aria-label="Stage insights"
>
<div className="flex h-7 items-center justify-between">
{!collapsed && (
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Insights
</h3>
)}
<button
type="button"
onClick={toggleCollapsed}
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand insights sidebar" : "Collapse insights sidebar"}
title={collapsed ? "Expand insights sidebar" : "Collapse insights sidebar"}
className={`inline-flex size-7 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 ${collapsed ? "mx-auto" : "-mr-1"}`}
>
{collapsed ? (
<ChevronDoubleRightIcon className="size-4" />
) : (
<ChevronDoubleLeftIcon className="size-4" />
)}
</button>
</div>
<div className="mt-3 flex flex-col gap-4">
<CollapsibleSection
sectionKey="todos"
title="Todos"
icon={ListBulletIcon}
collapsed={collapsed}
count={`${todoStats.done}/${todoStats.total}`}
empty={todoStats.total === 0}
>
<TodoSection todos={todos} />
</CollapsibleSection>
<ContextWindowSection collapsed={collapsed} snapshot={contextWindow ?? null} />
<CollapsibleSection
sectionKey="skills"
title="Skills"
icon={CheckBadgeIcon}
collapsed={collapsed}
count={`${skills.activated.length}/${skills.available.length}`}
empty={skills.available.length === 0}
hideCountWhenCollapsed
>
<SkillsSection activated={skills.activated} available={skills.available} activatedNames={activatedSkillNames} />
</CollapsibleSection>
<CollapsibleSection
sectionKey="mcps"
title="MCPs"
icon={ServerStackIcon}
collapsed={collapsed}
count={mcpServers.length}
empty={mcpServers.length === 0}
hideCountWhenCollapsed
>
<McpSection servers={mcpServers} />
</CollapsibleSection>
<PermissionBadge level={permission} collapsed={collapsed} />
</div>
</aside>
);
}
// ---------- 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 (
<div className="flex flex-col items-center gap-0.5" title={`${title}: ${countLabel(count)}`}>
<Icon className="size-4 shrink-0 text-fg-muted" />
{!hideCountWhenCollapsed && (
<span className="font-mono text-[10px] tabular-nums text-fg-3">{count}</span>
)}
</div>
);
}
return (
<div>
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
<ChevronRightIcon
className={`size-3 shrink-0 text-fg-muted transition-transform duration-150 ${open ? "rotate-90" : ""}`}
/>
<Icon className="size-4 shrink-0 text-fg-muted" />
<span className="flex-1 text-xs font-medium uppercase tracking-wider text-fg-3">{title}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">{count}</span>
</button>
{open && (
<div className="mt-1 pl-6 pr-2">
{empty ? <p className="text-xs text-fg-muted">None</p> : children}
</div>
)}
</div>
);
}
// ---------- 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 <p className="text-xs text-fg-muted">No todos.</p>;
const items = [...(todos.items ?? [])].sort((a, b) => a.order - b.order);
return (
<ul role="list" className="space-y-1">
{items.map((item) => (
<TodoRow key={item.id} todo={item} />
))}
</ul>
);
}
function TodoRow({ todo }: { todo: TodoProjection }) {
const { Icon, color, srLabel } = todoStatusVisual(todo.status);
const muted = todo.status === TodoStatus.COMPLETED;
return (
<li className="flex items-start gap-1.5">
<Icon className={`mt-0.5 size-3.5 shrink-0 ${color}`} aria-label={srLabel} />
<span className={`min-w-0 text-xs ${muted ? "text-fg-muted line-through" : "text-fg-2"}`}>{todo.subject}</span>
</li>
);
}
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 <span className={`inline-block rounded-full border border-current ${className ?? ""}`} />;
}
// ---------- 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 (
<div className="flex flex-col items-center gap-0.5" title={`Context: ${pctLabel}`}>
<Squares2X2Icon className="size-4 shrink-0 text-fg-muted" />
<span className="font-mono text-[10px] tabular-nums text-fg-3">{pctLabel}</span>
</div>
);
}
return (
<div>
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
<ChevronRightIcon
className={`size-3 shrink-0 text-fg-muted transition-transform duration-150 ${open ? "rotate-90" : ""}`}
/>
<Squares2X2Icon className="size-4 shrink-0 text-fg-muted" />
<span className="flex-1 text-xs font-medium uppercase tracking-wider text-fg-3">Context</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">{pctLabel}</span>
</button>
{open && (
<>
<div className="mt-2 px-2">
<ContextBar snapshot={snapshot} />
</div>
<ContextBreakdown snapshot={snapshot} />
</>
)}
</div>
);
}
function ContextBar({ snapshot }: { snapshot: StageContextWindow | null }) {
if (!snapshot || snapshot.usage_percent == null) {
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-overlay-strong" aria-hidden="true" />
);
}
const breakdown = nonZeroBreakdown(snapshot.breakdown);
const total = breakdown.reduce((acc, item) => acc + item.usage_percent, 0);
const scale = total > 0 ? snapshot.usage_percent / total : 1;
return (
<div
role="img"
aria-label={`${Math.round(snapshot.usage_percent)}% of context window used`}
className="flex h-1.5 w-full overflow-hidden rounded-full bg-overlay-strong"
>
{breakdown.map((item) => (
<span
key={item.category}
className="block h-full"
style={{
width: `${item.usage_percent * scale}%`,
backgroundColor: categoryColor(item.category),
}}
/>
))}
</div>
);
}
function ContextBreakdown({ snapshot }: { snapshot: StageContextWindow | null }) {
if (!snapshot) {
return <p className="mt-2 px-2 text-xs text-fg-muted">Context usage not yet available.</p>;
}
if (snapshot.staleness === StageContextWindowStaleness.UNAVAILABLE) {
return <p className="mt-2 px-2 text-xs text-fg-muted">Context usage unavailable for this stage.</p>;
}
const totalTokens = snapshot.input_tokens ?? 0;
const contextWindow = snapshot.context_window_tokens ?? null;
return (
<div className="mt-3 space-y-2 px-2">
<div className="flex items-baseline justify-between font-mono text-xs tabular-nums text-fg-3">
<span>{formatTokenCount(totalTokens, { compactDecimal: true })}</span>
{contextWindow != null && (
<span className="text-fg-muted">/ {formatTokenCount(contextWindow, { compactDecimal: true })}</span>
)}
</div>
<ul role="list" className="space-y-1">
{snapshot.breakdown
.filter((item) => item.tokens > 0)
.map((item) => (
<li key={item.category} className="flex items-center gap-2">
<span
className="block size-2 shrink-0 rounded-sm"
style={{ backgroundColor: categoryColor(item.category) }}
aria-hidden="true"
/>
<span className="flex-1 truncate text-xs text-fg-3">{categoryLabel(item.category)}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatTokenCount(item.tokens, { compactDecimal: true })}
</span>
</li>
))}
</ul>
{snapshot.warnings.length > 0 && (
<ul role="list" className="space-y-1">
{snapshot.warnings.map((w) => (
<li key={w.code} className="text-[11px] text-amber">⚠ {w.message}</li>
))}
</ul>
)}
</div>
);
}
function nonZeroBreakdown(items: StageContextWindowBreakdownItem[]): StageContextWindowBreakdownItem[] {
return items.filter((i) => i.usage_percent > 0);
}
/**
* Render the breakdown segment color via inline `backgroundColor` so the
* value is independent of Tailwind's class scanner — every category resolves
* to a known CSS custom property defined in `app.css`.
*
* Palette is chosen so the typical chunks (Conversation big + System +
* Tools) read as three distinct hues rather than three adjacent teals.
*/
function categoryColor(category: StageContextWindowCategory): string {
switch (category) {
case StageContextWindowCategory.SYSTEM_PROMPT:
return "var(--color-teal-700)";
case StageContextWindowCategory.TOOLS:
return "var(--color-amber)";
case StageContextWindowCategory.MCP_TOOLS:
return "var(--color-mint)";
case StageContextWindowCategory.SKILLS:
return "var(--color-teal-500)";
case StageContextWindowCategory.MEMORY:
return "var(--color-coral)";
case StageContextWindowCategory.CONVERSATION:
return "var(--color-teal-300)";
case StageContextWindowCategory.OTHER:
default:
return "var(--color-fg-muted)";
}
}
function categoryLabel(category: StageContextWindowCategory): string {
switch (category) {
case StageContextWindowCategory.SYSTEM_PROMPT:
return "System prompt";
case StageContextWindowCategory.TOOLS:
return "Tools";
case StageContextWindowCategory.MCP_TOOLS:
return "MCP tools";
case StageContextWindowCategory.SKILLS:
return "Skills";
case StageContextWindowCategory.MEMORY:
return "Memory";
case StageContextWindowCategory.CONVERSATION:
return "Conversation";
case StageContextWindowCategory.OTHER:
default:
return "Other";
}
}
// ---------- Skills ----------
interface SkillsSectionProps {
activated: ActivatedSkill[];
available: AgentSkillSummary[];
activatedNames: Set<string>;
}
function SkillsSection({ activated, available, activatedNames }: SkillsSectionProps) {
if (activated.length === 0 && available.length === 0) {
return <p className="text-xs text-fg-muted">No skills loaded.</p>;
}
const remaining = available.length - activatedNames.size;
return (
<div className="space-y-2">
{activated.length > 0 && (
<ul role="list" className="space-y-1">
{activated.map((skill) => (
<li key={`${skill.name}:${skill.source}`} className="flex items-center gap-1.5">
<SkillSourceIcon source={skill.source} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-2">{skill.name}</span>
<span className="text-[10px] uppercase tracking-wider text-fg-muted">{skill.source}</span>
</li>
))}
</ul>
)}
{remaining > 0 && (
<p className="text-[11px] text-fg-muted">{`+${remaining} more available`}</p>
)}
</div>
);
}
function SkillSourceIcon({ source }: { source: ActivatedSkill["source"] }) {
const Icon = source === AgentSkillActivationSource.SLASH ? CommandLineIcon : PuzzlePieceIcon;
return <Icon className="size-3.5 shrink-0 text-fg-muted" />;
}
// ---------- MCPs ----------
function McpSection({ servers }: { servers: McpServerProjection[] }) {
if (servers.length === 0) return <p className="text-xs text-fg-muted">No MCP servers.</p>;
return (
<ul role="list" className="space-y-1">
{servers.map((server) => (
<li key={server.server_name} className="flex items-center gap-1.5">
{server.status.kind === "ready" ? (
<CheckCircleIcon className="size-3.5 shrink-0 text-mint" aria-label="Ready" />
) : (
<XCircleIcon className="size-3.5 shrink-0 text-coral" aria-label="Failed" />
)}
<span className="min-w-0 flex-1 truncate text-xs text-fg-2">{server.server_name}</span>
{server.status.kind === "ready" ? (
<span className="font-mono text-[10px] tabular-nums text-fg-muted">
{`${server.tool_count} ${server.tool_count === 1 ? "tool" : "tools"}`}
</span>
) : (
<span className="text-[10px] uppercase tracking-wider text-coral">Failed</span>
)}
</li>
))}
</ul>
);
}
// ---------- Permissions ----------
function PermissionBadge({ level, collapsed }: { level: PermissionLevel | null; collapsed: boolean }) {
const { Icon, label, color, title } = permissionVisual(level);
if (collapsed) {
return (
<div className="flex flex-col items-center gap-0.5" title={title}>
<Icon className={`size-4 shrink-0 ${color}`} aria-label={label} />
</div>
);
}
return (
<div
title={title}
className="mx-2 flex items-center gap-2 rounded-md border border-line bg-overlay px-2 py-1.5"
>
<Icon className={`size-4 shrink-0 ${color}`} />
<span className="text-xs text-fg-2">{label}</span>
</div>
);
}
function permissionVisual(level: PermissionLevel | null): {
Icon: IconType;
color: string;
label: string;
title: string;
} {
switch (level) {
case PermissionLevel.READ_ONLY:
return { Icon: LockClosedIcon, color: "text-fg-3", label: "Read-only", title: "Agent can read but not modify files or run commands" };
case PermissionLevel.READ_WRITE:
return { Icon: PencilSquareIcon, color: "text-amber", label: "Read/write", title: "Agent can read and modify files" };
case PermissionLevel.FULL:
return { Icon: BoltIcon, color: "text-coral", label: "Full access", title: "Agent can read, modify files, and run commands" };
case null:
default:
return { Icon: LockClosedIcon, color: "text-fg-muted", label: "Unknown", title: "Permission level not yet reported" };
}
}
// ---------- helpers ----------
type IconType = (props: { className?: string }) => ReactNode;
function countLabel(count: ReactNode): string {
return typeof count === "string" || typeof count === "number" ? String(count) : "";
}
function loadStoredCollapsed(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(COLLAPSED_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function persistCollapsed(collapsed: boolean) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(COLLAPSED_STORAGE_KEY, collapsed ? "1" : "0");
} catch {
// non-fatal
}
}
function loadStoredSectionOpen(key: SectionKey): boolean {
if (typeof window === "undefined") return SECTIONS_DEFAULT_OPEN[key];
try {
const stored = window.localStorage.getItem(SECTION_STORAGE_PREFIX + key);
if (stored === "1") return true;
if (stored === "0") return false;
} catch {
// fall through to default
}
return SECTIONS_DEFAULT_OPEN[key];
}
function persistSectionOpen(key: SectionKey, open: boolean) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(SECTION_STORAGE_PREFIX + key, open ? "1" : "0");
} catch {
// non-fatal
}
}

View file

@ -25,6 +25,7 @@ import type {
ThreadDnaSelection,
} from "../components/event-debug";
import { StageContext } from "../components/stage-context";
import { StageInsightsSidebar } from "../components/stage-insights-sidebar";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
@ -54,9 +55,11 @@ import {
import {
useRun,
useRunEventsList,
useRunStageContextWindow,
useRunStageEvents,
useRunStageLog,
useRunStages,
useRunState,
} from "../lib/queries";
import { STAGE_ACTIVITY_EVENT_TYPES, type StageActivityEventType } from "../lib/run-events";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
@ -1352,6 +1355,18 @@ export default function RunStages() {
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at;
const stageEventsQuery = useRunStageEvents(id, selectedStageId);
// Insights sidebar only renders for agent stages; fetch projection + context
// window only when the user is on one to keep the hot path lean.
const isAgentStage = selectedStage?.handler === "agent";
const runStateQuery = useRunState(isAgentStage ? id : undefined);
const contextWindowQuery = useRunStageContextWindow(
isAgentStage ? id : undefined,
isAgentStage ? selectedStageId : undefined,
);
const stageProjection =
isAgentStage && selectedStageId
? runStateQuery.data?.stages[selectedStageId]
: undefined;
const turns = useMemo(
() =>
selectedStageId
@ -1493,6 +1508,23 @@ export default function RunStages() {
/>
</div>
{isAgentStage && (
<>
<div className="shrink-0 px-3 pb-6 pt-3">
<StageInsightsSidebar
stage={stageProjection}
contextWindow={contextWindowQuery.data}
/>
</div>
<div className="relative w-px shrink-0">
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 -bottom-6 bg-line"
/>
</div>
</>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
<div className="shrink-0 border-b border-line">
<div className="pl-3 pr-3">

View file

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