Skip to content

Commit aa0fa90

Browse files
authored
feat(web): React UI v2.0 — Phase 2 Tasks 26+27+30+31 (data hooks + selectedRef)
* feat(web): useSessionFull hook — bootstrap + live SSE composition * feat(web): useSessionList hook — list + cross-session SSE deltas * feat(web): useUiHints hook — react-query cache-once for /config/ui-hints * feat(web): selectedRef selection model (React Context)
1 parent ab16910 commit aa0fa90

8 files changed

Lines changed: 416 additions & 0 deletions

File tree

web/src/state/selectedRef.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createContext, useContext, useState, useCallback } from 'react';
2+
import type { ReactNode } from 'react';
3+
4+
export type SelectedKind = 'agent' | 'tool_call' | 'message' | null;
5+
6+
export interface SelectedRef {
7+
kind: SelectedKind;
8+
id?: string;
9+
}
10+
11+
const emptyRef: SelectedRef = { kind: null };
12+
13+
const SelectedRefContext = createContext<SelectedRef | undefined>(undefined);
14+
const SetSelectedRefContext = createContext<((ref: SelectedRef) => void) | undefined>(undefined);
15+
16+
export function SelectedRefProvider({ children }: { children: ReactNode }) {
17+
const [ref, setRef] = useState<SelectedRef>(emptyRef);
18+
const set = useCallback((next: SelectedRef) => setRef(next), []);
19+
return (
20+
<SelectedRefContext.Provider value={ref}>
21+
<SetSelectedRefContext.Provider value={set}>
22+
{children}
23+
</SetSelectedRefContext.Provider>
24+
</SelectedRefContext.Provider>
25+
);
26+
}
27+
28+
export function useSelected(): SelectedRef {
29+
const ctx = useContext(SelectedRefContext);
30+
if (ctx === undefined) {
31+
throw new Error('useSelected must be used within a SelectedRefProvider');
32+
}
33+
return ctx;
34+
}
35+
36+
export function useSetSelected(): (ref: SelectedRef) => void {
37+
const ctx = useContext(SetSelectedRefContext);
38+
if (ctx === undefined) {
39+
throw new Error('useSetSelected must be used within a SelectedRefProvider');
40+
}
41+
return ctx;
42+
}

web/src/state/useSessionFull.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useEffect, useReducer, useState, useCallback } from 'react';
2+
import { apiFetch, ApiClientError } from '@/api/client';
3+
import { useEventSource } from '@/api/sse';
4+
import { sessionReducer, initialSessionState, type SessionState } from './sessionReducer';
5+
import type { SessionFullBundle, SessionEvent, SessionId } from '@/api/types';
6+
7+
export interface UseSessionFullResult {
8+
state: SessionState;
9+
isLoading: boolean;
10+
error: ApiClientError | null;
11+
refresh: () => void;
12+
}
13+
14+
export function useSessionFull(sid: SessionId | null): UseSessionFullResult {
15+
const [state, dispatch] = useReducer(sessionReducer, initialSessionState);
16+
const [isLoading, setIsLoading] = useState<boolean>(sid !== null);
17+
const [error, setError] = useState<ApiClientError | null>(null);
18+
const [bootstrapped, setBootstrapped] = useState(false);
19+
const [refreshTick, setRefreshTick] = useState(0);
20+
21+
useEffect(() => {
22+
if (!sid) {
23+
setIsLoading(false);
24+
setBootstrapped(false);
25+
return;
26+
}
27+
let cancelled = false;
28+
setIsLoading(true);
29+
setError(null);
30+
setBootstrapped(false);
31+
apiFetch<SessionFullBundle>(`/sessions/${sid}/full`)
32+
.then((bundle) => {
33+
if (cancelled) return;
34+
dispatch({ type: 'bootstrap', bundle });
35+
setBootstrapped(true);
36+
setIsLoading(false);
37+
})
38+
.catch((err: unknown) => {
39+
if (cancelled) return;
40+
if (err instanceof ApiClientError) {
41+
setError(err);
42+
} else {
43+
setError(new ApiClientError(0, 'network_error', String(err), {}));
44+
}
45+
setIsLoading(false);
46+
});
47+
return () => {
48+
cancelled = true;
49+
};
50+
}, [sid, refreshTick]);
51+
52+
const onEvent = useCallback((ev: SessionEvent) => {
53+
dispatch({ type: 'event', event: ev });
54+
}, []);
55+
56+
useEventSource(
57+
sid ? `/api/v1/sessions/${sid}/events` : '',
58+
onEvent,
59+
Boolean(sid) && bootstrapped,
60+
);
61+
62+
const refresh = useCallback(() => {
63+
setRefreshTick((n) => n + 1);
64+
}, []);
65+
66+
return { state, isLoading, error, refresh };
67+
}

