Show the agent sidebar from stage.agent

The stage insights sidebar reads every session fact from the coding
agent's fold: the root agent's todo list with subagent lists counted
apart, MCP status derived from disconnected, error, and tools, skills, and
new Files and Subagents sections, a failover badge naming the route the
session moved to and why it stopped, and a compactions row under the
context window. The run state refreshes on the agent's own events those
sections read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-13 08:13:10 -06:00
parent b5ee16b37a
commit 232d347d39
No known key found for this signature in database
4 changed files with 534 additions and 84 deletions

View file

@ -3,17 +3,25 @@ import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import {
AgentSessionActivity,
CompactionReason,
ContextWindowCategory,
ContextWindowCountMethod,
ContextWindowStaleness,
FailoverContinuation,
FailoverStop,
SkillActivationSource,
TodoListKind,
TodoStatus,
ToolCategory,
} from "@qltysh/fabro-api-client";
import type {
AgentErrorData,
AgentSessionProjection,
McpToolSummary,
StageContextWindow,
StageProjection,
TokenUsage,
} from "@qltysh/fabro-api-client";
import { StageInsightsSidebar } from "./stage-insights-sidebar";
@ -33,6 +41,58 @@ function makeStage(overrides: Partial<StageProjection> = {}): StageProjection {
};
}
const NO_TOKENS: TokenUsage = { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 };
/** The coding agent's fold of a stage that has seen nothing yet. */
function makeAgent(overrides: Partial<AgentSessionProjection> = {}): AgentSessionProjection {
return {
root_session_id: "ses_root",
route: { provider: "anthropic", model: "claude-opus-4-7" },
activity: AgentSessionActivity.RUNNING,
usage: NO_TOKENS,
cost_usd_micros: null,
messages: 0,
descendants: {},
context_window: null,
tools: {},
mcp_servers: {},
skills: { available: [], activated: [] },
subagent_counts: { spawned: 0, turns_started: 0, completed: 0, failed: 0, closed: 0 },
todos: {},
subagents: [],
compactions: [],
failovers: [],
files_touched: [],
last_file_touched: null,
prompts: 1,
prompt: {
completed: false,
usage: NO_TOKENS,
cost_usd_micros: null,
messages: 0,
context_window: null,
tool_calls: 0,
descendants: {},
subagents: { spawned: 0, turns_started: 0, completed: 0, failed: 0, closed: 0 },
compactions: [],
files_touched: [],
last_file_touched: null,
},
...overrides,
};
}
function mcpTools(count: number): McpToolSummary[] {
return Array.from({ length: count }, (_, i) => ({
name: `mcp__server__tool_${i}`,
original_name: `tool_${i}`,
}));
}
function agentError(message: string): AgentErrorData {
return { kind: "llm", message };
}
function makeContextWindow(overrides: Partial<StageContextWindow> = {}): StageContextWindow {
return {
stage_id: "implement@1",
@ -60,13 +120,13 @@ function makeContextWindow(overrides: Partial<StageContextWindow> = {}): StageCo
// 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.
// sections 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", "tools", "skills", "mcps"]) {
for (const key of ["todos", "context", "files", "subagents", "tools", "skills", "mcps"]) {
store.set(`fabro:stage-insights-section:${key}`, "1");
}
const stub = {
@ -108,23 +168,34 @@ function render(stage: StageProjection | undefined, contextWindow: StageContextW
}
describe("StageInsightsSidebar", () => {
test("renders todo done/total ratio", () => {
test("renders the root agent's todo list and counts the subagent lists apart", () => {
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" },
],
},
agent: makeAgent({
todos: {
"anthropic_tasks:ses_root": {
kind: TodoListKind.ANTHROPIC_TASKS,
list_id: "anthropic_tasks:ses_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" },
],
},
"openai_plan:ses_child": {
kind: TodoListKind.OPENAI_PLAN,
list_id: "openai_plan:ses_child",
items: [{ id: "c1", status: TodoStatus.PENDING, order: 0, subject: "Child plan step" }],
},
},
}),
});
const dom = render(stage, null);
expect(dom).toContain("2/4");
expect(dom).toContain("Plan refactor");
expect(dom).toContain("Land migration");
expect(dom).not.toContain("Child plan step");
expect(dom).toContain("+1 subagent list");
});
test("renders context window percent and breakdown labels", () => {
@ -147,6 +218,24 @@ describe("StageInsightsSidebar", () => {
expect(dom).not.toContain("31%");
});
test("renders the compactions row under the context window", () => {
const stage = makeStage({
agent: makeAgent({
compactions: [
{
reason: CompactionReason.THRESHOLD,
original_turn_count: 20,
preserved_turn_count: 6,
summary_token_estimate: 500,
tracked_file_count: 3,
},
],
}),
});
const dom = render(stage, makeContextWindow());
expect(dom).toContain("1 compaction · last kept 6 of 20 turns");
});
test("renders projected agent tool names and invoked state", () => {
const dom = render(
makeStage({
@ -179,29 +268,16 @@ describe("StageInsightsSidebar", () => {
expect(dom).toContain("Used");
});
test("renders mcp server used/total count, marks invoked servers as 'used'", () => {
test("derives mcp server status from the fold and marks invoked servers as 'used'", () => {
const dom = render(
makeStage({
mcp_servers: [
{
server_name: "context7",
tool_count: 12,
status: { kind: "ready", tools: [] },
invoked: true,
agent: makeAgent({
mcp_servers: {
context7: { tools: mcpTools(12), error: null, invoked: true },
filesystem: { tools: mcpTools(3), error: null, invoked: false },
atlassian: { tools: [], error: "auth failed", invoked: false },
},
{
server_name: "filesystem",
tool_count: 3,
status: { kind: "ready", tools: [] },
invoked: false,
},
{
server_name: "atlassian",
tool_count: 0,
status: { kind: "failed", error: "auth failed" },
invoked: false,
},
],
}),
}),
null,
);
@ -213,19 +289,17 @@ describe("StageInsightsSidebar", () => {
expect(dom).toContain("3 tools");
expect(dom).toContain("atlassian");
expect(dom).toContain("Failed");
expect(dom).toContain("auth failed");
});
test("renders a disconnected mcp server as disconnected, still counted as used", () => {
const dom = render(
makeStage({
mcp_servers: [
{
server_name: "github",
tool_count: 4,
status: { kind: "disconnected", error: "transport closed" },
invoked: true,
agent: makeAgent({
mcp_servers: {
github: { tools: mcpTools(4), error: null, invoked: true, disconnected: "transport closed" },
},
],
}),
}),
null,
);
@ -238,18 +312,20 @@ describe("StageInsightsSidebar", () => {
test("shows skill activated/available ratio with source label", () => {
const dom = render(
makeStage({
skills: {
activated: [
{ name: "frontend-design", source: SkillActivationSource.SLASH },
{ name: "debug", source: SkillActivationSource.TOOL },
],
available: [
{ name: "frontend-design", description: "" },
{ name: "debug", description: "" },
{ name: "tdd", description: "" },
{ name: "ce-review", description: "" },
],
},
agent: makeAgent({
skills: {
activated: [
{ name: "frontend-design", source: SkillActivationSource.SLASH },
{ name: "debug", source: SkillActivationSource.TOOL },
],
available: [
{ name: "frontend-design", description: "" },
{ name: "debug", description: "" },
{ name: "tdd", description: "" },
{ name: "ce-review", description: "" },
],
},
}),
}),
null,
);
@ -259,9 +335,83 @@ describe("StageInsightsSidebar", () => {
expect(dom).toContain("+2 more available");
});
test("lists the files the session tree wrote and marks the last one", () => {
const dom = render(
makeStage({
agent: makeAgent({
files_touched: ["/workspace/src/lib.rs", "/workspace/src/main.rs"],
last_file_touched: "/workspace/src/main.rs",
}),
}),
null,
);
expect(dom).toContain("src/lib.rs");
expect(dom).toContain("src/main.rs");
expect(dom).toContain("last");
// Full paths stay in the tooltip.
expect(dom).toContain("/workspace/src/lib.rs");
});
test("renders subagents with their status", () => {
const dom = render(
makeStage({
agent: makeAgent({
subagents: [
{ agent_id: "sub-1", depth: 1, task: "Review the module", status: { status: "completed", success: true, turns_used: 3 } },
{ agent_id: "sub-2", depth: 1, task: "Check the tests", status: { status: "failed", error: agentError("boom") } },
{ agent_id: "sub-3", depth: 1, task: "Still looking", status: { status: "running" } },
],
}),
}),
null,
);
expect(dom).toContain("2/3");
expect(dom).toContain("Review the module");
expect(dom).toContain("3 turns");
expect(dom).toContain("Check the tests");
expect(dom).toContain("Failed");
expect(dom).toContain("boom");
expect(dom).toContain("Still looking");
expect(dom).toContain("running");
});
test("shows a failover badge with the route the session moved to and why it stopped", () => {
const dom = render(
makeStage({
agent: makeAgent({
route: { provider: "openai", model: "gpt-5.4" },
failovers: [
{
from: "anthropic/claude-opus-4-7",
to: "openai/gpt-5.4",
attempt: 1,
error: agentError("rate limited"),
usage: NO_TOKENS,
inference_ms: 120,
tool_ms: 30,
continuation: FailoverContinuation.CONTINUE_TURN,
},
],
failover_stopped: {
route: "openai/gpt-5.4",
attempt: 1,
reason: FailoverStop.EXHAUSTED,
error: agentError("key revoked"),
},
}),
}),
null,
);
expect(dom).toContain("Moved to openai/gpt-5.4 after 1 attempt");
expect(dom).toContain("Stopped: routes exhausted");
expect(dom).toContain("rate limited");
expect(dom).toContain("key revoked");
});
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("Agent");
expect(dom).not.toContain("Moved to");
});
});

View file

@ -11,24 +11,33 @@ import {
XCircleIcon,
} from "@heroicons/react/24/solid";
import {
ArrowsRightLeftIcon,
CheckBadgeIcon,
CommandLineIcon,
DocumentTextIcon,
ListBulletIcon,
PuzzlePieceIcon,
ServerStackIcon,
Squares2X2Icon,
UserGroupIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
import {
ContextWindowCategory,
ContextWindowStaleness,
FailoverStop,
SkillActivationSource,
TodoStatus,
} from "@qltysh/fabro-api-client";
import type {
ActivatedSkill,
AgentSessionActivatedSkill,
AgentSessionCompaction,
AgentSessionFailoverStop,
AgentSessionMcpServer,
AgentSessionProjection,
AgentSessionRouteFailover,
AgentSessionSubagent,
ContextWindowBreakdownItem,
McpServerProjection,
SkillSummary,
StageContextWindow,
StageProjection,
@ -41,14 +50,16 @@ 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" | "tools" | "skills" | "mcps";
type SectionKey = "todos" | "context" | "files" | "subagents" | "tools" | "skills" | "mcps";
const SECTIONS_DEFAULT_OPEN: Record<SectionKey, boolean> = {
todos: true,
context: false,
tools: false,
skills: false,
mcps: false,
todos: true,
context: false,
files: false,
subagents: false,
tools: false,
skills: false,
mcps: false,
};
export interface StageInsightsSidebarProps {
@ -58,6 +69,11 @@ export interface StageInsightsSidebarProps {
contextWindow: StageContextWindow | null | undefined;
}
/**
* The agent stage's sidebar. Everything about the agent's session comes from
* `stage.agent`, the coding agent's own fold of the stage's events; the
* stage itself contributes the tool catalog it was handed.
*/
export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSidebarProps) {
const [collapsed, setCollapsed] = useState(loadStoredCollapsed);
const toggleCollapsed = useCallback(() => {
@ -68,14 +84,24 @@ export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSide
});
}, []);
const todos = stage?.todos ?? null;
const skills = stage?.skills ?? { activated: [], available: [] };
const agent = stage?.agent ?? null;
const todoLists = agent ? Object.values(agent.todos) : [];
const todos = rootTodoList(agent);
const otherTodoLists = todos ? todoLists.length - 1 : todoLists.length;
const skills = agent?.skills ?? { activated: [], available: [] };
const agentTools = stage?.agent_tools ?? [];
const mcpServers = stage?.mcp_servers ?? [];
const mcpServers = agent ? mcpServerRows(agent.mcp_servers) : [];
const files = agent?.files_touched ?? [];
const lastFile = agent?.last_file_touched ?? null;
const subagents = agent?.subagents ?? [];
const failovers = agent?.failovers ?? [];
const failoverStopped = agent?.failover_stopped ?? null;
const compactions = agent?.compactions ?? [];
const todoStats = countTodoStats(todos);
const activatedSkillNames = new Set(skills.activated.map((s) => s.name));
const invokedToolCount = agentTools.filter((tool) => tool.invoked).length;
const finishedSubagents = subagents.filter((s) => s.status.status !== "running").length;
return (
<aside
@ -104,6 +130,8 @@ export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSide
</button>
</div>
<FailoverBadge collapsed={collapsed} failovers={failovers} stopped={failoverStopped} />
<div className="mt-3 flex flex-col gap-4">
{todoStats.total > 0 && (
<CollapsibleSection
@ -114,11 +142,39 @@ export function StageInsightsSidebar({ stage, contextWindow }: StageInsightsSide
count={`${todoStats.done}/${todoStats.total}`}
empty={false}
>
<TodoSection todos={todos} />
<TodoSection todos={todos} otherLists={otherTodoLists} />
</CollapsibleSection>
)}
<ContextWindowSection collapsed={collapsed} snapshot={contextWindow ?? null} />
<ContextWindowSection
collapsed={collapsed}
snapshot={contextWindow ?? null}
compactions={compactions}
/>
<CollapsibleSection
sectionKey="files"
title="Files"
icon={DocumentTextIcon}
collapsed={collapsed}
count={files.length}
empty={files.length === 0}
hideCountWhenCollapsed
>
<FilesSection files={files} lastFile={lastFile} />
</CollapsibleSection>
<CollapsibleSection
sectionKey="subagents"
title="Subagents"
icon={UserGroupIcon}
collapsed={collapsed}
count={`${finishedSubagents}/${subagents.length}`}
empty={subagents.length === 0}
hideCountWhenCollapsed
>
<SubagentsSection subagents={subagents} />
</CollapsibleSection>
<CollapsibleSection
sectionKey="skills"
@ -228,6 +284,52 @@ function CollapsibleSection({
);
}
// ---------- Failover ----------
interface FailoverBadgeProps {
collapsed: boolean;
failovers: AgentSessionRouteFailover[];
stopped: AgentSessionFailoverStop | null;
}
/**
* Where the session's model moved: the last fallback route the root took,
* and whether the prompt then stopped although routes were named. The
* failures that caused each are the hover text.
*/
function FailoverBadge({ collapsed, failovers, stopped }: FailoverBadgeProps) {
const last = failovers.length > 0 ? failovers[failovers.length - 1] : null;
if (!last && !stopped) return null;
const details = [
...failovers.map((move) => `${move.from} failed: ${move.error.message}`),
stopped ? `${stopped.route}: ${stopped.error.message}` : null,
].filter((line): line is string => line != null);
if (collapsed) {
return (
<div className="mt-2 flex justify-center" title={details.join("\n")}>
<ArrowsRightLeftIcon className="size-4 shrink-0 text-amber" aria-label="Model failover" />
</div>
);
}
return (
<div className="mt-2 space-y-0.5 px-2 text-[11px] text-amber" title={details.join("\n")}>
{last && (
<p className="flex items-center gap-1">
<ArrowsRightLeftIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">
{`Moved to ${last.to} after ${last.attempt} ${last.attempt === 1 ? "attempt" : "attempts"}`}
</span>
</p>
)}
{stopped && (
<p className="pl-4.5">
{`Stopped: ${stopped.reason === FailoverStop.EXHAUSTED ? "routes exhausted" : "failure not eligible for failover"}`}
</p>
)}
</div>
);
}
// ---------- Todos ----------
interface TodoStats {
@ -235,6 +337,18 @@ interface TodoStats {
total: number;
}
/**
* The root agent's own list: the one keyed by the root session's id. Subagent
* plans are separate lists in the same map.
*/
function rootTodoList(agent: AgentSessionProjection | null): TodoListProjection | null {
if (!agent) return null;
const lists = Object.values(agent.todos);
const rootId = agent.root_session_id;
const root = rootId ? lists.find((list) => list.list_id.endsWith(rootId)) : undefined;
return root ?? (lists.length === 1 ? lists[0] : null);
}
function countTodoStats(list: TodoListProjection | null): TodoStats {
const items = list?.items ?? [];
let done = 0;
@ -244,16 +358,23 @@ function countTodoStats(list: TodoListProjection | null): TodoStats {
return { done, total: items.length };
}
function TodoSection({ todos }: { todos: TodoListProjection | null }) {
function TodoSection({ todos, otherLists }: { todos: TodoListProjection | null; otherLists: number }) {
if (!todos || (todos.items?.length ?? 0) === 0) return <p className="text-xs text-fg-muted">No todos.</p>;
const items = Array.from(todos.items ?? []);
items.sort((a, b) => a.order - b.order);
return (
<ul className="space-y-1">
{items.map((item) => (
<TodoRow key={item.id} todo={item} />
))}
</ul>
<div className="space-y-2">
<ul className="space-y-1">
{items.map((item) => (
<TodoRow key={item.id} todo={item} />
))}
</ul>
{otherLists > 0 && (
<p className="text-[11px] text-fg-muted">
{`+${otherLists} subagent ${otherLists === 1 ? "list" : "lists"}`}
</p>
)}
</div>
);
}
@ -297,9 +418,10 @@ function EmptyCircleIcon({ className }: { className?: string }) {
interface ContextWindowSectionProps {
collapsed: boolean;
snapshot: StageContextWindow | null;
compactions: AgentSessionCompaction[];
}
function ContextWindowSection({ collapsed, snapshot }: ContextWindowSectionProps) {
function ContextWindowSection({ collapsed, snapshot, compactions }: ContextWindowSectionProps) {
const [open, setOpen] = useState(() => loadStoredSectionOpen("context"));
const toggle = useCallback(() => {
setOpen((prev) => {
@ -343,12 +465,25 @@ function ContextWindowSection({ collapsed, snapshot }: ContextWindowSectionProps
<ContextBar snapshot={snapshot} />
</div>
<ContextBreakdown snapshot={snapshot} />
<CompactionsRow compactions={compactions} />
</>
)}
</div>
);
}
/** How often the conversation was compacted, and what the last one kept. */
function CompactionsRow({ compactions }: { compactions: AgentSessionCompaction[] }) {
if (compactions.length === 0) return null;
const last = compactions[compactions.length - 1];
const noun = compactions.length === 1 ? "compaction" : "compactions";
return (
<p className="mt-2 px-2 text-[11px] text-fg-muted" title={`Last compaction: ${last.reason}`}>
{`${compactions.length} ${noun} · last kept ${last.preserved_turn_count} of ${last.original_turn_count} turns`}
</p>
);
}
function ContextBar({ snapshot }: { snapshot: StageContextWindow | null }) {
if (!snapshot || snapshot.usage_percent == null) {
return (
@ -484,10 +619,102 @@ function categoryLabel(category: ContextWindowCategory): string {
}
}
// ---------- Files ----------
/** Files the stage's agent and its subagents wrote or edited, sorted. */
function FilesSection({ files, lastFile }: { files: string[]; lastFile: string | null }) {
if (files.length === 0) return <p className="text-xs text-fg-muted">No files written.</p>;
return (
<ul className="space-y-1">
{files.map((path) => {
const isLast = path === lastFile;
return (
<li key={path} title={path} className="flex items-center gap-1.5">
<DocumentTextIcon className="size-3.5 shrink-0 text-fg-muted" aria-hidden="true" />
<span className={`min-w-0 flex-1 truncate font-mono text-[11px] ${isLast ? "text-fg-2" : "text-fg-3"}`}>
{fileLabel(path)}
</span>
{isLast && (
<span className="text-[10px] uppercase tracking-wider text-fg-muted">last</span>
)}
</li>
);
})}
</ul>
);
}
/** The path from its last two segments, so a deep tree still reads. */
function fileLabel(path: string): string {
const segments = path.split("/").filter((segment) => segment.length > 0);
return segments.length <= 2 ? path : segments.slice(-2).join("/");
}
// ---------- Subagents ----------
function SubagentsSection({ subagents }: { subagents: AgentSessionSubagent[] }) {
if (subagents.length === 0) return <p className="text-xs text-fg-muted">No subagents.</p>;
return (
<ul className="space-y-1">
{subagents.map((subagent) => (
<SubagentRow key={subagent.agent_id} subagent={subagent} />
))}
</ul>
);
}
function SubagentRow({ subagent }: { subagent: AgentSessionSubagent }) {
const { status } = subagent;
const title = status.status === "failed" ? `${subagent.task}\n${status.error.message}` : subagent.task;
return (
<li className="flex items-center gap-1.5" title={title}>
<SubagentStatusIcon subagent={subagent} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-2">{subagent.task}</span>
<SubagentStatusBadge subagent={subagent} />
</li>
);
}
function SubagentStatusIcon({ subagent }: { subagent: AgentSessionSubagent }) {
const { status } = subagent;
switch (status.status) {
case "running":
return <ArrowPathIcon className="size-3.5 shrink-0 animate-spin text-teal-500" aria-label="Running" />;
case "completed":
return status.success ? (
<CheckCircleIcon className="size-3.5 shrink-0 text-mint" aria-label="Completed" />
) : (
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-amber" aria-label="Completed without success" />
);
case "failed":
return <XCircleIcon className="size-3.5 shrink-0 text-coral" aria-label="Failed" />;
case "closed":
return <EmptyCircleIcon className="size-3.5 shrink-0 text-fg-muted" aria-label="Closed" />;
}
}
function SubagentStatusBadge({ subagent }: { subagent: AgentSessionSubagent }) {
const { status } = subagent;
switch (status.status) {
case "running":
return <span className="text-[10px] uppercase tracking-wider text-teal-500">running</span>;
case "completed":
return (
<span className="font-mono text-[10px] tabular-nums text-fg-muted">
{`${status.turns_used} ${status.turns_used === 1 ? "turn" : "turns"}`}
</span>
);
case "failed":
return <span className="text-[10px] uppercase tracking-wider text-coral">Failed</span>;
case "closed":
return <span className="text-[10px] uppercase tracking-wider text-fg-muted">closed</span>;
}
}
// ---------- Skills ----------
interface SkillsSectionProps {
activated: ActivatedSkill[];
activated: AgentSessionActivatedSkill[];
available: SkillSummary[];
activatedNames: Set<string>;
}
@ -517,7 +744,7 @@ function SkillsSection({ activated, available, activatedNames }: SkillsSectionPr
);
}
function SkillSourceIcon({ source }: { source: ActivatedSkill["source"] }) {
function SkillSourceIcon({ source }: { source: AgentSessionActivatedSkill["source"] }) {
const Icon = source === SkillActivationSource.SLASH ? CommandLineIcon : PuzzlePieceIcon;
return <Icon className="size-3.5 shrink-0 text-fg-muted" />;
}
@ -549,20 +776,49 @@ function AgentToolsSection({ tools }: { tools: ToolSummary[] }) {
// ---------- MCPs ----------
function McpSection({ servers }: { servers: McpServerProjection[] }) {
type McpStatus = "ready" | "failed" | "disconnected";
interface McpServerRow {
name: string;
status: McpStatus;
/** Why it failed, or what closed its connection. */
error: string | null;
toolCount: number;
invoked: boolean;
}
/**
* The agent's MCP servers as rows. A closed connection outranks a failed
* start, which outranks ready; the tool count is the tools it advertised.
*/
function mcpServerRows(servers: Record<string, AgentSessionMcpServer>): McpServerRow[] {
return Object.entries(servers).map(([name, server]) => {
const status: McpStatus =
server.disconnected != null ? "disconnected" : server.error != null ? "failed" : "ready";
return {
name,
status,
error: server.disconnected ?? server.error ?? null,
toolCount: server.tools.length,
invoked: server.invoked,
};
});
}
function McpSection({ servers }: { servers: McpServerRow[] }) {
if (servers.length === 0) return <p className="text-xs text-fg-muted">No MCP servers.</p>;
return (
<ul className="space-y-1">
{servers.map((server) => {
// Dim unused servers so the eye lands on the invoked ones first;
// failed and disconnected servers keep their tone regardless.
const nameClass = server.status.kind === "ready" && !server.invoked
const nameClass = server.status === "ready" && !server.invoked
? "min-w-0 flex-1 truncate text-xs text-fg-muted"
: "min-w-0 flex-1 truncate text-xs text-fg-2";
return (
<li key={server.server_name} className="flex items-center gap-1.5">
<li key={server.name} className="flex items-center gap-1.5" title={server.error ?? undefined}>
<McpStatusIcon status={server.status} />
<span className={nameClass}>{server.server_name}</span>
<span className={nameClass}>{server.name}</span>
<McpStatusBadge server={server} />
</li>
);
@ -571,8 +827,8 @@ function McpSection({ servers }: { servers: McpServerProjection[] }) {
);
}
function McpStatusIcon({ status }: { status: McpServerProjection["status"] }) {
switch (status.kind) {
function McpStatusIcon({ status }: { status: McpStatus }) {
switch (status) {
case "ready":
return <CheckCircleIcon className="size-3.5 shrink-0 text-mint" aria-label="Ready" />;
case "disconnected":
@ -587,14 +843,14 @@ function McpStatusIcon({ status }: { status: McpServerProjection["status"] }) {
}
}
function McpStatusBadge({ server }: { server: McpServerProjection }) {
switch (server.status.kind) {
function McpStatusBadge({ server }: { server: McpServerRow }) {
switch (server.status) {
case "ready":
return (
<span className="font-mono text-[10px] tabular-nums text-fg-muted">
{server.invoked
? "used"
: `${server.tool_count} ${server.tool_count === 1 ? "tool" : "tools"}`}
: `${server.toolCount} ${server.toolCount === 1 ? "tool" : "tools"}`}
</span>
);
case "disconnected":

View file

@ -514,3 +514,27 @@ async function waitFor(condition: () => boolean, timeoutMs = 200) {
}
throw new Error("condition did not become true before timeout");
}
describe("agent session events", () => {
test("refresh the run state the stage sidebar reads its agent facts from", () => {
for (const event of [
"agent.route.failover",
"agent.route.failover.stopped",
"agent.mcp.server.ready",
"agent.mcp.server.failed",
"agent.mcp.server.disconnected",
"agent.skills.discovered",
"agent.skill.activated",
"agent.sub.spawned",
"agent.sub.completed",
"agent.sub.failed",
"agent.compaction.completed",
]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
});
});

View file

@ -152,6 +152,26 @@ const TODO_EVENTS = new Set([
"todo.updated",
"todo.deleted",
]);
// The coding agent's own events whose facts the stage sidebar reads from
// `StageProjection.agent`: route moves, MCP server outcomes, skills,
// subagent lifecycle, and compactions. Each refreshes the run state the
// sidebar renders from, like a todo mutation does. Files the tree wrote
// arrive with `agent.tool.completed`, which already refreshes it.
const AGENT_SESSION_EVENTS = new Set([
"agent.route.failover",
"agent.route.failover.stopped",
"agent.mcp.server.ready",
"agent.mcp.server.failed",
"agent.mcp.server.disconnected",
"agent.skills.discovered",
"agent.skill.activated",
"agent.sub.spawned",
"agent.sub.turn.started",
"agent.sub.completed",
"agent.sub.failed",
"agent.sub.closed",
"agent.compaction.completed",
]);
function liveTimingKeys(runId: string): Key[] {
return [
@ -288,7 +308,7 @@ export function queryKeysForRunEvent(
: [];
}
if (TODO_EVENTS.has(event)) {
if (TODO_EVENTS.has(event) || AGENT_SESSION_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.state(runId),
queryKeys.runs.events(runId, 1000),