mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
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:
parent
b5ee16b37a
commit
232d347d39
4 changed files with 534 additions and 84 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue