Skip to content

Commit 1cfcc04

Browse files
aksOpsclaude
andauthored
feat(web): React UI v2.0 — Phase 3 Task 36 (App wiring) — Phase 3 complete
Composes the Phase 3 shell components (Topbar, FlowStrip, SessionsRail, Statusbar) and Phase 2 data hooks (useUiHints, useSessionList, useApprovalsQueue, useAgentDefinitions, useSessionFull) into a working <App> that renders the empty 3-pane shell. Canvas is an empty-state placeholder (Phase 4); monitor rail is a placeholder (Phase 5). main.tsx now wraps <App> in QueryClientProvider + SelectedRefProvider and mounts the IconSprite. vite.config.ts gains the same '@/' alias as vitest.config.ts (build broke without it now that main/App use '@/'). Tests: 4 new App.test.tsx cases (brand name from useUiHints, empty SessionsRail, Statusbar version, canvas empty-state) — full suite remains green at 91 passing. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8c4dc9d commit 1cfcc04

4 files changed

Lines changed: 197 additions & 30 deletions

File tree

web/src/App.tsx

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,116 @@
1-
export default function App() {
1+
import { useState } from 'react';
2+
import type { CSSProperties } from 'react';
3+
import { Topbar, type Health } from '@/shell/Topbar';
4+
import { Statusbar, type ConnectionState, type VmSeqState } from '@/shell/Statusbar';
5+
import { SessionsRail } from '@/shell/SessionsRail';
6+
import { FlowStrip } from '@/shell/FlowStrip';
7+
import { useUiHints } from '@/state/useUiHints';
8+
import { useSessionList } from '@/state/useSessionList';
9+
import { useApprovalsQueue } from '@/state/useApprovalsQueue';
10+
import { useAgentDefinitions } from '@/state/useAgentDefinitions';
11+
import { useSessionFull } from '@/state/useSessionFull';
12+
13+
const UI_VERSION = 'v2.0.0-rc1';
14+
const RUNTIME_VERSION_FALLBACK = 'unknown';
15+
16+
const shellStyle: CSSProperties = {
17+
display: 'grid',
18+
gridTemplateRows: 'auto auto 1fr auto',
19+
height: '100vh',
20+
background: 'var(--bg-page)',
21+
color: 'var(--ink-1)',
22+
fontFamily: 'var(--ff-sans)',
23+
};
24+
25+
const paneStyle: CSSProperties = {
26+
display: 'grid',
27+
gridTemplateColumns: '220px 1fr 340px',
28+
minHeight: 0,
29+
};
30+
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+
40+
const monitorRailPlaceholder: CSSProperties = {
41+
background: 'var(--bg-page)',
42+
borderLeft: '1px solid var(--hair)',
43+
padding: 16,
44+
fontSize: 11,
45+
color: 'var(--ink-3)',
46+
};
47+
48+
export function App() {
49+
const [activeSid, setActiveSid] = useState<string | null>(null);
50+
51+
const uiHints = useUiHints();
52+
const sessionList = useSessionList();
53+
const approvals = useApprovalsQueue();
54+
const agents = useAgentDefinitions();
55+
const sessionFull = useSessionFull(activeSid);
56+
57+
const brandName = uiHints.data?.brand_name ?? 'ASR';
58+
const envName = uiHints.data?.environments?.[0] ?? 'dev';
59+
const appName = 'runtime';
60+
61+
const health: Health =
62+
sessionList.error || approvals.error || agents.isError
63+
? 'down'
64+
: 'ok';
65+
66+
const connection: ConnectionState =
67+
sessionList.error || (sessionFull.error && activeSid !== null)
68+
? 'disconnected'
69+
: 'connected';
70+
71+
const vmSeqState: VmSeqState = 'in-sync';
72+
const vmSeq = sessionFull.state.vmSeq;
73+
274
return (
3-
<div style={{ padding: 'var(--s-6)' }}>
4-
<h1 style={{
5-
fontSize: 'var(--t-display)',
6-
fontWeight: 500,
7-
letterSpacing: '-0.018em',
8-
color: 'var(--ink-1)',
9-
marginBottom: 'var(--s-3)',
10-
}}>
11-
ASR Operator Console
12-
</h1>
13-
<p style={{
14-
fontSize: 'var(--t-body)',
15-
color: 'var(--ink-3)',
16-
fontFamily: 'var(--ff-mono)',
17-
}}>
18-
v2.0.0-rc1 · scaffold + design tokens · components land in tasks 16-20
19-
</p>
20-
<div style={{
21-
marginTop: 'var(--s-5)',
22-
padding: 'var(--s-4)',
23-
background: 'var(--bg-elev)',
24-
boxShadow: 'var(--elev-1)',
25-
color: 'var(--ink-2)',
26-
}}>
27-
Token preview: warm cream <code style={{ fontFamily: 'var(--ff-mono)' }}>#FBFAF6</code> page,
28-
accent <span style={{ color: 'var(--acc)' }}>navy #2A4365</span>,
29-
deep ink <span style={{ color: 'var(--ink-1)', fontWeight: 600 }}>#15110A</span>.
75+
<div style={shellStyle}>
76+
<Topbar
77+
brandName={brandName}
78+
appName={appName}
79+
envName={envName}
80+
health={health}
81+
approvalsCount={approvals.count}
82+
onSearch={() => {/* Phase 6: open search overlay */}}
83+
onNew={() => {/* Phase 6: open NewSessionModal */}}
84+
onApprovalsClick={() => {/* Phase 6: open approvals view */}}
85+
/>
86+
<FlowStrip
87+
agents={agents.data?.list ?? []}
88+
activeAgent={null}
89+
graphVersion={`v${agents.data?.list.length ?? 0}`}
90+
/>
91+
<div style={paneStyle}>
92+
<SessionsRail
93+
sessions={sessionList.sessions}
94+
activeSid={activeSid}
95+
onSelect={setActiveSid}
96+
/>
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>
102+
<div style={monitorRailPlaceholder}>
103+
Ambient monitors (Phase 5)
104+
</div>
30105
</div>
106+
<Statusbar
107+
connection={connection}
108+
sseEventCount={sessionFull.state.events.length}
109+
vmSeq={vmSeq}
110+
vmSeqState={vmSeqState}
111+
runtimeVersion={RUNTIME_VERSION_FALLBACK}
112+
uiVersion={UI_VERSION}
113+
/>
31114
</div>
32115
);
33116
}

web/src/main.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import './styles/global.css';
22
import { StrictMode } from 'react';
33
import { createRoot } from 'react-dom/client';
4-
import App from './App';
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { SelectedRefProvider } from '@/state/selectedRef';
6+
import { IconSprite } from '@/icons/sprite';
7+
import { App } from './App';
8+
9+
const queryClient = new QueryClient({
10+
defaultOptions: { queries: { refetchOnWindowFocus: false } },
11+
});
512

613
createRoot(document.getElementById('root')!).render(
714
<StrictMode>
8-
<App />
15+
<QueryClientProvider client={queryClient}>
16+
<SelectedRefProvider>
17+
<IconSprite />
18+
<App />
19+
</SelectedRefProvider>
20+
</QueryClientProvider>
921
</StrictMode>,
1022
);

web/tests/component/App.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, waitFor } from '../_helpers/render';
3+
import { App } from '@/App';
4+
5+
const uiHints = {
6+
brand_name: 'Test Brand',
7+
brand_logo_url: null,
8+
approval_rationale_templates: [],
9+
hitl_question_templates: {},
10+
environments: ['dev'],
11+
};
12+
13+
const sessions: unknown[] = [];
14+
const agents: unknown[] = [];
15+
16+
describe('<App>', () => {
17+
beforeEach(() => {
18+
global.fetch = vi.fn().mockImplementation((url: string) => {
19+
if (url.includes('/config/ui-hints')) {
20+
return Promise.resolve(new Response(JSON.stringify(uiHints), {
21+
status: 200, headers: { 'content-type': 'application/json' },
22+
}));
23+
}
24+
if (url.includes('/sessions') && !url.includes('/recent/events') && !url.includes('/events')) {
25+
return Promise.resolve(new Response(JSON.stringify(sessions), {
26+
status: 200, headers: { 'content-type': 'application/json' },
27+
}));
28+
}
29+
if (url.includes('/agents')) {
30+
return Promise.resolve(new Response(JSON.stringify(agents), {
31+
status: 200, headers: { 'content-type': 'application/json' },
32+
}));
33+
}
34+
return Promise.resolve(new Response('{}', { status: 200 }));
35+
});
36+
// @ts-expect-error -- jsdom does not provide EventSource
37+
global.EventSource = class {
38+
constructor(public url: string) {}
39+
onopen: ((e: Event) => void) | null = null;
40+
onmessage: ((e: MessageEvent) => void) | null = null;
41+
onerror: ((e: Event) => void) | null = null;
42+
close() {}
43+
addEventListener() {}
44+
};
45+
});
46+
47+
it('renders the brand name from useUiHints once loaded', async () => {
48+
render(<App />);
49+
await waitFor(() => expect(screen.getByText('Test Brand')).toBeInTheDocument());
50+
});
51+
52+
it('renders an empty-state SessionsRail when no sessions', async () => {
53+
render(<App />);
54+
await waitFor(() => expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument());
55+
});
56+
57+
it('renders Statusbar with version', async () => {
58+
render(<App />);
59+
await waitFor(() => expect(screen.getByText(/ui v/)).toBeInTheDocument());
60+
});
61+
62+
it('renders the canvas empty state when no session selected', async () => {
63+
render(<App />);
64+
await waitFor(() => expect(screen.getByText(/Select a session/i)).toBeInTheDocument());
65+
});
66+
});

web/vite.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { defineConfig } from 'vite';
22
import react from '@vitejs/plugin-react';
33
import tailwindcss from '@tailwindcss/vite';
4+
import { fileURLToPath } from 'node:url';
45

56
export default defineConfig({
67
plugins: [react(), tailwindcss()],
8+
resolve: {
9+
alias: {
10+
'@': fileURLToPath(new URL('./src', import.meta.url)),
11+
},
12+
},
713
server: {
814
port: 5173,
915
strictPort: true,

0 commit comments

Comments
 (0)