mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
8ceb246b5a
commit
deb8f27a27
4 changed files with 863 additions and 1 deletions
214
apps/fabro-web/app/components/stage-insights-sidebar.test.tsx
Normal file
214
apps/fabro-web/app/components/stage-insights-sidebar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
610
apps/fabro-web/app/components/stage-insights-sidebar.tsx
Normal file
610
apps/fabro-web/app/components/stage-insights-sidebar.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue