Skip to content

Commit cede837

Browse files
committed
feat(web): <SessionCanvas> + wire into App — Phase 4 complete
Composes CanvasHead + Transcript over useSessionFull(sid). Handles null sid (empty state), loading, error+retry, and not-found cases. App.tsx now renders <SessionCanvas activeSid={activeSid} /> in place of the empty-state placeholder. The double useSessionFull subscription (App for Statusbar counters, SessionCanvas internally) is acceptable v1 tech debt — Phase 6+ refactor when needed. Test helper render() now wraps with SelectedRefProvider so component tests using useSetSelected (via SessionCanvas → onSelectTool) work without per-test wrappers; matches production layout in main.tsx. Stop/Approve/Reject callbacks are no-ops (// Phase 6). Tests: 125 passing (+5).
1 parent ee4b429 commit cede837

4 files changed

Lines changed: 213 additions & 15 deletions

File tree

web/src/App.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Topbar, type Health } from '@/shell/Topbar';
44
import { Statusbar, type ConnectionState, type VmSeqState } from '@/shell/Statusbar';
55
import { SessionsRail } from '@/shell/SessionsRail';
66
import { FlowStrip } from '@/shell/FlowStrip';
7+
import { SessionCanvas } from '@/canvas/SessionCanvas';
78
import { useUiHints } from '@/state/useUiHints';
89
import { useSessionList } from '@/state/useSessionList';
910
import { useApprovalsQueue } from '@/state/useApprovalsQueue';
@@ -28,15 +29,6 @@ const paneStyle: CSSProperties = {
2829
minHeight: 0,
2930
};
3031

31-
const canvasEmptyStyle: CSSProperties = {
32-
display: 'flex',
33-
alignItems: 'center',
34-
justifyContent: 'center',
35-
background: 'var(--bg-elev)',
36-
borderRight: '1px solid var(--hair)',
37-
padding: 32,
38-
};
39-
4032
const monitorRailPlaceholder: CSSProperties = {
4133
background: 'var(--bg-page)',
4234
borderLeft: '1px solid var(--hair)',
@@ -94,11 +86,7 @@ export function App() {
9486
activeSid={activeSid}
9587
onSelect={setActiveSid}
9688
/>
97-
<div style={canvasEmptyStyle}>
98-
<span style={{ fontSize: 13, color: 'var(--ink-3)' }}>
99-
Select a session from the rail or create a new one.
100-
</span>
101-
</div>
89+
<SessionCanvas activeSid={activeSid} />
10290
<div style={monitorRailPlaceholder}>
10391
Ambient monitors (Phase 5)
10492
</div>

web/src/canvas/SessionCanvas.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { CSSProperties } from 'react';
2+
import { useSessionFull } from '@/state/useSessionFull';
3+
import { useSetSelected } from '@/state/selectedRef';
4+
import { CanvasHead } from './CanvasHead';
5+
import { Transcript } from './Transcript';
6+
import type { ToolCall } from '@/api/types';
7+
8+
interface SessionCanvasProps {
9+
activeSid: string | null;
10+
}
11+
12+
const wrap: CSSProperties = {
13+
display: 'flex',
14+
flexDirection: 'column',
15+
background: 'var(--bg-elev)',
16+
borderRight: '1px solid var(--hair)',
17+
minHeight: 0,
18+
overflowY: 'auto',
19+
};
20+
21+
const center: CSSProperties = {
22+
flex: 1,
23+
display: 'flex',
24+
alignItems: 'center',
25+
justifyContent: 'center',
26+
padding: 32,
27+
fontFamily: 'var(--ff-sans)',
28+
fontSize: 13,
29+
color: 'var(--ink-3)',
30+
};
31+
32+
function extraStr(extras: Record<string, unknown>, key: string, fallback: string): string {
33+
const v = extras[key];
34+
return v === undefined || v === null ? fallback : String(v);
35+
}
36+
37+
function extraNum(extras: Record<string, unknown>, key: string, fallback: number): number {
38+
const v = extras[key];
39+
if (typeof v === 'number') return v;
40+
return fallback;
41+
}
42+
43+
export function SessionCanvas({ activeSid }: SessionCanvasProps) {
44+
const { state, isLoading, error, refresh } = useSessionFull(activeSid);
45+
const setSelected = useSetSelected();
46+
47+
if (activeSid === null) {
48+
return (
49+
<div style={wrap}>
50+
<div style={center}>Select a session from the rail or create a new one.</div>
51+
</div>
52+
);
53+
}
54+
55+
if (isLoading) {
56+
return (
57+
<div style={wrap}>
58+
<div style={center}>Loading…</div>
59+
</div>
60+
);
61+
}
62+
63+
if (error) {
64+
return (
65+
<div style={wrap}>
66+
<div style={center}>
67+
<div>
68+
<div style={{ color: 'var(--danger)', marginBottom: 8 }}>
69+
{error.message || 'Error loading session'}
70+
</div>
71+
<button
72+
type="button"
73+
onClick={refresh}
74+
style={{
75+
padding: '6px 12px',
76+
fontFamily: 'var(--ff-sans)',
77+
fontSize: 12,
78+
color: 'var(--ink-1)',
79+
background: 'var(--bg-elev)',
80+
border: '1px solid var(--hair-strong)',
81+
borderRadius: 0,
82+
cursor: 'pointer',
83+
}}
84+
>
85+
Retry
86+
</button>
87+
</div>
88+
</div>
89+
</div>
90+
);
91+
}
92+
93+
const sess = state.session;
94+
if (!sess) {
95+
return <div style={wrap}><div style={center}>Session not found.</div></div>;
96+
}
97+
98+
const findings = sess.findings as Record<string, unknown>;
99+
const title = (findings.title as string | undefined) ?? sess.id;
100+
const env = extraStr(sess.extra_fields, 'env', 'dev');
101+
const sev = extraNum(sess.extra_fields, 'sev', 0);
102+
const reporter = extraStr(sess.extra_fields, 'reporter', '—');
103+
104+
const onSelectTool = (tc: ToolCall) => {
105+
setSelected({ kind: 'tool_call', id: `${tc.tool}@${tc.ts}` });
106+
};
107+
108+
return (
109+
<div style={wrap}>
110+
<CanvasHead
111+
sessionId={sess.id}
112+
status={sess.status}
113+
openedAt={sess.created_at}
114+
title={title}
115+
env={env}
116+
sev={sev}
117+
reporter={reporter}
118+
turnCount={state.agentsRun.length}
119+
toolCount={state.toolCalls.length}
120+
agentsActive={state.agentsRun.length}
121+
agentsTotal={Object.keys(state.agentDefinitions).length || state.agentsRun.length}
122+
onStop={() => { /* Phase 6: confirm modal */ }}
123+
onRetry={refresh}
124+
/>
125+
<Transcript
126+
agentsRun={state.agentsRun}
127+
toolCalls={state.toolCalls}
128+
activeAgent={null}
129+
hitlContext={null}
130+
onSelectTool={onSelectTool}
131+
onApprove={() => { /* Phase 6 */ }}
132+
onReject={() => { /* Phase 6 */ }}
133+
onApproveWithRationale={() => { /* Phase 6 */ }}
134+
/>
135+
</div>
136+
);
137+
}

web/tests/_helpers/render.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
import type { ReactElement } from 'react';
33
import { render as rtlRender, type RenderOptions } from '@testing-library/react';
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { SelectedRefProvider } from '@/state/selectedRef';
56

67
export function render(ui: ReactElement, options?: RenderOptions) {
78
const queryClient = new QueryClient({
89
defaultOptions: { queries: { retry: false, gcTime: 0 } },
910
});
1011
return rtlRender(
11-
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
12+
<QueryClientProvider client={queryClient}>
13+
<SelectedRefProvider>{ui}</SelectedRefProvider>
14+
</QueryClientProvider>,
1215
options,
1316
);
1417
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { render, screen, waitFor } from '../_helpers/render';
3+
import { SessionCanvas } from '@/canvas/SessionCanvas';
4+
5+
const fullBundle = {
6+
session: {
7+
id: 'SES-1', status: 'in_progress',
8+
created_at: '2026-05-15T14:16:30Z',
9+
updated_at: '2026-05-15T14:17:00Z',
10+
deleted_at: null,
11+
agents_run: [], tool_calls: [], findings: { title: 'Payments latency spike' },
12+
pending_intervention: null, user_inputs: [],
13+
parent_session_id: null, dedup_rationale: null,
14+
extra_fields: { env: 'prod', sev: 2, reporter: 'u1@platform' }, version: 1,
15+
},
16+
agents_run: [
17+
{ agent: 'intake', started_at: '2026-05-15T14:16:30Z', ended_at: '2026-05-15T14:16:32Z',
18+
summary: 'Triage observed elevated p99 latency.', confidence: 0.92,
19+
confidence_rationale: null, signal: null },
20+
],
21+
tool_calls: [],
22+
events: [],
23+
agent_definitions: {},
24+
vm_seq: 1,
25+
};
26+
27+
describe('<SessionCanvas>', () => {
28+
beforeEach(() => {
29+
global.fetch = vi.fn().mockResolvedValue(
30+
new Response(JSON.stringify(fullBundle), {
31+
status: 200, headers: { 'content-type': 'application/json' },
32+
}),
33+
);
34+
// @ts-expect-error -- jsdom does not provide EventSource
35+
global.EventSource = class {
36+
constructor(public url: string) {}
37+
onopen: ((e: Event) => void) | null = null;
38+
onmessage: ((e: MessageEvent) => void) | null = null;
39+
onerror: ((e: Event) => void) | null = null;
40+
close() {}
41+
addEventListener() {}
42+
};
43+
});
44+
45+
it('renders empty state when sid is null', () => {
46+
render(<SessionCanvas activeSid={null} />);
47+
expect(screen.getByText(/Select a session/i)).toBeInTheDocument();
48+
});
49+
50+
it('shows loading state when fetching', () => {
51+
render(<SessionCanvas activeSid="SES-1" />);
52+
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
53+
});
54+
55+
it('renders CanvasHead with session id once loaded', async () => {
56+
render(<SessionCanvas activeSid="SES-1" />);
57+
await waitFor(() => expect(screen.getByText(/SES-1/)).toBeInTheDocument());
58+
});
59+
60+
it('renders Transcript with the agent turn once loaded', async () => {
61+
render(<SessionCanvas activeSid="SES-1" />);
62+
await waitFor(() => expect(screen.getByText('intake')).toBeInTheDocument());
63+
expect(screen.getByText(/Triage observed/)).toBeInTheDocument();
64+
});
65+
66+
it('renders the title from findings.title', async () => {
67+
render(<SessionCanvas activeSid="SES-1" />);
68+
await waitFor(() => expect(screen.getByText(/Payments latency spike/)).toBeInTheDocument());
69+
});
70+
});

0 commit comments

Comments
 (0)