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
139 changes: 111 additions & 28 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,116 @@
export default function App() {
import { useState } from 'react';
import type { CSSProperties } from 'react';
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 { useUiHints } from '@/state/useUiHints';
import { useSessionList } from '@/state/useSessionList';
import { useApprovalsQueue } from '@/state/useApprovalsQueue';
import { useAgentDefinitions } from '@/state/useAgentDefinitions';
import { useSessionFull } from '@/state/useSessionFull';

const UI_VERSION = 'v2.0.0-rc1';
const RUNTIME_VERSION_FALLBACK = 'unknown';

const shellStyle: CSSProperties = {
display: 'grid',
gridTemplateRows: 'auto auto 1fr auto',
height: '100vh',
background: 'var(--bg-page)',
color: 'var(--ink-1)',
fontFamily: 'var(--ff-sans)',
};

const paneStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: '220px 1fr 340px',
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)',
padding: 16,
fontSize: 11,
color: 'var(--ink-3)',
};

export function App() {
const [activeSid, setActiveSid] = useState<string | null>(null);

const uiHints = useUiHints();
const sessionList = useSessionList();
const approvals = useApprovalsQueue();
const agents = useAgentDefinitions();
const sessionFull = useSessionFull(activeSid);

const brandName = uiHints.data?.brand_name ?? 'ASR';
const envName = uiHints.data?.environments?.[0] ?? 'dev';
const appName = 'runtime';

const health: Health =
sessionList.error || approvals.error || agents.isError
? 'down'
: 'ok';

const connection: ConnectionState =
sessionList.error || (sessionFull.error && activeSid !== null)
? 'disconnected'
: 'connected';

const vmSeqState: VmSeqState = 'in-sync';
const vmSeq = sessionFull.state.vmSeq;

return (
<div style={{ padding: 'var(--s-6)' }}>
<h1 style={{
fontSize: 'var(--t-display)',
fontWeight: 500,
letterSpacing: '-0.018em',
color: 'var(--ink-1)',
marginBottom: 'var(--s-3)',
}}>
ASR Operator Console
</h1>
<p style={{
fontSize: 'var(--t-body)',
color: 'var(--ink-3)',
fontFamily: 'var(--ff-mono)',
}}>
v2.0.0-rc1 · scaffold + design tokens · components land in tasks 16-20
</p>
<div style={{
marginTop: 'var(--s-5)',
padding: 'var(--s-4)',
background: 'var(--bg-elev)',
boxShadow: 'var(--elev-1)',
color: 'var(--ink-2)',
}}>
Token preview: warm cream <code style={{ fontFamily: 'var(--ff-mono)' }}>#FBFAF6</code> page,
accent <span style={{ color: 'var(--acc)' }}>navy #2A4365</span>,
deep ink <span style={{ color: 'var(--ink-1)', fontWeight: 600 }}>#15110A</span>.
<div style={shellStyle}>
<Topbar
brandName={brandName}
appName={appName}
envName={envName}
health={health}
approvalsCount={approvals.count}
onSearch={() => {/* Phase 6: open search overlay */}}
onNew={() => {/* Phase 6: open NewSessionModal */}}
onApprovalsClick={() => {/* Phase 6: open approvals view */}}
/>
<FlowStrip
agents={agents.data?.list ?? []}
activeAgent={null}
graphVersion={`v${agents.data?.list.length ?? 0}`}
/>
<div style={paneStyle}>
<SessionsRail
sessions={sessionList.sessions}
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>
<div style={monitorRailPlaceholder}>
Ambient monitors (Phase 5)
</div>
</div>
<Statusbar
connection={connection}
sseEventCount={sessionFull.state.events.length}
vmSeq={vmSeq}
vmSeqState={vmSeqState}
runtimeVersion={RUNTIME_VERSION_FALLBACK}
uiVersion={UI_VERSION}
/>
</div>
);
}
16 changes: 14 additions & 2 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import './styles/global.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SelectedRefProvider } from '@/state/selectedRef';
import { IconSprite } from '@/icons/sprite';
import { App } from './App';

const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
});

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<SelectedRefProvider>
<IconSprite />
<App />
</SelectedRefProvider>
</QueryClientProvider>
</StrictMode>,
);
66 changes: 66 additions & 0 deletions web/tests/component/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '../_helpers/render';
import { App } from '@/App';

const uiHints = {
brand_name: 'Test Brand',
brand_logo_url: null,
approval_rationale_templates: [],
hitl_question_templates: {},
environments: ['dev'],
};

const sessions: unknown[] = [];
const agents: unknown[] = [];

describe('<App>', () => {
beforeEach(() => {
global.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes('/config/ui-hints')) {
return Promise.resolve(new Response(JSON.stringify(uiHints), {
status: 200, headers: { 'content-type': 'application/json' },
}));
}
if (url.includes('/sessions') && !url.includes('/recent/events') && !url.includes('/events')) {
return Promise.resolve(new Response(JSON.stringify(sessions), {
status: 200, headers: { 'content-type': 'application/json' },
}));
}
if (url.includes('/agents')) {
return Promise.resolve(new Response(JSON.stringify(agents), {
status: 200, headers: { 'content-type': 'application/json' },
}));
}
return Promise.resolve(new Response('{}', { status: 200 }));
});
// @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 the brand name from useUiHints once loaded', async () => {
render(<App />);
await waitFor(() => expect(screen.getByText('Test Brand')).toBeInTheDocument());
});

it('renders an empty-state SessionsRail when no sessions', async () => {
render(<App />);
await waitFor(() => expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument());
});

it('renders Statusbar with version', async () => {
render(<App />);
await waitFor(() => expect(screen.getByText(/ui v/)).toBeInTheDocument());
});

it('renders the canvas empty state when no session selected', async () => {
render(<App />);
await waitFor(() => expect(screen.getByText(/Select a session/i)).toBeInTheDocument());
});
});
6 changes: 6 additions & 0 deletions web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath } from 'node:url';

export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
strictPort: true,
Expand Down
Loading