Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)',
Expand Down Expand Up @@ -94,11 +86,7 @@ export function App() {
activeSid={activeSid}
onSelect={setActiveSid}
/>
<div style={canvasEmptyStyle}>
<span style={{ fontSize: 13, color: 'var(--ink-3)' }}>
Select a session from the rail or create a new one.
</span>
</div>
<SessionCanvas activeSid={activeSid} />
<div style={monitorRailPlaceholder}>
Ambient monitors (Phase 5)
</div>
Expand Down
137 changes: 137 additions & 0 deletions web/src/canvas/SessionCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, key: string, fallback: string): string {
const v = extras[key];
return v === undefined || v === null ? fallback : String(v);
}

function extraNum(extras: Record<string, unknown>, 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 (
<div style={wrap}>
<div style={center}>Select a session from the rail or create a new one.</div>
</div>
);
}

if (isLoading) {
return (
<div style={wrap}>
<div style={center}>Loading…</div>
</div>
);
}

if (error) {
return (
<div style={wrap}>
<div style={center}>
<div>
<div style={{ color: 'var(--danger)', marginBottom: 8 }}>
{error.message || 'Error loading session'}
</div>
<button
type="button"
onClick={refresh}
style={{
padding: '6px 12px',
fontFamily: 'var(--ff-sans)',
fontSize: 12,
color: 'var(--ink-1)',
background: 'var(--bg-elev)',
border: '1px solid var(--hair-strong)',
borderRadius: 0,
cursor: 'pointer',
}}
>
Retry
</button>
</div>
</div>
</div>
);
}

const sess = state.session;
if (!sess) {
return <div style={wrap}><div style={center}>Session not found.</div></div>;
}

const findings = sess.findings as Record<string, unknown>;
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 (
<div style={wrap}>
<CanvasHead
sessionId={sess.id}
status={sess.status}
openedAt={sess.created_at}
title={title}
env={env}
sev={sev}
reporter={reporter}
turnCount={state.agentsRun.length}
toolCount={state.toolCalls.length}
agentsActive={state.agentsRun.length}
agentsTotal={Object.keys(state.agentDefinitions).length || state.agentsRun.length}
onStop={() => { /* Phase 6: confirm modal */ }}
onRetry={refresh}
/>
<Transcript
agentsRun={state.agentsRun}
toolCalls={state.toolCalls}
activeAgent={null}
hitlContext={null}
onSelectTool={onSelectTool}
onApprove={() => { /* Phase 6 */ }}
onReject={() => { /* Phase 6 */ }}
onApproveWithRationale={() => { /* Phase 6 */ }}
/>
</div>
);
}
5 changes: 4 additions & 1 deletion web/tests/_helpers/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
<QueryClientProvider client={queryClient}>
<SelectedRefProvider>{ui}</SelectedRefProvider>
</QueryClientProvider>,
options,
);
}
Expand Down
70 changes: 70 additions & 0 deletions web/tests/component/SessionCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<SessionCanvas>', () => {
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(<SessionCanvas activeSid={null} />);
expect(screen.getByText(/Select a session/i)).toBeInTheDocument();
});

it('shows loading state when fetching', () => {
render(<SessionCanvas activeSid="SES-1" />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});

it('renders CanvasHead with session id once loaded', async () => {
render(<SessionCanvas activeSid="SES-1" />);
await waitFor(() => expect(screen.getByText(/SES-1/)).toBeInTheDocument());
});

it('renders Transcript with the agent turn once loaded', async () => {
render(<SessionCanvas activeSid="SES-1" />);
await waitFor(() => expect(screen.getByText('intake')).toBeInTheDocument());
expect(screen.getByText(/Triage observed/)).toBeInTheDocument();
});

it('renders the title from findings.title', async () => {
render(<SessionCanvas activeSid="SES-1" />);
await waitFor(() => expect(screen.getByText(/Payments latency spike/)).toBeInTheDocument());
});
});
Loading