web/src/state/useSessionList.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useEffect, useState, useCallback } from 'react';
2+
import { apiFetch, ApiClientError } from '@/api/client';
3+
import { useEventSource } from '@/api/sse';
4+
import type { SessionEvent, SessionId } from '@/api/types';
5+
6+
export interface SessionSummary {
7+
id: SessionId;
8+
status: string;
9+
label?: string;
10+
created_at: string;
11+
updated_at: string;
12+
active_agent?: string | null;
13+
}
14+
15+
export interface UseSessionListResult {
16+
sessions: SessionSummary[];
17+
isLoading: boolean;
18+
error: ApiClientError | null;
19+
}
20+
21+
export function useSessionList(): UseSessionListResult {
22+
const [sessions, setSessions] = useState<SessionSummary[]>([]);
23+
const [isLoading, setIsLoading] = useState(true);
24+
const [error, setError] = useState<ApiClientError | null>(null);
25+
const [loaded, setLoaded] = useState(false);
26+
27+
useEffect(() => {
28+
let cancelled = false;
29+
apiFetch<SessionSummary[]>('/sessions')
30+
.then((list) => {
31+
if (cancelled) return;
32+
setSessions(list);
33+
setIsLoading(false);
34+
setLoaded(true);
35+
})
36+
.catch((err: unknown) => {
37+
if (cancelled) return;
38+
if (err instanceof ApiClientError) {
39+
setError(err);
40+
} else {
41+
setError(new ApiClientError(0, 'network_error', String(err), {}));
42+
}
43+
setIsLoading(false);
44+
});
45+
return () => {
46+
cancelled = true;
47+
};
48+
}, []);
49+
50+
const onEvent = useCallback((ev: SessionEvent) => {
51+
if (ev.kind === 'session.created') {
52+
const summary = ev.payload as unknown as SessionSummary;
53+
setSessions((prev) => {
54+
if (prev.find((s) => s.id === summary.id)) return prev;
55+
return [summary, ...prev];
56+
});
57+
} else if (ev.kind === 'session.status_changed') {
58+
const p = ev.payload as { id: string; status: string };
59+
setSessions((prev) => prev.map((s) => (s.id === p.id ? { ...s, status: p.status } : s)));
60+
} else if (ev.kind === 'session.agent_running') {
61+
const p = ev.payload as { id: string; agent: string | null };
62+
setSessions((prev) => prev.map((s) => (s.id === p.id ? { ...s, active_agent: p.agent } : s)));
63+
}
64+
}, []);
65+
66+
useEventSource('/api/v1/sessions/recent/events', onEvent, loaded);
67+
68+
return { sessions, isLoading, error };
69+
}

