;
+ toolCalls: ToolCall[];
+}
+
+const eyebrow: CSSProperties = {
+ display: 'inline-block',
+ padding: '2px 6px',
+ background: 'var(--acc-soft)',
+ color: 'var(--acc)',
+ fontFamily: 'var(--ff-mono)',
+ fontSize: 9,
+ letterSpacing: '0.14em',
+ textTransform: 'uppercase',
+ marginBottom: 8,
+};
+
+const kv: CSSProperties = {
+ display: 'flex',
+ gap: 6,
+ fontFamily: 'var(--ff-mono)',
+ fontSize: 11,
+ color: 'var(--ink-3)',
+ marginBottom: 4,
+};
+
+const kvKey: CSSProperties = { width: 80, color: 'var(--ink-3)' };
+const kvVal: CSSProperties = { flex: 1, color: 'var(--ink-1)' };
+
+function Empty(): ReactNode {
+ return (
+
+ Click an agent, tool, or message to see detail.
+
+ );
+}
+
+function NotFound(): ReactNode {
+ return (
+
+ Selected item not found in current session.
+
+ );
+}
+
+function AgentBody({ agent }: { agent: AgentDefinition }): ReactNode {
+ return (
+ <>
+ agent · {agent.name}
+
+ Kind
+ {agent.kind}
+
+
+ Model
+ {agent.model}
+
+
+ Tools
+ {agent.tools.join(', ') || '—'}
+
+ {Object.keys(agent.routes).length > 0 && (
+
+ Routes
+
+ {Object.entries(agent.routes)
+ .map(([k, v]) => `${k} → ${v}`)
+ .join(' · ')}
+
+
+ )}
+ {agent.system_prompt_excerpt && (
+
+ {agent.system_prompt_excerpt}
+
+ )}
+ >
+ );
+}
+
+function ToolCallBody({ tc }: { tc: ToolCall }): ReactNode {
+ return (
+ <>
+ tool · {tc.tool}
+
+ Agent
+ {tc.agent}
+
+
+ Status
+ {tc.status}
+
+
+ Risk
+ {tc.risk ?? '—'}
+
+
+ Args
+
+
+ {JSON.stringify(tc.args, null, 2)}
+
+ >
+ );
+}
+
+function MessageBody({ id }: { id: string | undefined }): ReactNode {
+ return (
+ <>
+ message · {id ?? '—'}
+
+ Message · {id ?? '—'}
+
+ >
+ );
+}
+
+export function SelectedPanel({ agentsByName, toolCalls }: SelectedPanelProps) {
+ const selected = useSelected();
+ let body: ReactNode;
+ if (selected.kind === null) {
+ body = ;
+ } else if (selected.kind === 'agent') {
+ const a = agentsByName[selected.id ?? ''];
+ body = a ? : ;
+ } else if (selected.kind === 'tool_call') {
+ const found = toolCalls.find((t) => `${t.tool}@${t.ts}` === selected.id);
+ body = found ? : ;
+ } else {
+ body = ;
+ }
+ return (
+
+ {body}
+
+ );
+}
diff --git a/web/src/monitors/ToolsPanel.tsx b/web/src/monitors/ToolsPanel.tsx
new file mode 100644
index 0000000..1dbcb1e
--- /dev/null
+++ b/web/src/monitors/ToolsPanel.tsx
@@ -0,0 +1,50 @@
+import { useQuery } from '@tanstack/react-query';
+import { apiFetch } from '@/api/client';
+import { Monitor } from './Monitor';
+
+interface Tool {
+ name: string;
+ description?: string;
+ risk?: 'low' | 'medium' | 'high';
+}
+
+const riskColor: Record = {
+ low: 'var(--good)',
+ medium: 'var(--warn)',
+ high: 'var(--danger)',
+};
+
+export function ToolsPanel() {
+ const { data } = useQuery({
+ queryKey: ['tools'],
+ queryFn: () => apiFetch('/tools'),
+ staleTime: Infinity,
+ gcTime: Infinity,
+ });
+
+ return (
+
+ {!data || data.length === 0 ? (
+ No tools registered.
+ ) : (
+ data.map((t) => (
+
+
+ {t.name}
+ {t.risk && (
+
+ {t.risk.toUpperCase()}
+
+ )}
+
+ {t.description && (
+
+ {t.description}
+
+ )}
+
+ ))
+ )}
+
+ );
+}
diff --git a/web/tests/component/ApprovalsQueuePanel.test.tsx b/web/tests/component/ApprovalsQueuePanel.test.tsx
new file mode 100644
index 0000000..0910558
--- /dev/null
+++ b/web/tests/component/ApprovalsQueuePanel.test.tsx
@@ -0,0 +1,35 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '../_helpers/render';
+import { ApprovalsQueuePanel } from '@/monitors/ApprovalsQueuePanel';
+import type { SessionSummary } from '@/state/useSessionList';
+
+const queue: SessionSummary[] = [
+ { id: 'SES-3', status: 'awaiting_input', label: 'oldest', created_at: 't0', updated_at: '2026-05-15T14:00:00Z' },
+ { id: 'SES-2', status: 'awaiting_input', label: 'newer', created_at: 't0', updated_at: '2026-05-15T14:05:00Z' },
+];
+
+describe('', () => {
+ it('renders the count in header', () => {
+ render( {}} />);
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('renders each queue item with id + label', () => {
+ render( {}} />);
+ expect(screen.getByText('SES-3')).toBeInTheDocument();
+ expect(screen.getByText('oldest')).toBeInTheDocument();
+ expect(screen.getByText('SES-2')).toBeInTheDocument();
+ });
+
+ it('shows empty state when queue=[]', () => {
+ render( {}} />);
+ expect(screen.getByText(/No approvals waiting/i)).toBeInTheDocument();
+ });
+
+ it('calls onSelect with sid on row click', () => {
+ const onSelect = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('SES-3').closest('[data-row]')!);
+ expect(onSelect).toHaveBeenCalledWith('SES-3');
+ });
+});
diff --git a/web/tests/component/HealthPanel.test.tsx b/web/tests/component/HealthPanel.test.tsx
new file mode 100644
index 0000000..8f4d8ae
--- /dev/null
+++ b/web/tests/component/HealthPanel.test.tsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '../_helpers/render';
+import { HealthPanel } from '@/monitors/HealthPanel';
+
+describe('', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ status: 'ok', uptime_seconds: 3600 }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ });
+
+ it('renders header', () => {
+ render();
+ expect(screen.getByText(/System Health/i)).toBeInTheDocument();
+ });
+
+ it('shows status label after fetch when expanded', async () => {
+ render();
+ screen.getByText(/System Health/i).click();
+ await waitFor(() => expect(screen.getByText(/ok/i)).toBeInTheDocument());
+ });
+
+ it('hits /health (NOT /api/v1/health)', async () => {
+ render();
+ screen.getByText(/System Health/i).click();
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled());
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/health',
+ expect.objectContaining({ method: 'GET' }),
+ );
+ });
+
+ it('shows degraded color when status=degraded', async () => {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ status: 'degraded' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ const { container } = render();
+ screen.getByText(/System Health/i).click();
+ await waitFor(() => expect(screen.getByText(/degraded/i)).toBeInTheDocument());
+ expect(container.querySelector('[data-health-dot]')).toHaveAttribute('data-status', 'degraded');
+ });
+});
diff --git a/web/tests/component/LessonsPanel.test.tsx b/web/tests/component/LessonsPanel.test.tsx
new file mode 100644
index 0000000..599fc8e
--- /dev/null
+++ b/web/tests/component/LessonsPanel.test.tsx
@@ -0,0 +1,55 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '../_helpers/render';
+import { LessonsPanel } from '@/monitors/LessonsPanel';
+
+const lessons = [
+ {
+ id: 'L1',
+ title: 'Always check deploy diff',
+ summary: 'When p99 spikes within 10m of deploy, the deploy is the prime suspect.',
+ agent: 'triage',
+ },
+ {
+ id: 'L2',
+ title: 'Restart only with rationale',
+ summary: 'Restart requires documented rationale per policy.',
+ agent: 'investigate',
+ },
+];
+
+describe('', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(lessons), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ });
+
+ it('renders nothing in the body when sessionId is null', () => {
+ render();
+ // header always renders
+ expect(screen.getByText(/Lessons/i)).toBeInTheDocument();
+ });
+
+ it('fetches lessons and renders titles', async () => {
+ render();
+ // header is collapsed by default — click to expand
+ const header = screen.getByText(/Lessons/i);
+ header.click();
+ await waitFor(() => expect(screen.getByText('Always check deploy diff')).toBeInTheDocument());
+ expect(screen.getByText('Restart only with rationale')).toBeInTheDocument();
+ });
+
+ it('hits /api/v1/sessions/{sid}/lessons', async () => {
+ render();
+ // expand
+ screen.getByText(/Lessons/i).click();
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled());
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/api/v1/sessions/SES-42/lessons',
+ expect.objectContaining({ method: 'GET' }),
+ );
+ });
+});
diff --git a/web/tests/component/OtherSessionsPanel.test.tsx b/web/tests/component/OtherSessionsPanel.test.tsx
new file mode 100644
index 0000000..7b677cf
--- /dev/null
+++ b/web/tests/component/OtherSessionsPanel.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '../_helpers/render';
+import { OtherSessionsPanel } from '@/monitors/OtherSessionsPanel';
+import type { SessionSummary } from '@/state/useSessionList';
+
+const sessions: SessionSummary[] = [
+ { id: 'SES-1', status: 'in_progress', label: 'A', created_at: 't0', updated_at: 't1', active_agent: 'triage' },
+ { id: 'SES-2', status: 'awaiting_input', label: 'B paused', created_at: 't0', updated_at: 't2' },
+ { id: 'SES-3', status: 'resolved', label: 'C done', created_at: 't0', updated_at: 't3' },
+];
+
+describe('', () => {
+ it('excludes the active session from the list', () => {
+ render( {}} />);
+ expect(screen.queryByText('SES-2')).not.toBeInTheDocument();
+ expect(screen.getByText('SES-1')).toBeInTheDocument();
+ expect(screen.getByText('SES-3')).toBeInTheDocument();
+ });
+
+ it('renders all sessions when activeSid is null', () => {
+ render( {}} />);
+ expect(screen.getByText('SES-1')).toBeInTheDocument();
+ expect(screen.getByText('SES-2')).toBeInTheDocument();
+ expect(screen.getByText('SES-3')).toBeInTheDocument();
+ });
+
+ it('shows count in the header', () => {
+ render( {}} />);
+ // count = 2 (excluding active)
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('shows label and active agent', () => {
+ render( {}} />);
+ expect(screen.getByText('A')).toBeInTheDocument();
+ expect(screen.getByText(/triage/)).toBeInTheDocument();
+ });
+
+ it('shows empty message when no other sessions', () => {
+ render( {}} />);
+ expect(screen.getByText(/Only this session is active/i)).toBeInTheDocument();
+ });
+
+ it('calls onSelect on tile click', () => {
+ const onSelect = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('SES-1').closest('[data-tile]')!);
+ expect(onSelect).toHaveBeenCalledWith('SES-1');
+ });
+});
diff --git a/web/tests/component/SelectedPanel.test.tsx b/web/tests/component/SelectedPanel.test.tsx
new file mode 100644
index 0000000..9aa63e1
--- /dev/null
+++ b/web/tests/component/SelectedPanel.test.tsx
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { useEffect } from 'react';
+import { render, screen } from '../_helpers/render';
+import { SelectedPanel } from '@/monitors/SelectedPanel';
+import { SelectedRefProvider, useSetSelected } from '@/state/selectedRef';
+import type { AgentDefinition, ToolCall } from '@/api/types';
+
+const agents: Record = {
+ intake: {
+ name: 'intake',
+ kind: 'responsive',
+ model: 'gpt',
+ tools: ['obs:get_logs'],
+ routes: { success: 'triage' },
+ system_prompt_excerpt: 'You triage incidents…',
+ },
+};
+
+const tc: ToolCall = {
+ agent: 'triage',
+ tool: 'obs:get_logs',
+ args: { service: 'api' },
+ result: 'ok',
+ ts: '2026-05-15T14:16:50Z',
+ risk: 'low',
+ status: 'executed',
+ approver: null,
+ approved_at: null,
+ approval_rationale: null,
+};
+
+interface SetterRef {
+ kind: 'agent' | 'tool_call' | 'message' | null;
+ id?: string;
+}
+
+function Setter({ ref }: { ref: SetterRef }) {
+ const set = useSetSelected();
+ useEffect(() => {
+ set(ref);
+ }, [set, ref]);
+ return null;
+}
+
+describe('', () => {
+ it('renders empty state when nothing selected', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/Click an agent, tool, or message/i)).toBeInTheDocument();
+ });
+
+ it('renders agent details when kind=agent', () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText(/intake/)).toBeInTheDocument();
+ expect(screen.getByText(/responsive/)).toBeInTheDocument();
+ expect(screen.getByText(/gpt/)).toBeInTheDocument();
+ });
+
+ it('renders tool call details when kind=tool_call', () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText(/obs:get_logs/)).toBeInTheDocument();
+ expect(screen.getByText(/low/i)).toBeInTheDocument();
+ });
+
+ it('falls back to "not found" when selected id has no match', () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText(/not found/i)).toBeInTheDocument();
+ });
+});
diff --git a/web/tests/component/ToolsPanel.test.tsx b/web/tests/component/ToolsPanel.test.tsx
new file mode 100644
index 0000000..9e2ebfb
--- /dev/null
+++ b/web/tests/component/ToolsPanel.test.tsx
@@ -0,0 +1,38 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '../_helpers/render';
+import { ToolsPanel } from '@/monitors/ToolsPanel';
+
+const tools = [
+ { name: 'obs:get_logs', description: 'Fetch service logs', risk: 'low' },
+ { name: 'rem:restart_service', description: 'Restart a service', risk: 'high' },
+];
+
+describe('', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(tools), { status: 200, headers: { 'content-type': 'application/json' } }),
+ );
+ });
+
+ it('renders header always', () => {
+ render();
+ expect(screen.getByText(/Tool Catalog/i)).toBeInTheDocument();
+ });
+
+ it('fetches tools and renders names when expanded', async () => {
+ render();
+ screen.getByText(/Tool Catalog/i).click();
+ await waitFor(() => expect(screen.getByText('obs:get_logs')).toBeInTheDocument());
+ expect(screen.getByText('rem:restart_service')).toBeInTheDocument();
+ });
+
+ it('hits /api/v1/tools', async () => {
+ render();
+ screen.getByText(/Tool Catalog/i).click();
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled());
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/api/v1/tools',
+ expect.objectContaining({ method: 'GET' }),
+ );
+ });
+});