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
42 changes: 42 additions & 0 deletions web/src/state/selectedRef.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectedRef | undefined>(undefined);
const SetSelectedRefContext = createContext<((ref: SelectedRef) => void) | undefined>(undefined);

export function SelectedRefProvider({ children }: { children: ReactNode }) {
const [ref, setRef] = useState<SelectedRef>(emptyRef);
const set = useCallback((next: SelectedRef) => setRef(next), []);
return (
<SelectedRefContext.Provider value={ref}>
<SetSelectedRefContext.Provider value={set}>
{children}
</SetSelectedRefContext.Provider>
</SelectedRefContext.Provider>
);
}

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;
}
67 changes: 67 additions & 0 deletions web/src/state/useSessionFull.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(sid !== null);
const [error, setError] = useState<ApiClientError | null>(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<SessionFullBundle>(`/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 };
}
69 changes: 69 additions & 0 deletions web/src/state/useSessionList.ts
Original file line number Diff line number Diff line change
@@ -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<SessionSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<ApiClientError | null>(null);
const [loaded, setLoaded] = useState(false);

useEffect(() => {
let cancelled = false;
apiFetch<SessionSummary[]>('/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 };
}
12 changes: 12 additions & 0 deletions web/src/state/useUiHints.ts
Original file line number Diff line number Diff line change
@@ -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<UiHints>('/config/ui-hints'),
staleTime: Infinity,
gcTime: Infinity,
});
}
45 changes: 45 additions & 0 deletions web/tests/unit/selectedRef.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <SelectedRefProvider>{children}</SelectedRefProvider>;
}

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/);
});
});
67 changes: 67 additions & 0 deletions web/tests/unit/useSessionFull.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
68 changes: 68 additions & 0 deletions web/tests/unit/useSessionList.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading