diff --git a/web/src/App.tsx b/web/src/App.tsx
index 8fc845b..1f949c6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -4,6 +4,7 @@ import { Topbar, type Health } from '@/shell/Topbar';
import { Statusbar, type ConnectionState, type VmSeqState } from '@/shell/Statusbar';
import { SessionsRail } from '@/shell/SessionsRail';
import { FlowStrip } from '@/shell/FlowStrip';
+import { SessionCanvas } from '@/canvas/SessionCanvas';
import { useUiHints } from '@/state/useUiHints';
import { useSessionList } from '@/state/useSessionList';
import { useApprovalsQueue } from '@/state/useApprovalsQueue';
@@ -28,15 +29,6 @@ const paneStyle: CSSProperties = {
minHeight: 0,
};
-const canvasEmptyStyle: CSSProperties = {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- background: 'var(--bg-elev)',
- borderRight: '1px solid var(--hair)',
- padding: 32,
-};
-
const monitorRailPlaceholder: CSSProperties = {
background: 'var(--bg-page)',
borderLeft: '1px solid var(--hair)',
@@ -94,11 +86,7 @@ export function App() {
activeSid={activeSid}
onSelect={setActiveSid}
/>
-
-
- Select a session from the rail or create a new one.
-
-
+
Ambient monitors (Phase 5)
diff --git a/web/src/canvas/SessionCanvas.tsx b/web/src/canvas/SessionCanvas.tsx
new file mode 100644
index 0000000..9648a4d
--- /dev/null
+++ b/web/src/canvas/SessionCanvas.tsx
@@ -0,0 +1,137 @@
+import type { CSSProperties } from 'react';
+import { useSessionFull } from '@/state/useSessionFull';
+import { useSetSelected } from '@/state/selectedRef';
+import { CanvasHead } from './CanvasHead';
+import { Transcript } from './Transcript';
+import type { ToolCall } from '@/api/types';
+
+interface SessionCanvasProps {
+ activeSid: string | null;
+}
+
+const wrap: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ background: 'var(--bg-elev)',
+ borderRight: '1px solid var(--hair)',
+ minHeight: 0,
+ overflowY: 'auto',
+};
+
+const center: CSSProperties = {
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 32,
+ fontFamily: 'var(--ff-sans)',
+ fontSize: 13,
+ color: 'var(--ink-3)',
+};
+
+function extraStr(extras: Record, key: string, fallback: string): string {
+ const v = extras[key];
+ return v === undefined || v === null ? fallback : String(v);
+}
+
+function extraNum(extras: Record, key: string, fallback: number): number {
+ const v = extras[key];
+ if (typeof v === 'number') return v;
+ return fallback;
+}
+
+export function SessionCanvas({ activeSid }: SessionCanvasProps) {
+ const { state, isLoading, error, refresh } = useSessionFull(activeSid);
+ const setSelected = useSetSelected();
+
+ if (activeSid === null) {
+ return (
+
+
Select a session from the rail or create a new one.
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ {error.message || 'Error loading session'}
+
+
+
+
+
+ );
+ }
+
+ const sess = state.session;
+ if (!sess) {
+ return ;
+ }
+
+ const findings = sess.findings as Record;
+ const title = (findings.title as string | undefined) ?? sess.id;
+ const env = extraStr(sess.extra_fields, 'env', 'dev');
+ const sev = extraNum(sess.extra_fields, 'sev', 0);
+ const reporter = extraStr(sess.extra_fields, 'reporter', '—');
+
+ const onSelectTool = (tc: ToolCall) => {
+ setSelected({ kind: 'tool_call', id: `${tc.tool}@${tc.ts}` });
+ };
+
+ return (
+
+ { /* Phase 6: confirm modal */ }}
+ onRetry={refresh}
+ />
+ { /* Phase 6 */ }}
+ onReject={() => { /* Phase 6 */ }}
+ onApproveWithRationale={() => { /* Phase 6 */ }}
+ />
+
+ );
+}
diff --git a/web/tests/_helpers/render.tsx b/web/tests/_helpers/render.tsx
index 7a64a8e..d8a6e23 100644
--- a/web/tests/_helpers/render.tsx
+++ b/web/tests/_helpers/render.tsx
@@ -2,13 +2,16 @@
import type { ReactElement } from 'react';
import { render as rtlRender, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SelectedRefProvider } from '@/state/selectedRef';
export function render(ui: ReactElement, options?: RenderOptions) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return rtlRender(
- {ui},
+
+ {ui}
+ ,
options,
);
}
diff --git a/web/tests/component/SessionCanvas.test.tsx b/web/tests/component/SessionCanvas.test.tsx
new file mode 100644
index 0000000..bfb8082
--- /dev/null
+++ b/web/tests/component/SessionCanvas.test.tsx
@@ -0,0 +1,70 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '../_helpers/render';
+import { SessionCanvas } from '@/canvas/SessionCanvas';
+
+const fullBundle = {
+ session: {
+ id: 'SES-1', status: 'in_progress',
+ created_at: '2026-05-15T14:16:30Z',
+ updated_at: '2026-05-15T14:17:00Z',
+ deleted_at: null,
+ agents_run: [], tool_calls: [], findings: { title: 'Payments latency spike' },
+ pending_intervention: null, user_inputs: [],
+ parent_session_id: null, dedup_rationale: null,
+ extra_fields: { env: 'prod', sev: 2, reporter: 'u1@platform' }, version: 1,
+ },
+ agents_run: [
+ { agent: 'intake', started_at: '2026-05-15T14:16:30Z', ended_at: '2026-05-15T14:16:32Z',
+ summary: 'Triage observed elevated p99 latency.', confidence: 0.92,
+ confidence_rationale: null, signal: null },
+ ],
+ tool_calls: [],
+ events: [],
+ agent_definitions: {},
+ vm_seq: 1,
+};
+
+describe('', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(fullBundle), {
+ status: 200, headers: { 'content-type': 'application/json' },
+ }),
+ );
+ // @ts-expect-error -- jsdom does not provide EventSource
+ global.EventSource = class {
+ constructor(public url: string) {}
+ onopen: ((e: Event) => void) | null = null;
+ onmessage: ((e: MessageEvent) => void) | null = null;
+ onerror: ((e: Event) => void) | null = null;
+ close() {}
+ addEventListener() {}
+ };
+ });
+
+ it('renders empty state when sid is null', () => {
+ render();
+ expect(screen.getByText(/Select a session/i)).toBeInTheDocument();
+ });
+
+ it('shows loading state when fetching', () => {
+ render();
+ expect(screen.getByText(/Loading/i)).toBeInTheDocument();
+ });
+
+ it('renders CanvasHead with session id once loaded', async () => {
+ render();
+ await waitFor(() => expect(screen.getByText(/SES-1/)).toBeInTheDocument());
+ });
+
+ it('renders Transcript with the agent turn once loaded', async () => {
+ render();
+ await waitFor(() => expect(screen.getByText('intake')).toBeInTheDocument());
+ expect(screen.getByText(/Triage observed/)).toBeInTheDocument();
+ });
+
+ it('renders the title from findings.title', async () => {
+ render();
+ await waitFor(() => expect(screen.getByText(/Payments latency spike/)).toBeInTheDocument());
+ });
+});