web/src/state/useUiHints.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { apiFetch } from '@/api/client';
3+
import type { UiHints } from '@/api/types';
4+
5+
export function useUiHints() {
6+
return useQuery({
7+
queryKey: ['ui-hints'],
8+
queryFn: () => apiFetch<UiHints>('/config/ui-hints'),
9+
staleTime: Infinity,
10+
gcTime: Infinity,
11+
});
12+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { SelectedRefProvider, useSelected, useSetSelected } from '@/state/selectedRef';
4+
import type { ReactNode } from 'react';
5+
6+
function wrapper({ children }: { children: ReactNode }) {
7+
return <SelectedRefProvider>{children}</SelectedRefProvider>;
8+
}
9+
10+
describe('selectedRef', () => {
11+
it('defaults to {kind: null}', () => {
12+
const { result } = renderHook(() => useSelected(), { wrapper });
13+
expect(result.current).toEqual({ kind: null });
14+
});
15+
16+
it('setSelected updates the value', () => {
17+
const { result } = renderHook(
18+
() => ({ selected: useSelected(), set: useSetSelected() }),
19+
{ wrapper },
20+
);
21+
act(() => {
22+
result.current.set({ kind: 'agent', id: 'intake' });
23+
});
24+
expect(result.current.selected).toEqual({ kind: 'agent', id: 'intake' });
25+
});
26+
27+
it('setSelected({kind: null}) clears the selection', () => {
28+
const { result } = renderHook(
29+
() => ({ selected: useSelected(), set: useSetSelected() }),
30+
{ wrapper },
31+
);
32+
act(() => {
33+
result.current.set({ kind: 'tool_call', id: 'tool-1' });
34+
});
35+
expect(result.current.selected.kind).toBe('tool_call');
36+
act(() => {
37+
result.current.set({ kind: null });
38+
});
39+
expect(result.current.selected).toEqual({ kind: null });
40+
});
41+
42+
it('throws when useSelected called outside Provider', () => {
43+
expect(() => renderHook(() => useSelected())).toThrow(/SelectedRefProvider/);
44+
});
45+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { renderHook, waitFor, act } from '@testing-library/react';
3+
import { useSessionFull } from '@/state/useSessionFull';
4+
import type { SessionFullBundle } from '@/api/types';
5+
import { MockEventSource } from '../_helpers/MockEventSource';
6+
7+
const bundle: SessionFullBundle = {
8+
session: {
9+
id: 'SES-1', status: 'in_progress',
10+
created_at: 't0', updated_at: 't0', deleted_at: null,
11+
agents_run: [], tool_calls: [], findings: {},
12+
pending_intervention: null, user_inputs: [],
13+
parent_session_id: null, dedup_rationale: null,
14+
extra_fields: {}, version: 1,
15+
},
16+
agents_run: [], tool_calls: [], events: [],
17+
agent_definitions: {}, vm_seq: 0,
18+
};
19+
20+
describe('useSessionFull', () => {
21+
beforeEach(() => {
22+
MockEventSource.reset();
23+
// @ts-expect-error global override
24+
global.EventSource = MockEventSource;
25+
global.fetch = vi.fn().mockResolvedValue(
26+
new Response(JSON.stringify(bundle), {
27+
status: 200,
28+
headers: { 'content-type': 'application/json' },
29+
}),
30+
);
31+
});
32+
33+
it('fetches bootstrap then sets state.session.id', async () => {
34+
const { result } = renderHook(() => useSessionFull('SES-1'));
35+
expect(result.current.isLoading).toBe(true);
36+
await waitFor(() => expect(result.current.isLoading).toBe(false));
37+
expect(result.current.state.session?.id).toBe('SES-1');
38+
expect(result.current.error).toBeNull();
39+
});
40+
41+
it('opens SSE stream after bootstrap and applies events', async () => {
42+
const { result } = renderHook(() => useSessionFull('SES-1'));
43+
await waitFor(() => expect(result.current.isLoading).toBe(false));
44+
await waitFor(() => expect(MockEventSource.lastInstance()).toBeDefined());
45+
expect(MockEventSource.lastInstance()!.url).toBe('/api/v1/sessions/SES-1/events');
46+
47+
act(() => {
48+
MockEventSource.lastInstance()!.emit(
49+
JSON.stringify({ seq: 1, kind: 'status_changed', payload: { status: 'resolved' }, ts: 't1' }),
50+
);
51+
});
52+
await waitFor(() => expect(result.current.state.session?.status).toBe('resolved'));
53+
});
54+
55+
it('captures fetch error in error state', async () => {
56+
global.fetch = vi.fn().mockResolvedValue(
57+
new Response(
58+
JSON.stringify({ error: { code: 'not_found', message: 'gone', details: {} } }),
59+
{ status: 404, headers: { 'content-type': 'application/json' } },
60+
),
61+
);
62+
const { result } = renderHook(() => useSessionFull('SES-x'));
63+
await waitFor(() => expect(result.current.error).not.toBeNull());
64+
expect(result.current.error?.code).toBe('not_found');
65+
expect(result.current.isLoading).toBe(false);
66+
});
67+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { renderHook, waitFor, act } from '@testing-library/react';
3+
import { useSessionList } from '@/state/useSessionList';
4+
import { MockEventSource } from '../_helpers/MockEventSource';
5+
6+
const sessions = [
7+
{ id: 'SES-1', status: 'in_progress', label: 'foo', created_at: 't0', updated_at: 't0' },
8+
{ id: 'SES-2', status: 'resolved', label: 'bar', created_at: 't0', updated_at: 't1' },
9+
];
10+
11+
describe('useSessionList', () => {
12+
beforeEach(() => {
13+
MockEventSource.reset();
14+
// @ts-expect-error global override
15+
global.EventSource = MockEventSource;
16+
global.fetch = vi.fn().mockResolvedValue(
17+
new Response(JSON.stringify(sessions), {
18+
status: 200,
19+
headers: { 'content-type': 'application/json' },
20+
}),
21+
);
22+
});
23+
24+
it('fetches /sessions and exposes the list', async () => {
25+
const { result } = renderHook(() => useSessionList());
26+
expect(result.current.isLoading).toBe(true);
27+
await waitFor(() => expect(result.current.isLoading).toBe(false));
28+
expect(result.current.sessions).toHaveLength(2);
29+
expect(result.current.sessions[0]?.id).toBe('SES-1');
30+
});
31+
32+
it('opens the recent-events SSE stream and prepends session.created', async () => {
33+
const { result } = renderHook(() => useSessionList());
34+
await waitFor(() => expect(result.current.isLoading).toBe(false));
35+
await waitFor(() => expect(MockEventSource.lastInstance()).toBeDefined());
36+
expect(MockEventSource.lastInstance()!.url).toBe('/api/v1/sessions/recent/events');
37+
38+
act(() => {
39+
MockEventSource.lastInstance()!.emit(
40+
JSON.stringify({
41+
seq: 1, kind: 'session.created', ts: 't2',
42+
payload: { id: 'SES-3', status: 'new', label: 'baz', created_at: 't2', updated_at: 't2' },
43+
}),
44+
);
45+
});
46+
await waitFor(() => expect(result.current.sessions).toHaveLength(3));
47+
expect(result.current.sessions[0]?.id).toBe('SES-3');
48+
});
49+
50+
it('updates an existing session status on session.status_changed', async () => {
51+
const { result } = renderHook(() => useSessionList());
52+
await waitFor(() => expect(result.current.isLoading).toBe(false));
53+
await waitFor(() => expect(MockEventSource.lastInstance()).toBeDefined());
54+
55+
act(() => {
56+
MockEventSource.lastInstance()!.emit(
57+
JSON.stringify({
58+
seq: 1, kind: 'session.status_changed', ts: 't3',
59+
payload: { id: 'SES-1', status: 'resolved' },
60+
}),
61+
);
62+
});
63+
await waitFor(() => {
64+
const s = result.current.sessions.find((x) => x.id === 'SES-1');
65+
expect(s?.status).toBe('resolved');
66+
});
67+
});
68+
});

0 commit comments

Comments
 (0)