From cab891b4c668f2f74d3c774969f9ad97fb74d15b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:01:47 +0000 Subject: [PATCH 1/4] feat(web): shell component (48px) --- web/src/shell/Topbar.tsx | 160 ++++++++++++++++++++++++++++ web/tests/component/Topbar.test.tsx | 89 ++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 web/src/shell/Topbar.tsx create mode 100644 web/tests/component/Topbar.test.tsx diff --git a/web/src/shell/Topbar.tsx b/web/src/shell/Topbar.tsx new file mode 100644 index 0000000..b54bf86 --- /dev/null +++ b/web/src/shell/Topbar.tsx @@ -0,0 +1,160 @@ +import type { CSSProperties } from 'react'; +import { Icon } from '@/icons/Icon'; +import { Button } from '@/components/Button'; + +export type Health = 'ok' | 'degraded' | 'down'; + +interface TopbarProps { + brandName: string; + appName: string; + envName: string; + health: Health; + approvalsCount: number; + user?: string; + onSearch: () => void; + onNew: () => void; + onApprovalsClick: () => void; +} + +const containerStyle: CSSProperties = { + height: 48, + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '0 16px', + background: 'var(--bg-elev)', + borderBottom: '1px solid var(--hair)', + fontFamily: 'var(--ff-sans)', +}; + +const sep = ( + +); + +const healthLabel: Record = { + ok: 'All Systems Normal', + degraded: 'Degraded', + down: 'Critical', +}; + +const healthColor: Record = { + ok: 'var(--good)', + degraded: 'var(--warn)', + down: 'var(--danger)', +}; + +export function Topbar({ + brandName, appName, envName, health, approvalsCount, + user = 'Operator', onSearch, onNew, onApprovalsClick, +}: TopbarProps) { + const initial = (brandName[0] ?? 'A').toUpperCase(); + return ( +
+
+ + {initial} + + {brandName} +
+ {sep} +
+ {appName} + / + {envName} +
+ {sep} +
+ + {healthLabel[health]} +
+ +
+ + +
+ {approvalsCount > 0 && ( + + )} + + {sep} + {user} +
+ ); +} diff --git a/web/tests/component/Topbar.test.tsx b/web/tests/component/Topbar.test.tsx new file mode 100644 index 0000000..6a1005f --- /dev/null +++ b/web/tests/component/Topbar.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { Topbar } from '@/shell/Topbar'; + +describe('', () => { + it('renders brand mark + brand name', () => { + render( + {}} + onNew={() => {}} + onApprovalsClick={() => {}} + />, + ); + expect(screen.getByText('Acme Agents')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); // brand mark letter + }); + + it('renders breadcrumb with app + env', () => { + render( + {}} onNew={() => {}} onApprovalsClick={() => {}} + />, + ); + expect(screen.getByText('my_app')).toBeInTheDocument(); + expect(screen.getByText('staging')).toBeInTheDocument(); + }); + + it('renders health pill text per state', () => { + const { rerender } = render( + {}} onNew={() => {}} onApprovalsClick={() => {}} />, + ); + expect(screen.getByText(/All Systems Normal/)).toBeInTheDocument(); + rerender( + {}} onNew={() => {}} onApprovalsClick={() => {}} />, + ); + expect(screen.getByText(/Degraded/)).toBeInTheDocument(); + rerender( + {}} onNew={() => {}} onApprovalsClick={() => {}} />, + ); + expect(screen.getByText(/Critical/)).toBeInTheDocument(); + }); + + it('shows approvals badge when count > 0', () => { + render( + {}} onNew={() => {}} onApprovalsClick={() => {}} />, + ); + const badge = screen.getByText(/2 Pending Approvals/); + expect(badge).toBeInTheDocument(); + }); + + it('hides approvals badge when count === 0', () => { + render( + {}} onNew={() => {}} onApprovalsClick={() => {}} />, + ); + expect(screen.queryByText(/Pending Approvals/)).not.toBeInTheDocument(); + }); + + it('fires onNew when New Session button clicked', () => { + const onNew = vi.fn(); + render( + {}} onNew={onNew} onApprovalsClick={() => {}} />, + ); + fireEvent.click(screen.getByRole('button', { name: /New Session/i })); + expect(onNew).toHaveBeenCalledTimes(1); + }); + + it('fires onSearch when search box clicked', () => { + const onSearch = vi.fn(); + render( + {}} onApprovalsClick={() => {}} />, + ); + fireEvent.click(screen.getByPlaceholderText(/Search sessions/i)); + expect(onSearch).toHaveBeenCalledTimes(1); + }); +}); From e231ef9b95f4868114f3629f5e0be43c184b8178 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 04:58:25 +0000 Subject: [PATCH 2/4] feat(web): shell component (24px ambient status) --- web/src/shell/Statusbar.tsx | 93 ++++++++++++++++++++++++++ web/tests/component/Statusbar.test.tsx | 49 ++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 web/src/shell/Statusbar.tsx create mode 100644 web/tests/component/Statusbar.test.tsx diff --git a/web/src/shell/Statusbar.tsx b/web/src/shell/Statusbar.tsx new file mode 100644 index 0000000..2474953 --- /dev/null +++ b/web/src/shell/Statusbar.tsx @@ -0,0 +1,93 @@ +import type { CSSProperties } from 'react'; + +export type ConnectionState = 'connected' | 'degraded' | 'disconnected'; +export type VmSeqState = 'in-sync' | 'replaying' | 'divergent'; + +interface StatusbarProps { + connection: ConnectionState; + sseEventCount: number; + vmSeq: number; + vmSeqState: VmSeqState; + runtimeVersion: string; + uiVersion: string; + p95Ms?: number; +} + +const dotColor: Record = { + connected: 'var(--good)', + degraded: 'var(--warn)', + disconnected: 'var(--danger)', +}; + +const vmSeqColor: Record = { + 'in-sync': 'var(--good)', + replaying: 'var(--warn)', + divergent: 'var(--danger)', +}; + +const baseStyle: CSSProperties = { + height: 24, + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '0 16px', + fontFamily: 'var(--ff-mono)', + fontSize: 11, + color: 'var(--ink-3)', + background: 'var(--bg-page)', + borderTop: '1px solid var(--hair)', + whiteSpace: 'nowrap', + overflow: 'hidden', +}; + +const sep = ·; + +export function Statusbar({ + connection, + sseEventCount, + vmSeq, + vmSeqState, + runtimeVersion, + uiVersion, + p95Ms, +}: StatusbarProps) { + const connectionLabel: Record = { + connected: 'Connected', + degraded: 'Reconnecting…', + disconnected: 'Disconnected', + }; + return ( +
+ + + {connectionLabel[connection]} + + {sep} + SSE {sseEventCount} events + {sep} + + vm_seq {vmSeq}{' '} + ({vmSeqState}) + + {p95Ms !== undefined && ( + <> + {sep} + p95 {p95Ms} ms + + )} + + runtime {runtimeVersion} + {sep} + ui {uiVersion} +
+ ); +} diff --git a/web/tests/component/Statusbar.test.tsx b/web/tests/component/Statusbar.test.tsx new file mode 100644 index 0000000..ff9d491 --- /dev/null +++ b/web/tests/component/Statusbar.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../_helpers/render'; +import { Statusbar } from '@/shell/Statusbar'; + +describe('', () => { + it('renders connection state and labels', () => { + render( + , + ); + expect(screen.getByText(/Connected/)).toBeInTheDocument(); + expect(screen.getByText(/SSE 247 events/)).toBeInTheDocument(); + expect(screen.getByText(/vm_seq 247/)).toBeInTheDocument(); + expect(screen.getByText(/in-sync/)).toBeInTheDocument(); + expect(screen.getByText(/runtime v1\.5\.2/)).toBeInTheDocument(); + expect(screen.getByText(/ui v2\.0\.0-rc1/)).toBeInTheDocument(); + }); + + it('uses data-connection attribute for state-driven styling', () => { + const { container, rerender } = render( + , + ); + expect(container.firstChild).toHaveAttribute('data-connection', 'connected'); + rerender( + , + ); + expect(container.firstChild).toHaveAttribute('data-connection', 'degraded'); + rerender( + , + ); + expect(container.firstChild).toHaveAttribute('data-connection', 'disconnected'); + }); + + it('renders the optional p95 latency when provided', () => { + render( + , + ); + expect(screen.getByText(/p95 87 ms/)).toBeInTheDocument(); + }); +}); From 4a07eebbc88465366e87da8c1e0b7c4f645bdd0d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:01:18 +0000 Subject: [PATCH 3/4] feat(web): shell component with grouping + filter --- web/src/shell/SessionsRail.tsx | 211 ++++++++++++++++++++++ web/tests/component/SessionsRail.test.tsx | 66 +++++++ 2 files changed, 277 insertions(+) create mode 100644 web/src/shell/SessionsRail.tsx create mode 100644 web/tests/component/SessionsRail.test.tsx diff --git a/web/src/shell/SessionsRail.tsx b/web/src/shell/SessionsRail.tsx new file mode 100644 index 0000000..a515c0e --- /dev/null +++ b/web/src/shell/SessionsRail.tsx @@ -0,0 +1,211 @@ +import { useMemo, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import type { SessionSummary } from '@/state/useSessionList'; + +const ACTIVE_STATUSES = new Set(['new', 'in_progress', 'awaiting_input', 'matched']); + +interface SessionsRailProps { + sessions: SessionSummary[]; + activeSid: string | null; + onSelect: (sid: string) => void; +} + +const containerStyle: CSSProperties = { + width: 220, + display: 'flex', + flexDirection: 'column', + background: 'var(--bg-page)', + borderRight: '1px solid var(--hair)', + overflow: 'hidden', + fontFamily: 'var(--ff-sans)', +}; + +export function SessionsRail({ sessions, activeSid, onSelect }: SessionsRailProps) { + const [filter, setFilter] = useState(''); + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return sessions; + return sessions.filter( + (s) => + s.id.toLowerCase().includes(q) || + (s.label !== undefined && s.label.toLowerCase().includes(q)), + ); + }, [sessions, filter]); + + const active = filtered.filter((s) => ACTIVE_STATUSES.has(s.status)); + const recent = filtered.filter((s) => !ACTIVE_STATUSES.has(s.status)); + + return ( + + ); +} + +function Group({ label, count, children }: { label: string; count: number; children: ReactNode }) { + return ( +
+
+ {label} · {count} +
+ {children} +
+ ); +} + +function Row({ + session, + active, + onSelect, +}: { + session: SessionSummary; + active: boolean; + onSelect: (sid: string) => void; +}) { + const isPending = session.status === 'awaiting_input'; + return ( +
onSelect(session.id)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '6px 10px 6px 12px', + cursor: 'pointer', + background: active ? 'var(--bg-elev)' : 'transparent', + borderLeft: active ? '2px solid var(--acc)' : '2px solid transparent', + position: 'relative', + }} + > + + {session.id} + + + {session.label ?? ''} + + + {shortStatus(session.status)} + + {isPending && ( + + )} +
+ ); +} + +function shortStatus(s: string): string { + switch (s) { + case 'in_progress': + return 'RUN'; + case 'awaiting_input': + return 'WAIT'; + case 'resolved': + return 'DONE'; + case 'escalated': + return 'ESC'; + case 'error': + return 'ERR'; + case 'stopped': + return 'STOP'; + case 'matched': + return 'MATCH'; + case 'new': + return 'NEW'; + default: + return s.slice(0, 4).toUpperCase(); + } +} diff --git a/web/tests/component/SessionsRail.test.tsx b/web/tests/component/SessionsRail.test.tsx new file mode 100644 index 0000000..e1fb68e --- /dev/null +++ b/web/tests/component/SessionsRail.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { SessionsRail } from '@/shell/SessionsRail'; +import type { SessionSummary } from '@/state/useSessionList'; + +const sessions: SessionSummary[] = [ + { id: 'SES-1', status: 'in_progress', label: 'Foo running', created_at: 't0', updated_at: 't1' }, + { id: 'SES-2', status: 'awaiting_input', label: 'Bar paused', created_at: 't0', updated_at: 't2' }, + { id: 'SES-3', status: 'resolved', label: 'Baz done', created_at: 't0', updated_at: 't3' }, +]; + +describe('', () => { + it('renders the header with total count', () => { + render( {}} />); + expect(screen.getByText(/Sessions/)).toBeInTheDocument(); + expect(screen.getByText(/3 total/)).toBeInTheDocument(); + }); + + it('groups sessions into Active and Recent', () => { + render( {}} />); + expect(screen.getByText(/Active/)).toBeInTheDocument(); + expect(screen.getByText(/Recent/)).toBeInTheDocument(); + // SES-1 + SES-2 in Active (in_progress + awaiting_input), SES-3 in Recent (resolved) + }); + + it('renders rows with id, label, and status', () => { + render( {}} />); + expect(screen.getByText('SES-1')).toBeInTheDocument(); + expect(screen.getByText('Foo running')).toBeInTheDocument(); + }); + + it('marks the active session row with data-active="true"', () => { + render( {}} />); + const row = screen.getByText('SES-2').closest('[data-row]'); + expect(row).toHaveAttribute('data-active', 'true'); + }); + + it('shows pending dot on awaiting_input rows', () => { + render( {}} />); + const row = screen.getByText('SES-2').closest('[data-row]'); + expect(row?.querySelector('[data-pending-dot]')).not.toBeNull(); + const otherRow = screen.getByText('SES-1').closest('[data-row]'); + expect(otherRow?.querySelector('[data-pending-dot]')).toBeNull(); + }); + + it('calls onSelect with sid when a row is clicked', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText('SES-2').closest('[data-row]')!); + expect(onSelect).toHaveBeenCalledWith('SES-2'); + }); + + it('filters rows when filter input has text', () => { + render( {}} />); + const filter = screen.getByPlaceholderText(/Filter/i); + fireEvent.change(filter, { target: { value: 'Bar' } }); + expect(screen.queryByText('SES-1')).not.toBeInTheDocument(); + expect(screen.getByText('SES-2')).toBeInTheDocument(); + expect(screen.queryByText('SES-3')).not.toBeInTheDocument(); + }); + + it('shows empty state when no sessions', () => { + render( {}} />); + expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument(); + }); +}); From 8301793122ca559d2d35bdc0e781aec06241a887 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:02:21 +0000 Subject: [PATCH 4/4] feat(web): hero visual with breathing-halo on active agent --- web/src/shell/FlowStrip.tsx | 167 +++++++++++++++++++++++++ web/src/styles/global.css | 15 +++ web/tests/component/FlowStrip.test.tsx | 44 +++++++ 3 files changed, 226 insertions(+) create mode 100644 web/src/shell/FlowStrip.tsx create mode 100644 web/tests/component/FlowStrip.test.tsx diff --git a/web/src/shell/FlowStrip.tsx b/web/src/shell/FlowStrip.tsx new file mode 100644 index 0000000..b0b06a7 --- /dev/null +++ b/web/src/shell/FlowStrip.tsx @@ -0,0 +1,167 @@ +import type { CSSProperties } from 'react'; +import type { AgentDefinition } from '@/api/types'; + +export type NodeStatus = 'idle' | 'active' | 'done' | 'gated' | 'error'; + +interface FlowStripProps { + agents: AgentDefinition[]; + activeAgent: string | null; + graphVersion: string; + /** Agent-name → status overrides (e.g. session.agents_run derives done) */ + statusByAgent?: Record; +} + +const containerStyle: CSSProperties = { + height: 92, + display: 'flex', + flexDirection: 'column', + background: 'var(--bg-page)', + borderBottom: '1px solid var(--hair)', + fontFamily: 'var(--ff-sans)', + position: 'relative', +}; + +const NODE_W = 90; +const NODE_W_ACTIVE = 100; +const NODE_H = 40; +const NODE_H_ACTIVE = 44; + +export function FlowStrip({ agents, activeAgent, graphVersion, statusByAgent }: FlowStripProps) { + if (agents.length === 0) { + return ( +
+ No agents loaded +
+ ); + } + + return ( +
+
+ Runtime · {agents.length} agents · graph {graphVersion} +
+
+ {agents.map((a, i) => { + const isActive = a.name === activeAgent; + const status: NodeStatus = isActive + ? 'active' + : statusByAgent?.[a.name] ?? 'idle'; + return ( +
+ + {i < agents.length - 1 && } +
+ ); + })} +
+
+ ); +} + +function FlowNode({ + agent, + status, + active, +}: { + agent: AgentDefinition; + status: NodeStatus; + active: boolean; +}) { + const w = active ? NODE_W_ACTIVE : NODE_W; + const h = active ? NODE_H_ACTIVE : NODE_H; + const fills: Record = { + idle: 'var(--bg-elev)', + active: 'var(--acc-soft)', + done: 'var(--good-bg)', + gated: 'var(--warn-bg)', + error: 'var(--danger-bg)', + }; + const strokes: Record = { + idle: 'var(--hair-strong)', + active: 'var(--acc)', + done: 'var(--good)', + gated: 'var(--warn)', + error: 'var(--danger)', + }; + const labelStatusFor: Record = { + idle: 'IDLE', + active: 'ACTIVE', + done: 'DONE', + gated: 'GATED', + error: 'ERROR', + }; + return ( +
+ + {agent.name} + + + {labelStatusFor[status]} + +
+ ); +} + +function FlowEdge() { + return ( + + + + ); +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 1061e94..e3c67db 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -79,3 +79,18 @@ body { animation-duration: 0.01ms !important; } } + +@keyframes asr-flow-halo { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(42, 67, 101, 0.0), 0 0 0 1.5px var(--acc); + } + 50% { + box-shadow: 0 0 0 6px rgba(42, 67, 101, 0.18), 0 0 0 1.5px var(--acc); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-flow-node][data-active="true"] { + animation: none !important; + } +} diff --git a/web/tests/component/FlowStrip.test.tsx b/web/tests/component/FlowStrip.test.tsx new file mode 100644 index 0000000..47c01c3 --- /dev/null +++ b/web/tests/component/FlowStrip.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../_helpers/render'; +import { FlowStrip } from '@/shell/FlowStrip'; +import type { AgentDefinition } from '@/api/types'; + +const agents: AgentDefinition[] = [ + { name: 'intake', kind: 'responsive', model: 'gpt', tools: [], routes: { success: 'triage' }, system_prompt_excerpt: '' }, + { name: 'triage', kind: 'gated', model: 'claude', tools: ['obs:get_logs'], routes: { success: 'investigate' }, system_prompt_excerpt: '' }, + { name: 'investigate', kind: 'gated', model: 'claude', tools: ['rem:propose_fix'], routes: {}, system_prompt_excerpt: '' }, +]; + +describe('', () => { + it('renders the runtime label with agent count + graph version', () => { + render(); + expect(screen.getByText(/3 agents/)).toBeInTheDocument(); + expect(screen.getByText(/v1\.5\.2/)).toBeInTheDocument(); + }); + + it('renders one node per agent', () => { + const { container } = render(); + const nodes = container.querySelectorAll('[data-flow-node]'); + expect(nodes).toHaveLength(3); + }); + + it('marks the active agent with data-active="true"', () => { + render(); + const activeNode = document.querySelector('[data-flow-node="triage"]'); + expect(activeNode).toHaveAttribute('data-active', 'true'); + const idleNode = document.querySelector('[data-flow-node="intake"]'); + expect(idleNode).toHaveAttribute('data-active', 'false'); + }); + + it('renders agent names', () => { + render(); + expect(screen.getByText('intake')).toBeInTheDocument(); + expect(screen.getByText('triage')).toBeInTheDocument(); + expect(screen.getByText('investigate')).toBeInTheDocument(); + }); + + it('renders empty state when agents=[]', () => { + render(); + expect(screen.getByText(/No agents loaded/i)).toBeInTheDocument(); + }); +});