From cede837cf0b7e7455f3dace9c79d7ef46c4da496 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:02:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=20+=20wire=20into?= =?UTF-8?q?=20App=20=E2=80=94=20Phase=204=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes CanvasHead + Transcript over useSessionFull(sid). Handles null sid (empty state), loading, error+retry, and not-found cases. App.tsx now renders 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). --- web/src/App.tsx | 16 +-- web/src/canvas/SessionCanvas.tsx | 137 +++++++++++++++++++++ web/tests/_helpers/render.tsx | 5 +- web/tests/component/SessionCanvas.test.tsx | 70 +++++++++++ 4 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 web/src/canvas/SessionCanvas.tsx create mode 100644 web/tests/component/SessionCanvas.test.tsx 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 ( +
+
Loading…
+
+ ); + } + + if (error) { + return ( +
+
+
+
+ {error.message || 'Error loading session'} +
+ +
+
+
+ ); + } + + const sess = state.session; + if (!sess) { + return
Session not found.
; + } + + 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()); + }); +});