diff --git a/web/src/state/selectedRef.tsx b/web/src/state/selectedRef.tsx new file mode 100644 index 0000000..df8e8e9 --- /dev/null +++ b/web/src/state/selectedRef.tsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useState, useCallback } from 'react'; +import type { ReactNode } from 'react'; + +export type SelectedKind = 'agent' | 'tool_call' | 'message' | null; + +export interface SelectedRef { + kind: SelectedKind; + id?: string; +} + +const emptyRef: SelectedRef = { kind: null }; + +const SelectedRefContext = createContext(undefined); +const SetSelectedRefContext = createContext<((ref: SelectedRef) => void) | undefined>(undefined); + +export function SelectedRefProvider({ children }: { children: ReactNode }) { + const [ref, setRef] = useState(emptyRef); + const set = useCallback((next: SelectedRef) => setRef(next), []); + return ( + + + {children} + + + ); +} + +export function useSelected(): SelectedRef { + const ctx = useContext(SelectedRefContext); + if (ctx === undefined) { + throw new Error('useSelected must be used within a SelectedRefProvider'); + } + return ctx; +} + +export function useSetSelected(): (ref: SelectedRef) => void { + const ctx = useContext(SetSelectedRefContext); + if (ctx === undefined) { + throw new Error('useSetSelected must be used within a SelectedRefProvider'); + } + return ctx; +} diff --git a/web/src/state/useSessionFull.ts b/web/src/state/useSessionFull.ts new file mode 100644 index 0000000..b39e820 --- /dev/null +++ b/web/src/state/useSessionFull.ts @@ -0,0 +1,67 @@ +import { useEffect, useReducer, useState, useCallback } from 'react'; +import { apiFetch, ApiClientError } from '@/api/client'; +import { useEventSource } from '@/api/sse'; +import { sessionReducer, initialSessionState, type SessionState } from './sessionReducer'; +import type { SessionFullBundle, SessionEvent, SessionId } from '@/api/types'; + +export interface UseSessionFullResult { + state: SessionState; + isLoading: boolean; + error: ApiClientError | null; + refresh: () => void; +} + +export function useSessionFull(sid: SessionId | null): UseSessionFullResult { + const [state, dispatch] = useReducer(sessionReducer, initialSessionState); + const [isLoading, setIsLoading] = useState(sid !== null); + const [error, setError] = useState(null); + const [bootstrapped, setBootstrapped] = useState(false); + const [refreshTick, setRefreshTick] = useState(0); + + useEffect(() => { + if (!sid) { + setIsLoading(false); + setBootstrapped(false); + return; + } + let cancelled = false; + setIsLoading(true); + setError(null); + setBootstrapped(false); + apiFetch(`/sessions/${sid}/full`) + .then((bundle) => { + if (cancelled) return; + dispatch({ type: 'bootstrap', bundle }); + setBootstrapped(true); + setIsLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof ApiClientError) { + setError(err); + } else { + setError(new ApiClientError(0, 'network_error', String(err), {})); + } + setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [sid, refreshTick]); + + const onEvent = useCallback((ev: SessionEvent) => { + dispatch({ type: 'event', event: ev }); + }, []); + + useEventSource( + sid ? `/api/v1/sessions/${sid}/events` : '', + onEvent, + Boolean(sid) && bootstrapped, + ); + + const refresh = useCallback(() => { + setRefreshTick((n) => n + 1); + }, []); + + return { state, isLoading, error, refresh }; +} diff --git a/web/src/state/useSessionList.ts b/web/src/state/useSessionList.ts new file mode 100644 index 0000000..a9fd67e --- /dev/null +++ b/web/src/state/useSessionList.ts @@ -0,0 +1,69 @@ +import { useEffect, useState, useCallback } from 'react'; +import { apiFetch, ApiClientError } from '@/api/client'; +import { useEventSource } from '@/api/sse'; +import type { SessionEvent, SessionId } from '@/api/types'; + +export interface SessionSummary { + id: SessionId; + status: string; + label?: string; + created_at: string; + updated_at: string; + active_agent?: string | null; +} + +export interface UseSessionListResult { + sessions: SessionSummary[]; + isLoading: boolean; + error: ApiClientError | null; +} + +export function useSessionList(): UseSessionListResult { + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + apiFetch('/sessions') + .then((list) => { + if (cancelled) return; + setSessions(list); + setIsLoading(false); + setLoaded(true); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof ApiClientError) { + setError(err); + } else { + setError(new ApiClientError(0, 'network_error', String(err), {})); + } + setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const onEvent = useCallback((ev: SessionEvent) => { + if (ev.kind === 'session.created') { + const summary = ev.payload as unknown as SessionSummary; + setSessions((prev) => { + if (prev.find((s) => s.id === summary.id)) return prev; + return [summary, ...prev]; + }); + } else if (ev.kind === 'session.status_changed') { + const p = ev.payload as { id: string; status: string }; + setSessions((prev) => prev.map((s) => (s.id === p.id ? { ...s, status: p.status } : s))); + } else if (ev.kind === 'session.agent_running') { + const p = ev.payload as { id: string; agent: string | null }; + setSessions((prev) => prev.map((s) => (s.id === p.id ? { ...s, active_agent: p.agent } : s))); + } + }, []); + + useEventSource('/api/v1/sessions/recent/events', onEvent, loaded); + + return { sessions, isLoading, error }; +} diff --git a/web/src/state/useUiHints.ts b/web/src/state/useUiHints.ts new file mode 100644 index 0000000..c1cb481 --- /dev/null +++ b/web/src/state/useUiHints.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '@/api/client'; +import type { UiHints } from '@/api/types'; + +export function useUiHints() { + return useQuery({ + queryKey: ['ui-hints'], + queryFn: () => apiFetch('/config/ui-hints'), + staleTime: Infinity, + gcTime: Infinity, + }); +} diff --git a/web/tests/unit/selectedRef.test.tsx b/web/tests/unit/selectedRef.test.tsx new file mode 100644 index 0000000..61be508 --- /dev/null +++ b/web/tests/unit/selectedRef.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { SelectedRefProvider, useSelected, useSetSelected } from '@/state/selectedRef'; +import type { ReactNode } from 'react'; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('selectedRef', () => { + it('defaults to {kind: null}', () => { + const { result } = renderHook(() => useSelected(), { wrapper }); + expect(result.current).toEqual({ kind: null }); + }); + + it('setSelected updates the value', () => { + const { result } = renderHook( + () => ({ selected: useSelected(), set: useSetSelected() }), + { wrapper }, + ); + act(() => { + result.current.set({ kind: 'agent', id: 'intake' }); + }); + expect(result.current.selected).toEqual({ kind: 'agent', id: 'intake' }); + }); + + it('setSelected({kind: null}) clears the selection', () => { + const { result } = renderHook( + () => ({ selected: useSelected(), set: useSetSelected() }), + { wrapper }, + ); + act(() => { + result.current.set({ kind: 'tool_call', id: 'tool-1' }); + }); + expect(result.current.selected.kind).toBe('tool_call'); + act(() => { + result.current.set({ kind: null }); + }); + expect(result.current.selected).toEqual({ kind: null }); + }); + + it('throws when useSelected called outside Provider', () => { + expect(() => renderHook(() => useSelected())).toThrow(/SelectedRefProvider/); + }); +}); diff --git a/web/tests/unit/useSessionFull.test.ts b/web/tests/unit/useSessionFull.test.ts new file mode 100644 index 0000000..275be64 --- /dev/null +++ b/web/tests/unit/useSessionFull.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useSessionFull } from '@/state/useSessionFull'; +import type { SessionFullBundle } from '@/api/types'; +import { MockEventSource } from '../_helpers/MockEventSource'; + +const bundle: SessionFullBundle = { + session: { + id: 'SES-1', status: 'in_progress', + created_at: 't0', updated_at: 't0', deleted_at: null, + agents_run: [], tool_calls: [], findings: {}, + pending_intervention: null, user_inputs: [], + parent_session_id: null, dedup_rationale: null, + extra_fields: {}, version: 1, + }, + agents_run: [], tool_calls: [], events: [], + agent_definitions: {}, vm_seq: 0, +}; + +describe('useSessionFull', () => { + beforeEach(() => { + MockEventSource.reset(); + // @ts-expect-error global override + global.EventSource = MockEventSource; + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(bundle), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('fetches bootstrap then sets state.session.id', async () => { + const { result } = renderHook(() => useSessionFull('SES-1')); + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.state.session?.id).toBe('SES-1'); + expect(result.current.error).toBeNull(); + }); + + it('opens SSE stream after bootstrap and applies events', async () => { + const { result } = renderHook(() => useSessionFull('SES-1')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(MockEventSource.lastInstance()).toBeDefined()); + expect(MockEventSource.lastInstance()!.url).toBe('/api/v1/sessions/SES-1/events'); + + act(() => { + MockEventSource.lastInstance()!.emit( + JSON.stringify({ seq: 1, kind: 'status_changed', payload: { status: 'resolved' }, ts: 't1' }), + ); + }); + await waitFor(() => expect(result.current.state.session?.status).toBe('resolved')); + }); + + it('captures fetch error in error state', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error: { code: 'not_found', message: 'gone', details: {} } }), + { status: 404, headers: { 'content-type': 'application/json' } }, + ), + ); + const { result } = renderHook(() => useSessionFull('SES-x')); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.code).toBe('not_found'); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/web/tests/unit/useSessionList.test.ts b/web/tests/unit/useSessionList.test.ts new file mode 100644 index 0000000..7b60988 --- /dev/null +++ b/web/tests/unit/useSessionList.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useSessionList } from '@/state/useSessionList'; +import { MockEventSource } from '../_helpers/MockEventSource'; + +const sessions = [ + { id: 'SES-1', status: 'in_progress', label: 'foo', created_at: 't0', updated_at: 't0' }, + { id: 'SES-2', status: 'resolved', label: 'bar', created_at: 't0', updated_at: 't1' }, +]; + +describe('useSessionList', () => { + beforeEach(() => { + MockEventSource.reset(); + // @ts-expect-error global override + global.EventSource = MockEventSource; + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(sessions), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('fetches /sessions and exposes the list', async () => { + const { result } = renderHook(() => useSessionList()); + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.sessions).toHaveLength(2); + expect(result.current.sessions[0]?.id).toBe('SES-1'); + }); + + it('opens the recent-events SSE stream and prepends session.created', async () => { + const { result } = renderHook(() => useSessionList()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(MockEventSource.lastInstance()).toBeDefined()); + expect(MockEventSource.lastInstance()!.url).toBe('/api/v1/sessions/recent/events'); + + act(() => { + MockEventSource.lastInstance()!.emit( + JSON.stringify({ + seq: 1, kind: 'session.created', ts: 't2', + payload: { id: 'SES-3', status: 'new', label: 'baz', created_at: 't2', updated_at: 't2' }, + }), + ); + }); + await waitFor(() => expect(result.current.sessions).toHaveLength(3)); + expect(result.current.sessions[0]?.id).toBe('SES-3'); + }); + + it('updates an existing session status on session.status_changed', async () => { + const { result } = renderHook(() => useSessionList()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(MockEventSource.lastInstance()).toBeDefined()); + + act(() => { + MockEventSource.lastInstance()!.emit( + JSON.stringify({ + seq: 1, kind: 'session.status_changed', ts: 't3', + payload: { id: 'SES-1', status: 'resolved' }, + }), + ); + }); + await waitFor(() => { + const s = result.current.sessions.find((x) => x.id === 'SES-1'); + expect(s?.status).toBe('resolved'); + }); + }); +}); diff --git a/web/tests/unit/useUiHints.test.tsx b/web/tests/unit/useUiHints.test.tsx new file mode 100644 index 0000000..a3c491a --- /dev/null +++ b/web/tests/unit/useUiHints.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useUiHints } from '@/state/useUiHints'; + +const hints = { + brand_name: 'Acme Agents', + brand_logo_url: null, + approval_rationale_templates: ['Looks safe', 'Verified'], + hitl_question_templates: { 'rem:restart_service': 'Restart {service}?' }, + environments: ['dev', 'prod'], +}; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return {children}; +} + +describe('useUiHints', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(hints), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('fetches ui-hints once and exposes data', async () => { + const { result } = renderHook(() => useUiHints(), { wrapper }); + await waitFor(() => expect(result.current.data?.brand_name).toBe('Acme Agents')); + expect(result.current.isLoading).toBe(false); + }); + + it('hits /api/v1/config/ui-hints', async () => { + renderHook(() => useUiHints(), { wrapper }); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/config/ui-hints', + expect.objectContaining({ method: 'GET' }), + ); + }); +});