From 8931027a5b63413b3a3acf1a66bd642ce344a9d9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 09:13:45 +0000 Subject: [PATCH 1/3] feat(web): Task 57 useBreakpoint hook (mobile/tablet/desktop) - web/src/state/useBreakpoint.ts: returns 'mobile' (<768px), 'tablet' (768-1199), or 'desktop' (>=1200) via matchMedia. SSR-safe: returns 'desktop' when window or matchMedia is unavailable. Listens for change events on both breakpoint queries. - web/tests/component/useBreakpoint.test.tsx: 5 tests covering each bucket, change-event updates, and SSR fallback. Verified: typecheck clean; 5/5 hook tests pass. --- web/src/state/useBreakpoint.ts | 35 +++++++++ web/tests/component/useBreakpoint.test.tsx | 89 ++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 web/src/state/useBreakpoint.ts create mode 100644 web/tests/component/useBreakpoint.test.tsx diff --git a/web/src/state/useBreakpoint.ts b/web/src/state/useBreakpoint.ts new file mode 100644 index 0000000..0839b79 --- /dev/null +++ b/web/src/state/useBreakpoint.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +export type Breakpoint = 'mobile' | 'tablet' | 'desktop'; + +const TABLET_MIN = '(min-width: 768px)'; +const DESKTOP_MIN = '(min-width: 1200px)'; + +function readNow(): Breakpoint { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return 'desktop'; + } + if (window.matchMedia(DESKTOP_MIN).matches) return 'desktop'; + if (window.matchMedia(TABLET_MIN).matches) return 'tablet'; + return 'mobile'; +} + +export function useBreakpoint(): Breakpoint { + const [bp, setBp] = useState(readNow); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const tabletMq = window.matchMedia(TABLET_MIN); + const desktopMq = window.matchMedia(DESKTOP_MIN); + const update = () => setBp(readNow()); + tabletMq.addEventListener('change', update); + desktopMq.addEventListener('change', update); + update(); + return () => { + tabletMq.removeEventListener('change', update); + desktopMq.removeEventListener('change', update); + }; + }, []); + + return bp; +} diff --git a/web/tests/component/useBreakpoint.test.tsx b/web/tests/component/useBreakpoint.test.tsx new file mode 100644 index 0000000..3d0f3a6 --- /dev/null +++ b/web/tests/component/useBreakpoint.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useBreakpoint } from '@/state/useBreakpoint'; + +type Listener = (e: { matches: boolean }) => void; + +interface MockMediaQueryList { + matches: boolean; + media: string; + addEventListener: (event: 'change', listener: Listener) => void; + removeEventListener: (event: 'change', listener: Listener) => void; + fire: (matches: boolean) => void; +} + +const originalMatchMedia = window.matchMedia; + +function installMatchMedia(width: number) { + const lists: MockMediaQueryList[] = []; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: (query: string) => { + const min = /\(min-width:\s*(\d+)px\)/.exec(query); + const threshold = min ? Number(min[1]) : 0; + const matches = width >= threshold; + const listeners = new Set(); + const list: MockMediaQueryList = { + matches, + media: query, + addEventListener: (_e, l) => { listeners.add(l); }, + removeEventListener: (_e, l) => { listeners.delete(l); }, + fire: (m) => { list.matches = m; listeners.forEach((l) => l({ matches: m })); }, + }; + lists.push(list); + return list as unknown as MediaQueryList; + }, + }); + return { + setWidth(newWidth: number) { + width = newWidth; + for (const list of lists) { + const min = /\(min-width:\s*(\d+)px\)/.exec(list.media); + const threshold = min ? Number(min[1]) : 0; + list.fire(width >= threshold); + } + }, + }; +} + +describe('useBreakpoint', () => { + beforeEach(() => { /* installed per-test */ }); + afterEach(() => { + Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: originalMatchMedia }); + }); + + it('returns "mobile" when width < 768', () => { + installMatchMedia(500); + const { result } = renderHook(() => useBreakpoint()); + expect(result.current).toBe('mobile'); + }); + + it('returns "tablet" when 768 <= width < 1200', () => { + installMatchMedia(900); + const { result } = renderHook(() => useBreakpoint()); + expect(result.current).toBe('tablet'); + }); + + it('returns "desktop" when width >= 1200', () => { + installMatchMedia(1440); + const { result } = renderHook(() => useBreakpoint()); + expect(result.current).toBe('desktop'); + }); + + it('updates when matchMedia change events fire', () => { + const mm = installMatchMedia(500); + const { result } = renderHook(() => useBreakpoint()); + expect(result.current).toBe('mobile'); + act(() => { mm.setWidth(1000); }); + expect(result.current).toBe('tablet'); + act(() => { mm.setWidth(1500); }); + expect(result.current).toBe('desktop'); + }); + + it('falls back to "desktop" when matchMedia is unavailable (SSR-safe)', () => { + Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: undefined }); + const { result } = renderHook(() => useBreakpoint()); + expect(result.current).toBe('desktop'); + }); +}); From ad6fcad204ebfb49e12462c4770fb5ac3c48e168 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 09:15:49 +0000 Subject: [PATCH 2/3] feat(web): Task 58 TabletShell + App.tsx breakpoint dispatch - web/src/shell/TabletShell.tsx: 2-pane grid (180px SessionsRail + Canvas) with a 'Monitors' button that opens MonitorRail in a Radix right-sliding sheet. data-shell="tablet" marker for tests. - web/src/App.tsx: useBreakpoint() picks TabletShell at 768-1199px; desktop layout unchanged. - web/tests/component/TabletShell.test.tsx: 3 tests (shell marker, monitors button, sheet open). Verified: vitest 43 files / 176 tests pass. --- web/src/App.tsx | 36 +++++--- web/src/shell/TabletShell.tsx | 112 +++++++++++++++++++++++ web/tests/component/TabletShell.test.tsx | 50 ++++++++++ 3 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 web/src/shell/TabletShell.tsx create mode 100644 web/tests/component/TabletShell.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 47106ea..b16f33d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,8 @@ import { useAgentDefinitions } from '@/state/useAgentDefinitions'; import { useSessionFull } from '@/state/useSessionFull'; import { MonitorRail } from '@/monitors/MonitorRail'; import { NewSessionModal } from '@/modals/NewSessionModal'; +import { TabletShell } from '@/shell/TabletShell'; +import { useBreakpoint } from '@/state/useBreakpoint'; const UI_VERSION = 'v2.0.0-rc1'; const RUNTIME_VERSION_FALLBACK = 'unknown'; @@ -40,6 +42,7 @@ export function App() { const approvals = useApprovalsQueue(); const agents = useAgentDefinitions(); const sessionFull = useSessionFull(activeSid); + const breakpoint = useBreakpoint(); const brandName = uiHints.data?.brand_name ?? 'ASR'; const envName = uiHints.data?.environments?.[0] ?? 'dev'; @@ -75,23 +78,34 @@ export function App() { activeAgent={null} graphVersion={`v${agents.data?.list.length ?? 0}`} /> -
- - - -
+ ) : ( +
+ + + +
+ )} void; + queue: SessionSummary[]; + agentsByName: Record; + toolCalls: ToolCall[]; +} + +const grid: CSSProperties = { + display: 'grid', + gridTemplateColumns: '180px 1fr', + minHeight: 0, + position: 'relative', +}; + +const monitorsBtn: CSSProperties = { + position: 'absolute', + top: 12, + right: 12, + height: 28, + padding: '0 12px', + fontFamily: 'var(--ff-mono)', + fontSize: 11, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: 'var(--ink-1)', + background: 'var(--bg-elev)', + border: '1px solid var(--hair-strong)', + borderRadius: 0, + cursor: 'pointer', + zIndex: 5, +}; + +const sheetContent: CSSProperties = { + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + width: 'min(360px, 90vw)', + background: 'var(--bg-page)', + borderLeft: '1px solid var(--hair-strong)', + boxShadow: 'var(--e-3)', + overflow: 'auto', + zIndex: 1000, + display: 'flex', + flexDirection: 'column', +}; + +export function TabletShell({ + sessions, activeSid, onSelectSession, queue, agentsByName, toolCalls, +}: TabletShellProps) { + const [monitorsOpen, setMonitorsOpen] = useState(false); + return ( +
+ +
+ + +
+ + + + + + Monitors + + { onSelectSession(sid); setMonitorsOpen(false); }} + /> + + + +
+ ); +} diff --git a/web/tests/component/TabletShell.test.tsx b/web/tests/component/TabletShell.test.tsx new file mode 100644 index 0000000..ffefbe3 --- /dev/null +++ b/web/tests/component/TabletShell.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { TabletShell } from '@/shell/TabletShell'; + +const ORIGINAL_FETCH = globalThis.fetch; + +beforeEach(() => { + globalThis.fetch = vi.fn(() => Promise.resolve({ + ok: true, status: 200, statusText: 'OK', + clone() { return this; }, + json: () => Promise.resolve({}), + } as unknown as Response)); +}); +afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; }); + +describe('', () => { + it('renders SessionsRail + Monitors button + empty SessionCanvas hint', () => { + render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + expect(screen.getAllByText(/Sessions/i).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: /open monitors/i })).toBeInTheDocument(); + expect(screen.getByText(/Select a session/i)).toBeInTheDocument(); + }); + + it('uses a 180px sessions rail grid template (tablet shell marker)', () => { + const { container } = render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + expect(container.querySelector('[data-shell="tablet"]')).toBeInTheDocument(); + }); + + it('opens the monitors sheet when the button is clicked', () => { + render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + expect(document.querySelector('[data-monitors-sheet]')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /open monitors/i })); + expect(document.querySelector('[data-monitors-sheet]')).toBeInTheDocument(); + }); +}); From 0f19207095d641682537414a001ae1f6e1f9bcbc Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 09:18:04 +0000 Subject: [PATCH 3/3] feat(web): Tasks 59+60+61 MobileShell + bottom sheets (sessions/monitors) - web/src/shell/MobileSheet.tsx: Radix Dialog wrapped as a bottom-anchored sheet. Slide-up animation gated by prefers-reduced-motion (CSS keyframe asr-sheet-slide-up). data-mobile-sheet="" marker for tests. - web/src/shell/MobileShell.tsx: single-pane SessionCanvas + 3-tab bottom nav (Sessions / Canvas / Monitors). Sessions and Monitors tap opens MobileSheet over SessionsRail / MonitorRail respectively. Selecting a session inside a sheet closes it. - web/src/styles/global.css: asr-sheet-slide-up keyframe. - web/src/App.tsx: breakpoint==='mobile' branch mounts MobileShell. - Tests: MobileShell (4) + MobileSheet (3). Verified: typecheck clean; vitest 45 files / 183 tests pass. --- web/src/App.tsx | 12 ++- web/src/shell/MobileSheet.tsx | 85 +++++++++++++++ web/src/shell/MobileShell.tsx | 126 +++++++++++++++++++++++ web/src/styles/global.css | 5 + web/tests/component/MobileSheet.test.tsx | 36 +++++++ web/tests/component/MobileShell.test.tsx | 65 ++++++++++++ 6 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 web/src/shell/MobileSheet.tsx create mode 100644 web/src/shell/MobileShell.tsx create mode 100644 web/tests/component/MobileSheet.test.tsx create mode 100644 web/tests/component/MobileShell.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index b16f33d..96a535f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { useSessionFull } from '@/state/useSessionFull'; import { MonitorRail } from '@/monitors/MonitorRail'; import { NewSessionModal } from '@/modals/NewSessionModal'; import { TabletShell } from '@/shell/TabletShell'; +import { MobileShell } from '@/shell/MobileShell'; import { useBreakpoint } from '@/state/useBreakpoint'; const UI_VERSION = 'v2.0.0-rc1'; @@ -78,7 +79,16 @@ export function App() { activeAgent={null} graphVersion={`v${agents.data?.list.length ?? 0}`} /> - {breakpoint === 'tablet' ? ( + {breakpoint === 'mobile' ? ( + + ) : breakpoint === 'tablet' ? ( void; + title: string; + children: ReactNode; + testId?: string; +} + +const overlay: CSSProperties = { + position: 'fixed', + inset: 0, + background: 'rgba(21,17,10,0.30)', + backdropFilter: 'blur(2px)', + zIndex: 1000, +}; + +const content: CSSProperties = { + position: 'fixed', + left: 0, + right: 0, + bottom: 0, + height: 'min(85vh, 720px)', + background: 'var(--bg-page)', + borderTop: '1px solid var(--hair-strong)', + borderRadius: 0, + zIndex: 1001, + display: 'flex', + flexDirection: 'column', + boxShadow: 'var(--e-3)', + animation: 'asr-sheet-slide-up 220ms cubic-bezier(0.16, 1, 0.3, 1)', +}; + +const handle: CSSProperties = { + width: 44, height: 4, + background: 'var(--ink-4)', + margin: '8px auto 4px', + opacity: 0.35, +}; + +const titleRow: CSSProperties = { + height: 40, padding: '0 16px', + display: 'flex', alignItems: 'center', + borderBottom: '1px solid var(--hair)', +}; + +export function MobileSheet({ open, onOpenChange, title, children, testId }: MobileSheetProps) { + return ( + + + + +
+
+ + {title} + + × +
+
{children}
+ + + + ); +} diff --git a/web/src/shell/MobileShell.tsx b/web/src/shell/MobileShell.tsx new file mode 100644 index 0000000..d07a2d1 --- /dev/null +++ b/web/src/shell/MobileShell.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import type { CSSProperties } from 'react'; +import { SessionsRail } from '@/shell/SessionsRail'; +import { SessionCanvas } from '@/canvas/SessionCanvas'; +import { MonitorRail } from '@/monitors/MonitorRail'; +import { MobileSheet } from '@/shell/MobileSheet'; +import type { SessionSummary } from '@/state/useSessionList'; +import type { AgentDefinition, ToolCall } from '@/api/types'; + +interface MobileShellProps { + sessions: SessionSummary[]; + activeSid: string | null; + onSelectSession: (sid: string) => void; + queue: SessionSummary[]; + agentsByName: Record; + toolCalls: ToolCall[]; +} + +const shellWrap: CSSProperties = { + display: 'grid', + gridTemplateRows: '1fr auto', + minHeight: 0, +}; + +const tabBar: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + borderTop: '1px solid var(--hair-strong)', + background: 'var(--bg-elev)', + height: 56, +}; + +const tabBtn = (active: boolean): CSSProperties => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + fontFamily: 'var(--ff-mono)', + fontSize: 11, + letterSpacing: '0.12em', + textTransform: 'uppercase', + color: active ? 'var(--ink-1)' : 'var(--ink-3)', + background: 'transparent', + border: 'none', + borderTop: active ? '2px solid var(--acc)' : '2px solid transparent', + cursor: 'pointer', + padding: 0, +}); + +type ActiveSheet = 'sessions' | 'monitors' | null; + +export function MobileShell({ + sessions, activeSid, onSelectSession, queue, agentsByName, toolCalls, +}: MobileShellProps) { + const [sheet, setSheet] = useState(null); + + function selectAndClose(sid: string) { + onSelectSession(sid); + setSheet(null); + } + + return ( +
+
+ +
+ + setSheet(o ? 'sessions' : null)} + title="Sessions" + testId="sessions" + > + + + setSheet(o ? 'monitors' : null)} + title="Monitors" + testId="monitors" + > + + +
+ ); +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 7a7e5d4..2c55b1e 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -104,3 +104,8 @@ body { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } + +@keyframes asr-sheet-slide-up { + from { opacity: 0; transform: translateY(8%); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/web/tests/component/MobileSheet.test.tsx b/web/tests/component/MobileSheet.test.tsx new file mode 100644 index 0000000..47a6fb7 --- /dev/null +++ b/web/tests/component/MobileSheet.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { MobileSheet } from '@/shell/MobileSheet'; + +describe('', () => { + it('does not render content when closed', () => { + render( + {}} title="Sessions"> + body content + , + ); + expect(screen.queryByText('body content')).not.toBeInTheDocument(); + }); + + it('renders title + body + handle when open', () => { + render( + {}} title="Sessions" testId="sessions"> + body content + , + ); + expect(screen.getByText('Sessions')).toBeInTheDocument(); + expect(screen.getByText('body content')).toBeInTheDocument(); + expect(document.querySelector('[data-mobile-sheet="sessions"]')).toBeInTheDocument(); + }); + + it('calls onOpenChange(false) when Close is tapped', () => { + const onOpenChange = vi.fn(); + render( + + body + , + ); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/web/tests/component/MobileShell.test.tsx b/web/tests/component/MobileShell.test.tsx new file mode 100644 index 0000000..d4a6a86 --- /dev/null +++ b/web/tests/component/MobileShell.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { MobileShell } from '@/shell/MobileShell'; + +const ORIGINAL_FETCH = globalThis.fetch; + +beforeEach(() => { + globalThis.fetch = vi.fn(() => Promise.resolve({ + ok: true, status: 200, statusText: 'OK', + clone() { return this; }, + json: () => Promise.resolve({}), + } as unknown as Response)); +}); +afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; }); + +describe('', () => { + it('renders 3-tab bottom nav and canvas by default', () => { + render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + expect(screen.getByRole('navigation', { name: /mobile/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open sessions/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /show canvas/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open monitors/i })).toBeInTheDocument(); + expect(screen.getByText(/Select a session/i)).toBeInTheDocument(); + }); + + it('opens sessions sheet on tap', () => { + render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + expect(document.querySelector('[data-mobile-sheet="sessions"]')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /open sessions/i })); + expect(document.querySelector('[data-mobile-sheet="sessions"]')).toBeInTheDocument(); + }); + + it('opens monitors sheet on tap and closes via the sheet Close button', () => { + render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /open monitors/i })); + expect(document.querySelector('[data-mobile-sheet="monitors"]')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(document.querySelector('[data-mobile-sheet="monitors"]')).not.toBeInTheDocument(); + }); + + it('stamps data-shell="mobile" for layout snapshots', () => { + const { container } = render( + {}} + queue={[]} agentsByName={{}} toolCalls={[]} + />, + ); + expect(container.querySelector('[data-shell="mobile"]')).toBeInTheDocument(); + }); +});