chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchCatalog,
|
||||
fetchHarnesses,
|
||||
fetchPersistedSelection,
|
||||
persistSelection,
|
||||
} from './chat-api';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Every URL the client actually requested, across all calls. */
|
||||
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
}
|
||||
|
||||
describe('chat-api', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json([
|
||||
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||
]),
|
||||
);
|
||||
|
||||
const harnesses = await fetchHarnesses();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
|
||||
expect(harnesses).toEqual([
|
||||
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json({
|
||||
harnessId: 'pi',
|
||||
version: '2026-08-11',
|
||||
fingerprint: 'abc123',
|
||||
models: [
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
displayName: 'GPT-5',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCatalog('pi');
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error('expected ok catalog');
|
||||
expect(result.catalog.harnessId).toBe('pi');
|
||||
expect(result.catalog.models).toHaveLength(1);
|
||||
expect(result.catalog.models[0]).toMatchObject({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
availability: 'available',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json(
|
||||
{
|
||||
code: 'adapter_unavailable',
|
||||
message: 'raw gateway detail that must not leak verbatim',
|
||||
harnessId: 'attacker-echo',
|
||||
extra: { hostile: 'blob' },
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchCatalog('ghost');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error('expected unavailable result');
|
||||
expect(result.code).toBe('catalog_unavailable');
|
||||
// harnessId comes from the request, never the (untrusted) response body.
|
||||
expect(result.harnessId).toBe('ghost');
|
||||
expect(typeof result.message).toBe('string');
|
||||
// The raw response body is never rendered/returned verbatim.
|
||||
expect(JSON.stringify(result)).not.toContain('hostile');
|
||||
expect(JSON.stringify(result)).not.toContain('attacker-echo');
|
||||
});
|
||||
|
||||
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||
);
|
||||
await expect(fetchPersistedSelection()).resolves.toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({ selection: null }));
|
||||
await expect(fetchPersistedSelection()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||
);
|
||||
|
||||
const result = await persistSelection({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
|
||||
const init = call?.[1] as RequestInit;
|
||||
expect(String(init.method).toUpperCase()).toBe('PUT');
|
||||
// The body is exactly the structured tuple — harness/provider/model kept distinct.
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json(
|
||||
{
|
||||
code: 'model_unavailable',
|
||||
message: 'raw detail that must not leak',
|
||||
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
|
||||
},
|
||||
422,
|
||||
),
|
||||
);
|
||||
|
||||
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
|
||||
const result = await persistSelection(requested);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error('expected failed persist');
|
||||
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
|
||||
// The requested tuple is preserved unchanged — not replaced by the body's echo.
|
||||
expect(result.requested).toEqual(requested);
|
||||
expect(JSON.stringify(result)).not.toContain('raw detail');
|
||||
});
|
||||
|
||||
it('never requests any /api/providers* endpoint', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(json([]));
|
||||
await fetchHarnesses();
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
|
||||
);
|
||||
await fetchCatalog('pi');
|
||||
fetchMock.mockResolvedValue(json({ selection: null }));
|
||||
await fetchPersistedSelection();
|
||||
|
||||
for (const url of requestedUrls(fetchMock)) {
|
||||
expect(url).not.toContain('/api/providers');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
||||
* UI depends on. Every response body is untrusted and is normalized through the
|
||||
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
||||
* are mapped to typed, body-free error results so a raw gateway body is never
|
||||
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
||||
*
|
||||
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
||||
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
||||
*/
|
||||
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
||||
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||
|
||||
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
||||
* never a thrown raw body. */
|
||||
export type CatalogResult =
|
||||
| { ok: true; catalog: HarnessCatalog }
|
||||
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
||||
|
||||
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
||||
|
||||
/** A persist either confirms the stored tuple or reports a typed domain failure
|
||||
* that echoes back the exact tuple the caller requested. */
|
||||
export type SelectionPersistResult =
|
||||
| { ok: true; selection: HarnessSelection }
|
||||
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
||||
|
||||
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
||||
* never surfaced. */
|
||||
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
||||
|
||||
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
||||
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
||||
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
||||
* read from the body. */
|
||||
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
||||
if (typeof body === 'object' && body !== null && 'code' in body) {
|
||||
const code = (body as { code: unknown }).code;
|
||||
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
||||
}
|
||||
// Default to the more conservative "invalid" classification for anything
|
||||
// unrecognized rather than guessing "model_unavailable".
|
||||
return 'selection_invalid';
|
||||
}
|
||||
|
||||
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
||||
* normalizes to an empty list (the UI then has no harness to select). */
|
||||
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
||||
const response = await fetch('/api/harnesses', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
return asHarnessSummaries(await readJson(response));
|
||||
}
|
||||
|
||||
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
||||
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
||||
* fallback catalog or a rendered raw body. */
|
||||
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
||||
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'catalog_unavailable',
|
||||
// Scoped to the requested harness id, never the untrusted body's echo.
|
||||
harnessId,
|
||||
message: CATALOG_UNAVAILABLE_MESSAGE,
|
||||
};
|
||||
}
|
||||
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
||||
}
|
||||
|
||||
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
||||
* unset or malformed. */
|
||||
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
||||
const response = await fetch('/api/chat/preferences/selection', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const body = await readJson(response);
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
return asHarnessSelection((body as { selection?: unknown }).selection);
|
||||
}
|
||||
|
||||
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
||||
* On success returns the confirmed selection; on a typed domain failure (422)
|
||||
* or validation error, returns a typed result carrying the EXACT requested
|
||||
* tuple — never the body's echo — and never the raw body text. */
|
||||
export async function persistSelection(
|
||||
selection: HarnessSelection,
|
||||
): Promise<SelectionPersistResult> {
|
||||
const requested: HarnessSelection = {
|
||||
harnessId: selection.harnessId,
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
};
|
||||
const response = await fetch('/api/chat/preferences/selection', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requested),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await readJson(response);
|
||||
return {
|
||||
ok: false,
|
||||
code: safeSelectionCode(body),
|
||||
// Fixed copy only — the untrusted body's message is never surfaced.
|
||||
message: SELECTION_REJECTED_MESSAGE,
|
||||
requested,
|
||||
};
|
||||
}
|
||||
const body = await readJson(response);
|
||||
const confirmed =
|
||||
typeof body === 'object' && body !== null
|
||||
? asHarnessSelection((body as { selection?: unknown }).selection)
|
||||
: null;
|
||||
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
||||
// the server accepted the tuple, so the requested tuple is the source of truth.
|
||||
return { ok: true, selection: confirmed ?? requested };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandsPanel } from './commands-panel';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('CommandsPanel', () => {
|
||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
// Free-text server message claims a different, less alarming target
|
||||
// than what will actually be sent — the UI must not rely on this.
|
||||
message: 'This will only affect the staging environment.',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The exact frozen combined action is visible...
|
||||
expect(container?.textContent).toContain('/deploy');
|
||||
expect(container?.textContent).toContain('prod');
|
||||
// ...and the misleading server free-text is never shown next to it.
|
||||
expect(container?.textContent).not.toContain('staging environment');
|
||||
});
|
||||
|
||||
it('does not throw when a manifest commands entry is null', async () => {
|
||||
const manifest = {
|
||||
commands: [
|
||||
null,
|
||||
{
|
||||
name: 'model',
|
||||
aliases: [],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [null],
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container?.textContent).toContain('model');
|
||||
});
|
||||
|
||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||
});
|
||||
|
||||
it('renders skills from a skills-only manifest', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={{
|
||||
commands: [],
|
||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||
version: 1,
|
||||
}}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('brave-search');
|
||||
expect(container?.textContent).toContain('Search the web');
|
||||
});
|
||||
|
||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||
const approval = {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: { truthy: 'object' },
|
||||
approvalId: { also: 'object' },
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={approval}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||
button.textContent?.includes('Run approved command'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: false,
|
||||
message: 'Not authorized',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Not authorized');
|
||||
});
|
||||
|
||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Denied.');
|
||||
});
|
||||
|
||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Unknown model');
|
||||
expect(container?.textContent).toContain('Command failed.');
|
||||
});
|
||||
|
||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
||||
const hostileMessage = 'y'.repeat(50_000);
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = container?.textContent ?? '';
|
||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
||||
});
|
||||
|
||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||
const manifest = {
|
||||
commands: 'not-an-array',
|
||||
skills: null,
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import type { PendingApproval } from './use-chat-connection';
|
||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
||||
import { asNonEmptyString, asString } from './runtime-guards';
|
||||
import type {
|
||||
CommandManifest,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandResultPayload,
|
||||
} from '@/lib/chat-contract';
|
||||
|
||||
/** Stable fallback copy shown for a failed command only when the server's
|
||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
||||
* malformed — the structured contract reason itself is otherwise shown
|
||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||
|
||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
||||
* stored command:result message at ingestion, but this component must never
|
||||
* assume every caller went through that path — bounding again here means a
|
||||
* hostile/oversized message can never force an unbounded render. */
|
||||
function boundMessage(value: string): string {
|
||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
||||
: value;
|
||||
}
|
||||
|
||||
interface CommandsPanelProps {
|
||||
manifest: CommandManifest | null;
|
||||
results: SlashCommandResultPayload[];
|
||||
approval: SlashCommandApprovalResultPayload | null;
|
||||
pendingApproval: PendingApproval | null;
|
||||
hasConversation: boolean;
|
||||
onExecute: (input: { command: string; args?: string }) => void;
|
||||
onApprove: (input: { command: string; args?: string }) => void;
|
||||
onRunApproved: () => void;
|
||||
}
|
||||
|
||||
export function CommandsPanel({
|
||||
manifest,
|
||||
results,
|
||||
approval,
|
||||
pendingApproval,
|
||||
hasConversation,
|
||||
onExecute,
|
||||
onApprove,
|
||||
onRunApproved,
|
||||
}: CommandsPanelProps): ReactElement {
|
||||
const [command, setCommand] = useState('');
|
||||
const [args, setArgs] = useState('');
|
||||
|
||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||
// before storing `approval`, but a matching command string alone must
|
||||
// never be trusted here either — require the literal boolean `true` and a
|
||||
// non-empty string approvalId, not merely truthy values.
|
||||
const canRunApproved =
|
||||
approval?.success === true &&
|
||||
typeof approval.approvalId === 'string' &&
|
||||
approval.approvalId.length > 0 &&
|
||||
!!pendingApproval &&
|
||||
pendingApproval.command === approval.command;
|
||||
|
||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||
// both collections before mapping so a malformed manifest cannot throw.
|
||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||
|
||||
return (
|
||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||
{commands.length > 0 ? (
|
||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||
{commands.map((cmd, index) => (
|
||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{skills.length > 0 ? (
|
||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||
{skills.map((skill, index) => (
|
||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Command name"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="command"
|
||||
/>
|
||||
<input
|
||||
aria-label="Command arguments"
|
||||
value={args}
|
||||
onChange={(event) => setArgs(event.target.value)}
|
||||
placeholder="args (optional)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Run command
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Request approval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approval ? (
|
||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||
{/* A successful approval shows stable client copy only — never
|
||||
the server-controlled approval.message or echoed
|
||||
approval.command as the primary confirmation. The frozen local
|
||||
pendingApproval below (not this line) is the sole authoritative
|
||||
statement of what will run. A denial, by contrast, is not an
|
||||
execution authority and safely surfaces the guarded structured
|
||||
reason the server gave (e.g. "Not authorized"), falling back to
|
||||
a stable copy only when absent/malformed. */}
|
||||
<span>
|
||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
||||
</span>
|
||||
{canRunApproved && pendingApproval ? (
|
||||
<>
|
||||
{/* Authoritative frozen local command+args — what the click below
|
||||
will actually emit. The server's `approval` above is display-only
|
||||
and must never be trusted to represent the executed payload. */}
|
||||
<span>
|
||||
Will run: /{pendingApproval.command}{' '}
|
||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||
</span>
|
||||
<button type="button" onClick={onRunApproved}>
|
||||
Run approved command
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length > 0 ? (
|
||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||
{results.map((result, index) => (
|
||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||
{result.success
|
||||
? typeof result.message === 'string' && result.message
|
||||
? ` — ${boundMessage(result.message)}`
|
||||
: ''
|
||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||
import type { HarnessSelection } from '@/lib/types';
|
||||
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||
|
||||
interface ComposerProps {
|
||||
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
* throughout streaming — a superset of `streaming` that also covers the
|
||||
* pre-ack window where a second send could otherwise slip through. */
|
||||
sending: boolean;
|
||||
hasConversation: boolean;
|
||||
/** Structured harness/provider/model selection state. The composer never
|
||||
* accepts free-text provider/model — every sendable tuple is a validated,
|
||||
* persisted catalog entry, and the send projection is derived from it. */
|
||||
harness: HarnessSelectionValue;
|
||||
}
|
||||
|
||||
/** The distinct provider ids present in the current catalog, in first-seen
|
||||
* order — the provider select is catalog-derived, never a hardcoded list. */
|
||||
function providerOptions(harness: HarnessSelectionValue): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const model of harness.catalog?.models ?? []) {
|
||||
if (seen.has(model.providerId)) continue;
|
||||
seen.add(model.providerId);
|
||||
out.push(model.providerId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
onSend,
|
||||
onStop,
|
||||
streaming,
|
||||
sending,
|
||||
hasConversation,
|
||||
harness,
|
||||
}: ComposerProps): ReactElement {
|
||||
const [content, setContent] = useState('');
|
||||
const busy = streaming || sending;
|
||||
|
||||
function submit(): void {
|
||||
if (busy) return;
|
||||
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||
// selection can never emit, so provider/model never travel as free text.
|
||||
if (!harness.canSend || harness.persistedSelection === null) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
// Pass the validated, persisted selection tuple only. The hook derives the
|
||||
// wire projection (legacy `message` provider/model, or `turn:send`) from the
|
||||
// negotiated `chat:send-capability` protocol — never from flat caller input.
|
||||
const selection = harness.persistedSelection;
|
||||
const ok = onSend({ content: trimmed, selection });
|
||||
// Clear the input only when the send was accepted — a refused turn (e.g. a
|
||||
// failed idempotency mint) must retain the user's text so it is not lost.
|
||||
if (ok) setContent('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
// Scope the model options to the intentionally selected provider. With no
|
||||
// provider chosen (`providerId === ''`) nothing matches, so the model select
|
||||
// offers only the placeholder — never a cross-provider row.
|
||||
const models = (harness.catalog?.models ?? []).filter(
|
||||
(model) => model.providerId === harness.providerId,
|
||||
);
|
||||
// A collision-safe composite option identity covering the full provider+model
|
||||
// tuple. The controlled select mirrors the same identity so the exact catalog
|
||||
// row highlights (a bare modelId would collide across providers).
|
||||
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
|
||||
`${model.providerId}:${model.modelId}`;
|
||||
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="flex flex-col gap-2 border-t p-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
aria-label="Harness"
|
||||
value={harness.harnessId}
|
||||
onChange={(event) => harness.selectHarness(event.target.value)}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="">Select a harness…</option>
|
||||
{harness.harnesses.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Provider"
|
||||
value={harness.providerId}
|
||||
onChange={(event) => harness.selectProvider(event.target.value)}
|
||||
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="">Select a provider…</option>
|
||||
{providerOptions(harness).map((providerId) => (
|
||||
<option key={providerId} value={providerId}>
|
||||
{providerId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Model"
|
||||
value={selectedModelValue}
|
||||
onChange={(event) => {
|
||||
// Resolve the composite option identity back to the exact catalog
|
||||
// row and persist that row's own provider+model — never a bare id.
|
||||
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
||||
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
||||
}}
|
||||
disabled={harness.catalogUnavailable || models.length === 0}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="">Select a model…</option>
|
||||
{models.map((model) => (
|
||||
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
||||
{model.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{harness.catalogUnavailable ? (
|
||||
<p role="status" className="text-xs opacity-70">
|
||||
This harness catalog is currently unavailable.
|
||||
</p>
|
||||
) : null}
|
||||
{harness.isStale ? (
|
||||
<p role="status" className="text-xs opacity-70">
|
||||
The saved model is no longer available — pick another to continue.
|
||||
</p>
|
||||
) : null}
|
||||
{harness.persistError ? (
|
||||
<p role="alert" className="text-xs">
|
||||
{harness.persistError.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || busy || !harness.canSend}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Stop"
|
||||
disabled={!hasConversation || !streaming}
|
||||
onClick={onStop}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||
* flood any of these collections; caps keep memory/render cost flat instead
|
||||
* of growing unboundedly for the lifetime of the connection.
|
||||
*/
|
||||
|
||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||
export const MAX_STREAM_CHARS = 20_000;
|
||||
/** Max transcript turns retained (oldest dropped first). */
|
||||
export const MAX_MESSAGES = 500;
|
||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||
export const MAX_TOOLS = 200;
|
||||
/** Max slash-command results retained. */
|
||||
export const MAX_COMMAND_RESULTS = 200;
|
||||
/** Max commands/skills accepted from a single manifest push. */
|
||||
export const MAX_MANIFEST_ITEMS = 500;
|
||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||
/** Max characters retained for a single command:result message — a hostile
|
||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
||||
* success/failure reason into state (or, defensively, onto the page). */
|
||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||
|
||||
interface MessageTranscriptProps {
|
||||
messages: ChatTranscriptMessage[];
|
||||
streaming: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function MessageTranscript({
|
||||
messages,
|
||||
streaming,
|
||||
text,
|
||||
}: MessageTranscriptProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Conversation"
|
||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||
<span>{message.text}</span>
|
||||
{message.thinking ? (
|
||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{streaming ? (
|
||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">Assistant: </span>
|
||||
<span>{text || 'Thinking…'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Socket.IO payloads are only statically typed at the call site — a
|
||||
* misbehaving or compromised gateway can send anything at runtime. These
|
||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||
* child.
|
||||
*/
|
||||
import type {
|
||||
HarnessAuthState,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessModelAvailability,
|
||||
HarnessSelection,
|
||||
HarnessSummary,
|
||||
} from '@/lib/types';
|
||||
|
||||
export function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asString`, but an empty string also falls back — used for guarded
|
||||
* contract-provided reason strings (e.g. a denial or failure message) where
|
||||
* an empty string is not a meaningful value to display in place of the
|
||||
* stable fallback copy. */
|
||||
export function asNonEmptyString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||
* honest "unavailable" label instead. */
|
||||
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
|
||||
* payloads above — a misbehaving or compromised gateway can send anything. The
|
||||
* guards below normalize those bodies into the typed client shapes without ever
|
||||
* rendering a raw body, so a 404/422/malformed response can never inject an
|
||||
* object into React or a non-tuple into the selection state.
|
||||
*/
|
||||
|
||||
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
|
||||
* safest value (`unavailable`) for anything unrecognized. */
|
||||
export function asHarnessAuthState(value: unknown): HarnessAuthState {
|
||||
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
|
||||
? value
|
||||
: 'unavailable';
|
||||
}
|
||||
|
||||
/** Normalizes an untrusted `availability` to the closed set, defaulting to
|
||||
* `unavailable` so a malformed row can never present as sendable. */
|
||||
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
|
||||
return value === 'available' ? 'available' : 'unavailable';
|
||||
}
|
||||
|
||||
/** A tuple is valid only when all three ids are non-empty strings — a partial
|
||||
* or malformed selection is rejected (null) rather than half-adopted. */
|
||||
export function asHarnessSelection(value: unknown): HarnessSelection | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const harnessId = value.harnessId;
|
||||
const providerId = value.providerId;
|
||||
const modelId = value.modelId;
|
||||
if (
|
||||
typeof harnessId !== 'string' ||
|
||||
typeof providerId !== 'string' ||
|
||||
typeof modelId !== 'string' ||
|
||||
harnessId.length === 0 ||
|
||||
providerId.length === 0 ||
|
||||
modelId.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { harnessId, providerId, modelId };
|
||||
}
|
||||
|
||||
/** Normalizes an untrusted array into typed harness summaries, dropping any row
|
||||
* without a usable id. */
|
||||
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: HarnessSummary[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const id = asString(item.id);
|
||||
if (id.length === 0) continue;
|
||||
out.push({
|
||||
id,
|
||||
displayName: asNonEmptyString(item.displayName, id),
|
||||
capabilities: asStringArray(item.capabilities),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
|
||||
const selection = asHarnessSelection(value);
|
||||
if (selection === null || !isRecord(value)) return null;
|
||||
return {
|
||||
...selection,
|
||||
displayName: asNonEmptyString(value.displayName, selection.modelId),
|
||||
reasoningCapability: value.reasoningCapability === true,
|
||||
inputTypes: asStringArray(value.inputTypes),
|
||||
authState: asHarnessAuthState(value.authState),
|
||||
availability: asHarnessAvailability(value.availability),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes an untrusted catalog body into the typed client catalog. The
|
||||
* caller supplies `harnessId` (from the request path) so the returned catalog
|
||||
* is scoped to the harness that was actually requested, never a body-echoed id.
|
||||
* Malformed model rows are dropped rather than invalidating the whole catalog. */
|
||||
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const rawModels = Array.isArray(record.models) ? record.models : [];
|
||||
const models: HarnessCatalogEntry[] = [];
|
||||
for (const row of rawModels) {
|
||||
const entry = asHarnessCatalogEntry(row);
|
||||
if (entry !== null) models.push(entry);
|
||||
}
|
||||
return {
|
||||
harnessId,
|
||||
version: asString(record.version),
|
||||
fingerprint: asString(record.fingerprint),
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/** The single point of truth for what counts as a valid conversation ID
|
||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||
* string, nothing else. Every site that establishes or compares
|
||||
* `state.conversationId` against a raw socket payload must route through
|
||||
* this guard so a malformed first frame (null/object/number/empty string)
|
||||
* can never be adopted verbatim. */
|
||||
export function asConversationId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
||||
import { asString, asStringArray } from './runtime-guards';
|
||||
|
||||
interface SessionPanelProps {
|
||||
sessionInfo: SessionInfoPayload | null;
|
||||
onSetThinking: (level: string) => void;
|
||||
}
|
||||
|
||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||
|
||||
export function SessionPanel({
|
||||
sessionInfo,
|
||||
onSetThinking,
|
||||
}: SessionPanelProps): ReactElement | null {
|
||||
if (!sessionInfo) return null;
|
||||
|
||||
// The reducer already caps this before storing it, but the render site
|
||||
// defends independently — a hostile payload must never be able to force
|
||||
// this <select> to lay out an unbounded number of options.
|
||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
||||
0,
|
||||
MAX_MANIFEST_ITEMS,
|
||||
);
|
||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Session info"
|
||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||
>
|
||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>Thinking level</span>
|
||||
<select
|
||||
aria-label="Thinking level"
|
||||
value={
|
||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||
}
|
||||
onChange={(event) => {
|
||||
// The placeholder option is not a real, settable level — a
|
||||
// malformed availableThinkingLevels list must never let the
|
||||
// client emit set:thinking for it.
|
||||
if (!hasThinkingLevels) return;
|
||||
onSetThinking(event.target.value);
|
||||
}}
|
||||
>
|
||||
{hasThinkingLevels ? (
|
||||
availableThinkingLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{sessionInfo.routingDecision ? (
|
||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||
{asString(sessionInfo.routingDecision.reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||
|
||||
type ServerEvent = keyof ServerToClientEvents;
|
||||
type ClientEvent = keyof ClientToServerEvents;
|
||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||
|
||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
event: K;
|
||||
payload: ClientPayload<K>;
|
||||
}
|
||||
|
||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||
export interface FakeChatSocket {
|
||||
connected: boolean;
|
||||
/** Mirrors socket.io-client's `Socket.id`: the connection identity the server
|
||||
* echoes in a `chat:send-capability` payload. The generation-bound send
|
||||
* protocol accepts an advertisement only when `payload.connectionId === id`. */
|
||||
id: string;
|
||||
connect(): FakeChatSocket;
|
||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||
* missing a required field fails to compile instead of silently no-op'ing at
|
||||
* runtime.
|
||||
*/
|
||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||
* ServerToClientEvents contract, but real sockets always support them and
|
||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||
type LifecycleEvent = 'connect' | 'disconnect';
|
||||
|
||||
export function createFakeChatSocket(): {
|
||||
socket: FakeChatSocket;
|
||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||
emitted: EmittedEvent[];
|
||||
serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void;
|
||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||
simulateDisconnect(): void;
|
||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||
* instance after a transient disconnect: marks the socket connected again
|
||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||
* without clearing or replacing any listeners. A real reconnect is assigned
|
||||
* a fresh `Socket.id`; pass `nextId` to model that new connection identity
|
||||
* (defaults to the current id so existing callers are unaffected). */
|
||||
simulateReconnect(nextId?: string): void;
|
||||
} {
|
||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
|
||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||
// varies by K, which a single Map can't express); the generic signatures on the
|
||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||
const socket = {
|
||||
connected: false,
|
||||
id: 'socket-a',
|
||||
connect: vi.fn(function connect(this: void) {
|
||||
socket.connected = true;
|
||||
return socket;
|
||||
}),
|
||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)?.add(handler);
|
||||
return socket;
|
||||
}),
|
||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
listeners.get(event)?.delete(handler);
|
||||
return socket;
|
||||
}),
|
||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||
emitted.push({ event, payload } as EmittedEvent);
|
||||
return socket;
|
||||
}),
|
||||
} as unknown as FakeChatSocket;
|
||||
|
||||
function serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: unknown) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function simulateDisconnect(): void {
|
||||
socket.connected = false;
|
||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
function simulateReconnect(nextId: string = socket.id): void {
|
||||
socket.connected = true;
|
||||
socket.id = nextId;
|
||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
listeners,
|
||||
emitted,
|
||||
serverEmit,
|
||||
serverEmitRaw,
|
||||
simulateDisconnect,
|
||||
simulateReconnect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { ToolCallList } from './tool-call-list';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ToolCallList', () => {
|
||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await render(
|
||||
<ToolCallList
|
||||
tools={[
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.textContent).toContain('success');
|
||||
expect(items[1]?.textContent).toContain('running');
|
||||
|
||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||
);
|
||||
expect(duplicateKeyWarning).toBe(false);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ToolCallState } from './use-chat-connection';
|
||||
|
||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||
{tools.map((tool, index) => (
|
||||
<li
|
||||
// A valid server-controlled toolCallId can legitimately repeat
|
||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||
// would give React two identical keys. Pairing it with its
|
||||
// (stable, append-only) render index keeps every key unique.
|
||||
key={`${tool.toolCallId}-${index}`}
|
||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||
>
|
||||
{tool.toolName} —{' '}
|
||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
import { act, type ReactElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
harnesses?: unknown;
|
||||
catalog?: { body: unknown; status?: number };
|
||||
selection?: unknown;
|
||||
/** When set, the PUT resolves only when this is called (for race tests). */
|
||||
deferPut?: boolean;
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function defer<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let putBodies: unknown[] = [];
|
||||
let putDeferred: Deferred<Response> | null = null;
|
||||
|
||||
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
|
||||
putBodies = [];
|
||||
putDeferred = scenario.deferPut ? defer<Response>() : null;
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
|
||||
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||
const spec = scenario.catalog ?? {
|
||||
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
|
||||
};
|
||||
return json(spec.body, spec.status ?? 200);
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||
return json({ selection: scenario.selection ?? null });
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||
putBodies.push(JSON.parse(String(init?.body)));
|
||||
const ok = json({ selection: JSON.parse(String(init?.body)) });
|
||||
if (putDeferred) return putDeferred.promise;
|
||||
return ok;
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
let latest: HarnessSelectionValue | null = null;
|
||||
|
||||
function Probe(): ReactElement | null {
|
||||
latest = useHarnessSelection();
|
||||
return null;
|
||||
}
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
latest = null;
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function mount(): Promise<void> {
|
||||
await act(async () => {
|
||||
root?.render(<Probe />);
|
||||
});
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function flush(times = 5): Promise<void> {
|
||||
for (let i = 0; i < times; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function value(): HarnessSelectionValue {
|
||||
if (!latest) throw new Error('hook value not captured');
|
||||
return latest;
|
||||
}
|
||||
|
||||
const PI_CATALOG = {
|
||||
harnessId: 'pi',
|
||||
version: '2026-08-11',
|
||||
fingerprint: 'fp',
|
||||
models: [
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
displayName: 'GPT-5',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
displayName: 'Claude',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('useHarnessSelection', () => {
|
||||
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
|
||||
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||
expect(value().catalog).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().catalog?.harnessId).toBe('pi');
|
||||
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
|
||||
});
|
||||
|
||||
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
|
||||
const fetchMock = installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().modelId).toBe('');
|
||||
expect(value().persistedSelection).toBeNull();
|
||||
expect(value().canSend).toBe(false);
|
||||
// Nothing was persisted — no PUT fired for an unset selection.
|
||||
const putCalls = fetchMock.mock.calls.filter(
|
||||
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
|
||||
);
|
||||
expect(putCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
deferPut: true,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
value().selectProvider('openai');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('openai', 'gpt-5');
|
||||
});
|
||||
await flush();
|
||||
|
||||
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
putDeferred?.resolve(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(value().canSend).toBe(true);
|
||||
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
|
||||
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
|
||||
// harnessId must never be dropped on the way to the wire.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
|
||||
});
|
||||
await mount();
|
||||
|
||||
// The persisted tuple is displayed even though its model is gone from the catalog.
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'retired-model',
|
||||
});
|
||||
expect(value().modelId).toBe('retired-model');
|
||||
expect(value().isStale).toBe(true);
|
||||
// A stale model is not a valid catalog option, so send stays disabled.
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().catalog?.models ?? []).toHaveLength(0);
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: {
|
||||
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
|
||||
status: 404,
|
||||
},
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().catalogUnavailable).toBe(true);
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: {
|
||||
body: {
|
||||
...PI_CATALOG,
|
||||
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
|
||||
},
|
||||
},
|
||||
selection: null,
|
||||
});
|
||||
// Override PUT to 422.
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
if (url === '/api/harnesses')
|
||||
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||
if (url.endsWith('/catalog')) return json(PI_CATALOG);
|
||||
if (url === '/api/chat/preferences/selection' && method === 'GET')
|
||||
return json({ selection: null });
|
||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||
return json(
|
||||
{
|
||||
code: 'model_unavailable',
|
||||
message: 'nope',
|
||||
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
return new Response('nf', { status: 404 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
value().selectProvider('openai');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('openai', 'gpt-5');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().modelId).toBe('gpt-5');
|
||||
expect(value().persistError?.code).toBe('model_unavailable');
|
||||
expect(value().persistError?.requested).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(value().persistedSelection).toBeNull();
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
value().selectProvider('openai');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('openai', 'gpt-5');
|
||||
});
|
||||
await flush();
|
||||
// A valid provider-A tuple has persisted.
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
|
||||
// Switching provider clears the model that no longer belongs to it.
|
||||
await act(async () => {
|
||||
value().selectProvider('anthropic');
|
||||
});
|
||||
expect(value().modelId).toBe('');
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
// Send stays disabled until the new exact provider-B tuple persists.
|
||||
await act(async () => {
|
||||
value().selectModel('anthropic', 'claude');
|
||||
});
|
||||
await flush();
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
});
|
||||
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||
deferPut: true,
|
||||
});
|
||||
await mount();
|
||||
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
|
||||
expect(value().canSend).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
value().selectProvider('anthropic');
|
||||
});
|
||||
expect(value().modelId).toBe('');
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
value().selectModel('anthropic', 'claude');
|
||||
});
|
||||
await flush();
|
||||
// PUT for the new tuple is still in flight — send MUST stay disabled.
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
putDeferred?.resolve(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
expect(value().canSend).toBe(true);
|
||||
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||
const fetchMock = installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectProvider('anthropic');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('anthropic', 'claude');
|
||||
});
|
||||
await flush();
|
||||
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
expect(String(call[0])).not.toContain('/api/providers');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
fetchCatalog,
|
||||
fetchHarnesses,
|
||||
fetchPersistedSelection,
|
||||
persistSelection,
|
||||
type SelectionErrorCode,
|
||||
} from './chat-api';
|
||||
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||
|
||||
export interface HarnessPersistError {
|
||||
code: SelectionErrorCode;
|
||||
message: string;
|
||||
/** The exact tuple the user requested — preserved so the failed selection
|
||||
* stays visible rather than being silently dropped. */
|
||||
requested: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface HarnessSelectionValue {
|
||||
harnesses: HarnessSummary[];
|
||||
catalog: HarnessCatalog | null;
|
||||
/** True when the selected harness has no usable catalog (404/error). */
|
||||
catalogUnavailable: boolean;
|
||||
/** The working (displayed) selection, kept as three distinct ids. Empty
|
||||
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
||||
harnessId: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
/** The last tuple confirmed persisted by the server, or null. */
|
||||
persistedSelection: HarnessSelection | null;
|
||||
/** True when a persisted selection references a model no longer present as an
|
||||
* available catalog entry — it stays visibly displayed rather than dropped. */
|
||||
isStale: boolean;
|
||||
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
||||
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
||||
* can never race ahead of successful persistence. */
|
||||
canSend: boolean;
|
||||
persistError: HarnessPersistError | null;
|
||||
selectHarness: (harnessId: string) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
||||
* resolves the composite option identity to the real entry and passes both
|
||||
* ids, so a bare model id is never combined with ambient provider state. */
|
||||
selectModel: (providerId: string, modelId: string) => void;
|
||||
}
|
||||
|
||||
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||
* matching, available entry — the single gate that keeps a stale/unavailable
|
||||
* model from ever counting as sendable. */
|
||||
function isAvailableInCatalog(
|
||||
selection: HarnessSelection | null,
|
||||
catalog: HarnessCatalog | null,
|
||||
): boolean {
|
||||
if (selection === null || catalog === null) return false;
|
||||
return catalog.models.some(
|
||||
(model) =>
|
||||
model.providerId === selection.providerId &&
|
||||
model.modelId === selection.modelId &&
|
||||
model.availability === 'available',
|
||||
);
|
||||
}
|
||||
|
||||
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
||||
if (a === null || b === null) return a === b;
|
||||
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the harness/catalog/selection state for the chat composer: loads the
|
||||
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
||||
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
||||
* when a model is picked. It never auto-selects a catalog row, keeps a
|
||||
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
||||
* once a full tuple has actually persisted as an available catalog entry.
|
||||
*/
|
||||
export function useHarnessSelection(): HarnessSelectionValue {
|
||||
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
||||
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
||||
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
||||
const [harnessId, setHarnessId] = useState('');
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
||||
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
||||
|
||||
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
||||
// overwrite the result of a newer request the user has since triggered.
|
||||
const catalogRequestRef = useRef(0);
|
||||
const persistRequestRef = useRef(0);
|
||||
|
||||
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
||||
const requestId = catalogRequestRef.current + 1;
|
||||
catalogRequestRef.current = requestId;
|
||||
setCatalog(null);
|
||||
setCatalogUnavailable(false);
|
||||
const result = await fetchCatalog(id);
|
||||
if (catalogRequestRef.current !== requestId) return;
|
||||
if (result.ok) {
|
||||
setCatalog(result.catalog);
|
||||
setCatalogUnavailable(false);
|
||||
} else {
|
||||
setCatalog(null);
|
||||
setCatalogUnavailable(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async (): Promise<void> => {
|
||||
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
||||
if (!active) return;
|
||||
setHarnesses(list);
|
||||
if (persisted !== null) {
|
||||
// Adopt the persisted tuple as the displayed selection and load its
|
||||
// catalog. If the model has since been retired, it still shows (stale).
|
||||
setHarnessId(persisted.harnessId);
|
||||
setProviderId(persisted.providerId);
|
||||
setModelId(persisted.modelId);
|
||||
setPersistedSelection(persisted);
|
||||
await loadCatalog(persisted.harnessId);
|
||||
}
|
||||
// No persisted selection → nothing is auto-selected; the user must choose.
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [loadCatalog]);
|
||||
|
||||
const selectHarness = useCallback(
|
||||
(id: string): void => {
|
||||
setHarnessId(id);
|
||||
// Changing harness invalidates the provider/model draft — never carry a
|
||||
// model across harnesses.
|
||||
setProviderId('');
|
||||
setModelId('');
|
||||
setPersistError(null);
|
||||
void loadCatalog(id);
|
||||
},
|
||||
[loadCatalog],
|
||||
);
|
||||
|
||||
const selectProvider = useCallback((id: string): void => {
|
||||
setProviderId(id);
|
||||
// A new provider invalidates the chosen model — no cross-provider carryover.
|
||||
setModelId('');
|
||||
setPersistError(null);
|
||||
}, []);
|
||||
|
||||
const selectModel = useCallback(
|
||||
(selectedProviderId: string, selectedModelId: string): void => {
|
||||
// Bind the model to the EXACT catalog row's provider — never to ambient
|
||||
// provider state — so two providers exposing the same modelId can never
|
||||
// collide or mis-resolve. Keep the displayed provider consistent with the
|
||||
// resolved row.
|
||||
setProviderId(selectedProviderId);
|
||||
setModelId(selectedModelId);
|
||||
setPersistError(null);
|
||||
const requested: HarnessSelection = {
|
||||
harnessId,
|
||||
providerId: selectedProviderId,
|
||||
modelId: selectedModelId,
|
||||
};
|
||||
const requestId = persistRequestRef.current + 1;
|
||||
persistRequestRef.current = requestId;
|
||||
void (async (): Promise<void> => {
|
||||
const result = await persistSelection(requested);
|
||||
if (persistRequestRef.current !== requestId) return;
|
||||
if (result.ok) {
|
||||
setPersistedSelection(result.selection);
|
||||
setPersistError(null);
|
||||
} else {
|
||||
// Leave persistedSelection unchanged (send stays disabled) and surface
|
||||
// the typed error carrying the exact requested tuple.
|
||||
setPersistError({
|
||||
code: result.code,
|
||||
message: result.message,
|
||||
requested: result.requested,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[harnessId],
|
||||
);
|
||||
|
||||
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
||||
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
||||
const canSend =
|
||||
persistedSelection !== null &&
|
||||
!catalogUnavailable &&
|
||||
tuplesEqual(draft, persistedSelection) &&
|
||||
isAvailableInCatalog(persistedSelection, catalog);
|
||||
|
||||
return {
|
||||
harnesses,
|
||||
catalog,
|
||||
catalogUnavailable,
|
||||
harnessId,
|
||||
providerId,
|
||||
modelId,
|
||||
persistedSelection,
|
||||
isStale,
|
||||
canSend,
|
||||
persistError,
|
||||
selectHarness,
|
||||
selectProvider,
|
||||
selectModel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { AuthGuard, GuestGuard } from './guards';
|
||||
|
||||
interface RenderedRouter {
|
||||
container: HTMLDivElement;
|
||||
router: ReturnType<typeof createMemoryRouter>;
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
async function renderRouter(
|
||||
routeObjects: RouteObject[],
|
||||
initialEntry: string,
|
||||
): Promise<RenderedRouter> {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return { container, router };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
const guestRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <GuestGuard />,
|
||||
children: [{ index: true, element: <p>Guest page</p> }],
|
||||
},
|
||||
{ path: '/chat', element: <p>Chat page</p> },
|
||||
];
|
||||
|
||||
const authenticatedRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/chat',
|
||||
element: <AuthGuard />,
|
||||
children: [{ index: true, element: <p>Private page</p> }],
|
||||
},
|
||||
{ path: '/login', element: <p>Login page</p> },
|
||||
];
|
||||
|
||||
describe('GuestGuard', () => {
|
||||
it('renders the guest outlet while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the guest outlet when no session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects an authenticated session to chat', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Chat page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Loading...');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
|
||||
it('redirects an unauthenticated visitor to login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Login page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the authenticated outlet when a session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Private page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
export function GuestGuard(): ReactElement {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function AuthGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export function AdminGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const user = session.user as typeof session.user & { role?: string };
|
||||
|
||||
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: {},
|
||||
}));
|
||||
|
||||
import { AdminPage } from './admin';
|
||||
import { AdminGuard } from '@/spa/guards';
|
||||
|
||||
const userFixtures = {
|
||||
users: [
|
||||
{
|
||||
id: 'u-admin',
|
||||
name: 'Ada Admin',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
banned: false,
|
||||
banReason: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'u-member',
|
||||
name: 'Mel Member',
|
||||
email: '[email protected]',
|
||||
role: 'member',
|
||||
banned: true,
|
||||
banReason: 'spam',
|
||||
createdAt: '2026-08-02T00:00:00.000Z',
|
||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderAdminRoute(): Promise<void> {
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
{ path: '/', element: <div>home page</div> },
|
||||
{ path: '/login', element: <div>login page</div> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
||||
return {
|
||||
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
it('redirects unauthenticated visitors to /login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('login page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects non-admin users to /', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('home page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the admin page for admin users', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('Admin Panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage users tab', () => {
|
||||
it('lists users with role and ban status after load', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
expect(container.textContent).toContain('Mel Member');
|
||||
expect(container.textContent).toContain('Banned');
|
||||
expect(container.textContent).toContain('2 user(s)');
|
||||
});
|
||||
|
||||
it('shows the load error with a retry control', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('gateway unavailable');
|
||||
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
const retry = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('Retry'),
|
||||
);
|
||||
expect(retry).toBeTruthy();
|
||||
await act(async () => {
|
||||
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
});
|
||||
|
||||
it('posts to the ban endpoint and reloads on Ban', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValue(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const banButton = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === 'Ban',
|
||||
);
|
||||
expect(banButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage health tab', () => {
|
||||
it('loads health status when the tab is opened', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
database: { status: 'ok', latencyMs: 3 },
|
||||
cache: { status: 'ok', latencyMs: 1 },
|
||||
agentPool: { activeSessions: 2 },
|
||||
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
||||
checkedAt: '2026-08-26T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('System Health'),
|
||||
);
|
||||
await act(async () => {
|
||||
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
||||
expect(container.textContent).toContain('Database (PostgreSQL)');
|
||||
expect(container.textContent).toContain('Active sessions: 2');
|
||||
expect(container.textContent).toContain('4 models');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
banReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface UserListDto {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ServiceStatusDto {
|
||||
status: 'ok' | 'error';
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProviderStatusDto {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
interface HealthStatusDto {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
database: ServiceStatusDto;
|
||||
cache: ServiceStatusDto;
|
||||
agentPool: { activeSessions: number };
|
||||
providers: ProviderStatusDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
||||
// assumes an authenticated admin session.
|
||||
export function AdminPage(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{(['users', 'health'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{tab === 'users' ? 'User Management' : 'System Health'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function UsersTab(): React.ReactElement {
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<UserListDto>('/api/admin/users');
|
||||
setUsers(data.users);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
||||
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/role`, {
|
||||
method: 'PATCH',
|
||||
body: { role: newRole },
|
||||
});
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBanToggle(user: UserDto): Promise<void> {
|
||||
const endpoint = user.banned ? 'unban' : 'ban';
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(user: UserDto): Promise<void> {
|
||||
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-text-muted">Loading users...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadUsers()}
|
||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
+ New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateUserForm
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreated={() => {
|
||||
setShowCreate(false);
|
||||
void loadUsers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||
<p className="text-sm text-text-muted">No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Name / Email</th>
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
||||
<th className="px-4 py-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
||||
<div className="text-xs text-text-muted">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-surface-elevated text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 md:table-cell">
|
||||
{user.banned ? (
|
||||
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
Banned
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRoleToggle(user)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBanToggle(user)}
|
||||
className={cn(
|
||||
'text-xs',
|
||||
user.banned
|
||||
? 'text-green-400 hover:text-green-300'
|
||||
: 'text-yellow-400 hover:text-yellow-300',
|
||||
)}
|
||||
>
|
||||
{user.banned ? 'Unban' : 'Ban'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(user)}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create User Form ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateUserFormProps {
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('member');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: { name, email, password, role },
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function HealthTab(): React.ReactElement {
|
||||
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<HealthStatusDto>('/api/admin/health');
|
||||
setHealth(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load health');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHealth()}
|
||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!health) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={health.status} />
|
||||
<span className="text-sm text-text-muted">
|
||||
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHealth()}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Database */}
|
||||
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
||||
{health.database.latencyMs !== undefined && (
|
||||
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
||||
)}
|
||||
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
||||
</HealthCard>
|
||||
|
||||
{/* Cache */}
|
||||
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
||||
{health.cache.latencyMs !== undefined && (
|
||||
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
||||
)}
|
||||
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
||||
</HealthCard>
|
||||
|
||||
{/* Agent Pool */}
|
||||
<HealthCard title="Agent Pool" status="ok">
|
||||
<p className="text-xs text-text-muted">
|
||||
Active sessions: {health.agentPool.activeSessions}
|
||||
</p>
|
||||
</HealthCard>
|
||||
|
||||
{/* Providers */}
|
||||
<HealthCard
|
||||
title="LLM Providers"
|
||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
||||
>
|
||||
{health.providers.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">No providers configured</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{health.providers.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-secondary">{p.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5',
|
||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
||||
)}
|
||||
>
|
||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</HealthCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
||||
const map = {
|
||||
ok: 'bg-green-500/20 text-green-400',
|
||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||
error: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
return (
|
||||
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface HealthCardProps {
|
||||
title: string;
|
||||
status: 'ok' | 'error';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
||||
<span
|
||||
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('render blew up');
|
||||
}
|
||||
|
||||
/** Recursively clones the real exported route table, replacing only the
|
||||
* `/chat` route's `element` with `<Boom />` — every other route (including
|
||||
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
||||
* exactly as exported. This is what makes the test fail if a future change
|
||||
* removes the real route's `errorElement`, unlike a hand-built independent
|
||||
* route tree that could drift from production undetected. */
|
||||
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === '/chat') {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceChatElementWithBoom(cloned.children);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('ChatRouteErrorBoundary', () => {
|
||||
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const routeObjects = replaceChatElementWithBoom(routes);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toBe('');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
||||
* manifests) that can carry malformed payloads no compile-time contract can
|
||||
* fully rule out at every dereference site. This is the last line of
|
||||
* defense: if something still throws during render, show a recoverable
|
||||
* alert instead of leaving the user on a blank/white screen.
|
||||
*/
|
||||
export function ChatRouteErrorBoundary(): ReactElement {
|
||||
useRouteError();
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
||||
<a href="/chat" className="text-sm underline">
|
||||
Reload chat
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||
import { Composer } from '@/spa/chat/composer';
|
||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||
import { useHarnessSelection } from '@/spa/chat/use-harness-selection';
|
||||
|
||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||
* an unknown token count must never look the same. */
|
||||
function formatTokens(value: unknown): string {
|
||||
const tokens = asFiniteNumberOrNull(value);
|
||||
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
||||
}
|
||||
|
||||
/** Same honesty guarantee as `formatTokens`, for cost. */
|
||||
function formatCost(value: unknown): string {
|
||||
const cost = asFiniteNumberOrNull(value);
|
||||
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
const harness = useHarnessSelection();
|
||||
const hasConversation = state.conversationId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h1 className="text-lg font-semibold">Chat</h1>
|
||||
</header>
|
||||
|
||||
{state.systemReload ? (
|
||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.systemReload.message)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? (
|
||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.ack ? (
|
||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
||||
{asString(state.ack.messageId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||
|
||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||
|
||||
{state.thinking ? (
|
||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||
{state.thinking}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ToolCallList tools={state.tools} />
|
||||
|
||||
{state.usage ? (
|
||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
||||
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommandsPanel
|
||||
manifest={state.manifest}
|
||||
results={state.commandResults}
|
||||
approval={state.approval}
|
||||
pendingApproval={state.pendingApproval}
|
||||
hasConversation={hasConversation}
|
||||
onExecute={actions.executeCommand}
|
||||
onApprove={actions.approveCommand}
|
||||
onRunApproved={actions.runApprovedCommand}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
onSend={actions.sendMessage}
|
||||
onStop={actions.abort}
|
||||
streaming={state.streaming}
|
||||
sending={state.sending}
|
||||
hasConversation={hasConversation}
|
||||
harness={harness}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function LoginPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||
(providers) => {
|
||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||
},
|
||||
() => {
|
||||
if (active) setSsoProviders([]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Mission, Project, Task } from '@/lib/types';
|
||||
|
||||
export const projectFixtures: Project[] = [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'Mosaic Stack',
|
||||
description: 'Gateway and dashboard parity work',
|
||||
status: 'active',
|
||||
userId: 'user-1',
|
||||
metadata: {
|
||||
prd: '# Mosaic Stack PRD\n\n## Objective\n\nShip the SPA route parity pages.',
|
||||
},
|
||||
createdAt: '2026-08-01T12:00:00.000Z',
|
||||
updatedAt: '2026-08-09T18:30:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'project-2',
|
||||
name: 'Agent Runtime',
|
||||
description: 'Pi SDK integration',
|
||||
status: 'paused',
|
||||
userId: 'user-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-02T08:00:00.000Z',
|
||||
updatedAt: '2026-08-05T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const missionFixtures: Mission[] = [
|
||||
{
|
||||
id: 'mission-1',
|
||||
name: 'Ship web parity',
|
||||
description: 'Port read-only routes into the SPA',
|
||||
status: 'active',
|
||||
projectId: 'project-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-03T12:00:00.000Z',
|
||||
updatedAt: '2026-08-09T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'mission-2',
|
||||
name: 'Unrelated mission',
|
||||
description: 'Must be filtered out of the project detail view',
|
||||
status: 'planning',
|
||||
projectId: 'project-2',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-04T12:00:00.000Z',
|
||||
updatedAt: '2026-08-04T12:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const taskFixtures: Task[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Route /projects',
|
||||
description: 'Port the read-only projects listing into the SPA',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
projectId: 'project-1',
|
||||
missionId: 'mission-1',
|
||||
assignee: 'Jarvis',
|
||||
tags: ['spa', 'projects'],
|
||||
dueDate: '2026-08-12T00:00:00.000Z',
|
||||
metadata: {
|
||||
notes: 'Read-only modal content should remain intact.',
|
||||
pr_links: [{ url: 'https://example.invalid/pr/1', label: 'PR #1' }],
|
||||
},
|
||||
createdAt: '2026-08-04T10:00:00.000Z',
|
||||
updatedAt: '2026-08-09T15:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
title: 'Route /projects/:id',
|
||||
description: 'Reuse overview, tasks, missions, and PRD tabs',
|
||||
status: 'in-progress',
|
||||
priority: 'critical',
|
||||
projectId: 'project-1',
|
||||
missionId: 'mission-1',
|
||||
assignee: null,
|
||||
tags: ['spa', 'detail'],
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-05T09:00:00.000Z',
|
||||
updatedAt: '2026-08-10T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
title: 'Route /tasks',
|
||||
description: 'Wire list and kanban modal interactions',
|
||||
status: 'blocked',
|
||||
priority: 'medium',
|
||||
projectId: 'project-1',
|
||||
missionId: null,
|
||||
assignee: null,
|
||||
tags: ['spa', 'tasks'],
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-06T09:00:00.000Z',
|
||||
updatedAt: '2026-08-08T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-4',
|
||||
title: 'Other project task',
|
||||
description: 'Used only to confirm mission filtering remains project-scoped',
|
||||
status: 'not-started',
|
||||
priority: 'low',
|
||||
projectId: 'project-2',
|
||||
missionId: 'mission-2',
|
||||
assignee: null,
|
||||
tags: null,
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-06T09:00:00.000Z',
|
||||
updatedAt: '2026-08-06T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,318 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectDetailPage } from './project-detail';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <p>Projects index target</p> },
|
||||
{ path: '/projects/:id', element: <ProjectDetailPage /> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
|
||||
|
||||
function mockHealthyLoad(): void {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
}
|
||||
|
||||
describe('ProjectDetailPage', () => {
|
||||
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/api/projects/project-1',
|
||||
'/api/missions',
|
||||
'/api/tasks?projectId=project-1',
|
||||
]);
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Route /projects/:id');
|
||||
expect(container.textContent).toContain('Tasks');
|
||||
expect(container.textContent).toContain('Done');
|
||||
expect(container.textContent).toContain('Blocked');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (1)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ship web parity');
|
||||
expect(container.textContent).not.toContain('Unrelated mission');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('PRD');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack PRD');
|
||||
expect(container.textContent).toContain('Ship the SPA route parity pages.');
|
||||
});
|
||||
|
||||
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Tasks (3)');
|
||||
});
|
||||
|
||||
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /projects'),
|
||||
);
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Read-only modal content should remain intact.');
|
||||
|
||||
const closeButton = container.querySelector('button[aria-label="Close task details"]');
|
||||
expect(closeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows verified completion verdicts when the task collection is current', async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
const doneCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === 'Done1',
|
||||
);
|
||||
expect(doneCard).toBeTruthy();
|
||||
const inProgressCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === 'In Progress1',
|
||||
);
|
||||
expect(inProgressCard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
// Secondary failure degrades the surface to partial; the project itself
|
||||
// still renders.
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
const partial = container.querySelector('[role="status"]');
|
||||
expect(partial?.textContent).toContain('Missions');
|
||||
expect(partial?.textContent).toContain('unavailable');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (?)');
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Missions request failed');
|
||||
// Negative control: a failed fetch must not look like an empty list.
|
||||
expect(container.textContent).not.toContain('No missions for this project');
|
||||
});
|
||||
|
||||
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
|
||||
// Completion verdicts become unknown ('?') — never green counts.
|
||||
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
|
||||
const unknownCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === `${label}?`,
|
||||
);
|
||||
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
|
||||
}
|
||||
// Negative control: no green "Done 1" verdict anywhere.
|
||||
expect(
|
||||
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Tasks (?)');
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
// Negative control: no healthy empty task list from a failed fetch.
|
||||
expect(container.textContent).not.toContain('No tasks found');
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
it('recovers a partial surface to current after revalidation', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'))
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
await renderProjectDetailPage();
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Revalidate');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(
|
||||
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never shows one project's data on another project's route after navigation", async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
|
||||
const deferred = createDeferred<(typeof projectFixtures)[number]>();
|
||||
apiMock
|
||||
.mockResolvedValueOnce(deferred.promise)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
await router.navigate('/projects/project-2');
|
||||
});
|
||||
|
||||
// While project-2 loads, nothing from project-1 may render on its route.
|
||||
expect(container.textContent).toContain('Loading project...');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.textContent).not.toContain('Route /projects/:id');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures[1]!);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
|
||||
});
|
||||
|
||||
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Project request failed');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Back to projects');
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||
import {
|
||||
PartialDataNotice,
|
||||
StaleDataNotice,
|
||||
UnavailableDataNotice,
|
||||
} from '@/components/freshness/freshness-notices';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
import {
|
||||
combineFreshness,
|
||||
UNKNOWN_VERDICT,
|
||||
verdictValue,
|
||||
type FreshSnapshot,
|
||||
} from '@/lib/freshness/model';
|
||||
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
|
||||
import {
|
||||
validateMissionCollection,
|
||||
validateProjectEntity,
|
||||
validateTaskCollection,
|
||||
} from '@/lib/freshness/validators';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
const projectStatusColors: Record<string, string> = {
|
||||
active: 'bg-success/20 text-success',
|
||||
paused: 'bg-warning/20 text-warning',
|
||||
completed: 'bg-blue-600/20 text-blue-400',
|
||||
archived: 'bg-gray-600/20 text-gray-400',
|
||||
};
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||
blocked: 'bg-error/20 text-error',
|
||||
done: 'bg-success/20 text-success',
|
||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||
};
|
||||
|
||||
interface TabButtonProps {
|
||||
id: Tab;
|
||||
label: string;
|
||||
activeTab: Tab;
|
||||
onClick: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(id)}
|
||||
className={cn(
|
||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||
activeTab === id
|
||||
? 'border-text-primary text-text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Remounts per project id so no state from one project renders for another. */
|
||||
export function ProjectDetailPage(): ReactElement {
|
||||
const { id = '' } = useParams();
|
||||
return <ProjectDetail id={id} key={id} />;
|
||||
}
|
||||
|
||||
function ProjectDetail({ id }: { id: string }): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const enabled = id.length > 0;
|
||||
|
||||
// Primary collection gates the surface; missions and tasks are secondaries
|
||||
// whose failures degrade the surface to `partial` instead of rendering
|
||||
// empty healthy lists.
|
||||
const project = useFreshCollection<Project>({
|
||||
source: `gateway:/api/projects/${id}`,
|
||||
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
|
||||
validate: validateProjectEntity,
|
||||
// No last-known restore: the entity carries workspace identity that
|
||||
// cannot be scope-checked before display (see ProjectsPage note).
|
||||
enabled,
|
||||
});
|
||||
const missions = useFreshCollection<Mission[]>({
|
||||
source: 'gateway:/api/missions',
|
||||
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
|
||||
validate: validateMissionCollection,
|
||||
cacheKey: enabled ? 'missions' : null,
|
||||
enabled,
|
||||
});
|
||||
const tasks = useFreshCollection<Task[]>({
|
||||
source: `gateway:/api/tasks?projectId=${id}`,
|
||||
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
|
||||
validate: validateTaskCollection,
|
||||
cacheKey: enabled ? `project-tasks:${id}` : null,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
|
||||
const tasksVerified = tasks.freshness === 'current';
|
||||
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
|
||||
|
||||
const retryAll = (): void => {
|
||||
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
Project id is missing.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="mt-4 w-fit text-sm underline"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (project.freshness === 'unknown') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (project.freshness === 'unavailable' || project.data === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<UnavailableDataNotice
|
||||
title="This project"
|
||||
detail={describeFailure(project.failure)}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="mt-4 w-fit text-sm underline"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const projectTasks = tasks.data ?? null;
|
||||
const filteredTasks =
|
||||
projectTasks === null
|
||||
? []
|
||||
: taskFilter === 'all'
|
||||
? projectTasks
|
||||
: projectTasks.filter((task) => task.status === taskFilter);
|
||||
|
||||
// Derived completion verdicts: unknown (never green) unless the task
|
||||
// collection is verified current.
|
||||
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
|
||||
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
|
||||
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
|
||||
|
||||
const prdContent = getPrdContent(project.data);
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{
|
||||
id: 'tasks',
|
||||
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
|
||||
},
|
||||
{
|
||||
id: 'missions',
|
||||
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
|
||||
},
|
||||
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
const staleSnapshot: FreshSnapshot<unknown> | null =
|
||||
project.freshness === 'stale'
|
||||
? project.snapshot
|
||||
: missions.freshness === 'stale'
|
||||
? missions.snapshot
|
||||
: tasks.freshness === 'stale'
|
||||
? tasks.snapshot
|
||||
: null;
|
||||
const missingSections: string[] = [];
|
||||
if (missions.freshness === 'unavailable') missingSections.push('Missions');
|
||||
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
|
||||
|
||||
return (
|
||||
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="hover:text-text-secondary"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.data.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{project.data.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.data.description ? (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.data.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{staleSnapshot !== null ? (
|
||||
<div className="mb-6">
|
||||
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{missingSections.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label="Tasks"
|
||||
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Done"
|
||||
value={verdictValue(tasksVerified, String(doneCount))}
|
||||
valueClass={tasksVerified ? 'text-success' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={verdictValue(tasksVerified, String(inProgressCount))}
|
||||
valueClass={tasksVerified ? 'text-blue-400' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={verdictValue(tasksVerified, String(blockedCount))}
|
||||
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
label={tab.label}
|
||||
activeTab={activeTab}
|
||||
onClick={setActiveTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'tasks' ? (
|
||||
<div>
|
||||
{projectTasks === null ? (
|
||||
<UnavailableDataNotice
|
||||
title="Tasks"
|
||||
detail={describeFailure(tasks.failure)}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={projectTasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'missions' ? (
|
||||
projectMissions === null ? (
|
||||
<UnavailableDataNotice
|
||||
title="Missions"
|
||||
detail={describeFailure(missions.failure)}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
) : (
|
||||
<MissionTimeline missions={projectMissions} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{activeTab === 'prd' && prdContent ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
<PrdViewer content={prdContent} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedTask ? (
|
||||
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
project,
|
||||
missions,
|
||||
tasks,
|
||||
}: {
|
||||
project: Project;
|
||||
missions: Mission[] | null;
|
||||
tasks: Task[] | null;
|
||||
}): ReactElement {
|
||||
const recentTasks =
|
||||
tasks === null
|
||||
? null
|
||||
: [...tasks]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||
{recentTasks === null ? (
|
||||
<UnavailableDataNotice title="Tasks" />
|
||||
) : recentTasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2"
|
||||
>
|
||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||
{missions === null ? (
|
||||
<UnavailableDataNotice title="Missions" />
|
||||
) : missions.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No missions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{project.metadata && Object.keys(project.metadata).length > 0 ? (
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||
{JSON.stringify(project.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrdContent(project: Project): string | null {
|
||||
if (!project.metadata) return null;
|
||||
|
||||
const prd = project.metadata['prd'];
|
||||
if (typeof prd === 'string' && prd.trim().length > 0) {
|
||||
return prd;
|
||||
}
|
||||
|
||||
const prdContent = project.metadata['prdContent'];
|
||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) {
|
||||
return prdContent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { projectFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectsPage } from './projects';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <ProjectsPage /> },
|
||||
{ path: '/projects/:id', element: <p>Project detail target</p> },
|
||||
];
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ProjectsPage', () => {
|
||||
it('shows a visible loading state while the project request is in flight', async () => {
|
||||
const deferred = createDeferred<typeof projectFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading projects...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders project cards from the API and navigates to a project detail route on click', async () => {
|
||||
apiMock.mockResolvedValueOnce(projectFixtures);
|
||||
|
||||
const router = await renderProjectsPage();
|
||||
|
||||
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Mosaic Stack'),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects/project-1');
|
||||
expect(container.textContent).toContain('Project detail target');
|
||||
});
|
||||
|
||||
it('renders the empty state only for a verified empty collection', async () => {
|
||||
apiMock.mockResolvedValueOnce([]);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('No projects yet');
|
||||
expect(container.textContent).toContain(
|
||||
'Projects will appear here when created via the gateway API',
|
||||
);
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Projects are unavailable');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
|
||||
// Negative controls: no healthy empty state and no project cards render
|
||||
// from a failed fetch.
|
||||
expect(container.textContent).not.toContain('No projects yet');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an auth failure as unavailable and recovers after retry', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
|
||||
.mockResolvedValueOnce(projectFixtures);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Unauthorized');
|
||||
expect(container.textContent).not.toContain('No projects yet');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Retry');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a schema-mismatched response as unavailable, never as data', async () => {
|
||||
apiMock.mockResolvedValueOnce({ results: projectFixtures });
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.textContent).not.toContain('No projects yet');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
|
||||
import { validateProjectCollection } from '@/lib/freshness/validators';
|
||||
|
||||
export function ProjectsPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const projects = useFreshCollection<Project[]>({
|
||||
source: 'gateway:/api/projects',
|
||||
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
|
||||
validate: validateProjectCollection,
|
||||
// Projects carry workspace identity (userId) that is only knowable from
|
||||
// the payload itself, so a restored entry cannot be scope-checked before
|
||||
// display. Conservative choice: no last-known restore for this surface;
|
||||
// cross-workspace switching is still invalidated at verification time.
|
||||
});
|
||||
const retry = (): void => {
|
||||
void projects.revalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-freshness={projects.freshness}
|
||||
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
|
||||
>
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</header>
|
||||
|
||||
{projects.freshness === 'stale' && projects.snapshot ? (
|
||||
<div className="mb-6">
|
||||
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projects.freshness === 'unknown' ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||
) : projects.freshness === 'unavailable' ? (
|
||||
<UnavailableDataNotice
|
||||
title="Projects"
|
||||
detail={describeFailure(projects.failure)}
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : projects.data !== null && projects.data.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Projects will appear here when created via the gateway API
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(projects.data ?? []).map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={(selectedProject) => navigate(`/projects/${selectedProject.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export function RegisterPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const name = String(form.get('name') ?? '');
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('resource route render blew up');
|
||||
}
|
||||
|
||||
function replaceRouteElementWithBoom(nodes: RouteObject[], path: string): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === path) {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceRouteElementWithBoom(cloned.children, path);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('resource route error boundaries', () => {
|
||||
it.each(['/projects', '/projects/:id', '/tasks'])(
|
||||
'renders a recoverable fallback when %s throws during route render',
|
||||
async (path) => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const initialEntry = path === '/projects/:id' ? '/projects/project-1' : path;
|
||||
const router = createMemoryRouter(replaceRouteElementWithBoom(routes, path), {
|
||||
initialEntries: [initialEntry],
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toBe('');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
interface ResourceRouteErrorBoundaryProps {
|
||||
message: string;
|
||||
href: string;
|
||||
linkLabel: string;
|
||||
}
|
||||
|
||||
function ResourceRouteErrorBoundary({
|
||||
message,
|
||||
href,
|
||||
linkLabel,
|
||||
}: ResourceRouteErrorBoundaryProps): ReactElement {
|
||||
useRouteError();
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<a href={href} className="text-sm underline">
|
||||
{linkLabel}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading projects."
|
||||
href="/projects"
|
||||
linkLabel="Reload projects"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDetailRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading this project."
|
||||
href="/projects"
|
||||
linkLabel="Back to projects"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading tasks."
|
||||
href="/tasks"
|
||||
linkLabel="Reload tasks"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
updateUserMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: { updateUser: updateUserMock },
|
||||
}));
|
||||
|
||||
import { SettingsPage } from './settings';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderSettingsPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): Promise<void> {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
return act(async () => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const session = {
|
||||
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
|
||||
};
|
||||
|
||||
describe('SettingsPage profile tab', () => {
|
||||
it('renders the profile form from the session and saves via authClient', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({});
|
||||
|
||||
await renderSettingsPage();
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
|
||||
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
||||
expect(nameInput?.value).toBe('Test User');
|
||||
expect(emailInput?.value).toBe('[email protected]');
|
||||
expect(emailInput?.disabled).toBe(true);
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
|
||||
expect(container.textContent).toContain('Saved!');
|
||||
});
|
||||
|
||||
it('surfaces an update failure without clearing the form', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(container.textContent).toContain('name rejected');
|
||||
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage appearance tab', () => {
|
||||
it('loads preferences and posts each changed preference on save', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string) =>
|
||||
path.startsWith('/api/memory/preferences?')
|
||||
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
|
||||
: Promise.resolve({}),
|
||||
);
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Appearance');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage providers tab', () => {
|
||||
it('loads LLM and SSO providers and runs a connection test', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
|
||||
if (path === '/api/providers' && opts === undefined) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
available: true,
|
||||
models: [
|
||||
{
|
||||
id: 'llama3.2',
|
||||
provider: 'ollama',
|
||||
name: 'Llama 3.2',
|
||||
reasoning: false,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
inputTypes: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (path === '/api/sso/providers') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (path === '/api/providers/test') {
|
||||
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Providers');
|
||||
|
||||
expect(container.textContent).toContain('Ollama');
|
||||
expect(container.textContent).toContain('1 model');
|
||||
|
||||
await clickButtonByText('Test');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId: 'ollama' },
|
||||
});
|
||||
expect(container.textContent).toContain('Reachable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,836 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, useSession } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
interface TestConnectionResult {
|
||||
providerId: string;
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
discoveredModels?: string[];
|
||||
}
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
|
||||
interface ProviderTestStatus {
|
||||
state: TestState;
|
||||
result?: TestConnectionResult;
|
||||
}
|
||||
|
||||
interface Preference {
|
||||
key: string;
|
||||
value: unknown;
|
||||
category: string;
|
||||
}
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||
const p = prefs.find((x) => x.key === key);
|
||||
if (p === undefined) return fallback;
|
||||
return p.value as T;
|
||||
}
|
||||
|
||||
// The reset must not outlive the tab: an uncleared setTimeout fires into a
|
||||
// torn-down environment (unmount, or jsdom teardown under vitest).
|
||||
function useSavedBadgeReset(saveState: SaveState, setSaveState: (s: SaveState) => void): void {
|
||||
useEffect(() => {
|
||||
if (saveState !== 'saved') return undefined;
|
||||
const timer = setTimeout(() => setSaveState('idle'), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [saveState, setSaveState]);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'profile', label: 'Profile' },
|
||||
{ id: 'appearance', label: 'Appearance' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'providers', label: 'Providers' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-b-2 border-accent text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile' && <ProfileTab session={session} />}
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'providers' && <ProvidersTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileTab({
|
||||
session,
|
||||
}: {
|
||||
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(session?.user.name ?? '');
|
||||
const [image, setImage] = useState(session?.user.image ?? '');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
// Sync from session when it loads
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
setName(session.user.name ?? '');
|
||||
setImage(session.user.image ?? '');
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const result = await authClient.updateUser({ name, image: image || null });
|
||||
if (result.error) {
|
||||
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
||||
setSaveState('error');
|
||||
return;
|
||||
}
|
||||
setSaveState('saved');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
||||
<FormField label="Display Name" id="profile-name">
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" id="profile-email">
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={session?.user.email ?? ''}
|
||||
disabled
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Avatar URL" id="profile-image">
|
||||
<input
|
||||
id="profile-image"
|
||||
type="url"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function AppearanceTab(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [defaultModel, setDefaultModel] = useState('');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||
.catch(() => [] as Preference[])
|
||||
.then((p) => {
|
||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await Promise.all([
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'ui.sidebar_collapsed',
|
||||
value: sidebarCollapsed,
|
||||
category: 'appearance',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
...(defaultModel
|
||||
? [
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'ui.default_model',
|
||||
value: defaultModel,
|
||||
category: 'appearance',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
||||
<div className="flex gap-3">
|
||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTheme(t)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
||||
theme === t
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar collapsed default */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
||||
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
||||
</div>
|
||||
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
{/* Default model */}
|
||||
<FormField label="Default Model" id="default-model">
|
||||
<input
|
||||
id="default-model"
|
||||
type="text"
|
||||
value={defaultModel}
|
||||
onChange={(e) => setDefaultModel(e.target.value)}
|
||||
placeholder="e.g. ollama/llama3.2"
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Model ID to pre-select for new conversations.
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
||||
const [emailMentions, setEmailMentions] = useState(true);
|
||||
const [emailDigest, setEmailDigest] = useState(false);
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||
.catch(() => [] as Preference[])
|
||||
.then((p) => {
|
||||
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
||||
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
||||
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await Promise.all([
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_agent_complete',
|
||||
value: emailAgentComplete,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_mentions',
|
||||
value: emailMentions,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_digest',
|
||||
value: emailDigest,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
||||
|
||||
<NotifyRow
|
||||
label="Agent task completed"
|
||||
description="Email when an agent finishes a task"
|
||||
checked={emailAgentComplete}
|
||||
onChange={setEmailAgentComplete}
|
||||
/>
|
||||
<NotifyRow
|
||||
label="Mentions"
|
||||
description="Email when you are mentioned in a conversation"
|
||||
checked={emailMentions}
|
||||
onChange={setEmailMentions}
|
||||
/>
|
||||
<NotifyRow
|
||||
label="Weekly digest"
|
||||
description="Weekly summary of activity"
|
||||
checked={emailDigest}
|
||||
onChange={setEmailDigest}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ProvidersTab(): React.ReactElement {
|
||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ssoLoading, setSsoLoading] = useState(true);
|
||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.catch(() => [] as ProviderInfo[])
|
||||
.then((p) => setProviders(p))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||
.catch(() => [] as SsoProviderDiscovery[])
|
||||
.then((providers) => setSsoProviders(providers))
|
||||
.finally(() => setSsoLoading(false));
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { state: 'testing' },
|
||||
}));
|
||||
try {
|
||||
const result = await api<TestConnectionResult>('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId },
|
||||
});
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
||||
}));
|
||||
} catch {
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: {
|
||||
state: 'error',
|
||||
result: { providerId, reachable: false, error: 'Request failed' },
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const defaultModel: ModelInfo | undefined = providers
|
||||
.flatMap((p) => p.models)
|
||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
||||
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
No providers configured. Set{' '}
|
||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||
OLLAMA_BASE_URL
|
||||
</code>{' '}
|
||||
or{' '}
|
||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||
MOSAIC_CUSTOM_PROVIDERS
|
||||
</code>{' '}
|
||||
to add providers.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
defaultModel={defaultModel}
|
||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||
onTest={() => void testConnection(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
||||
checked ? 'bg-accent' : 'bg-surface-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
checked ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
</div>
|
||||
<Toggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({
|
||||
state,
|
||||
onClick,
|
||||
}: {
|
||||
state: SaveState;
|
||||
onClick: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={state === 'saving'}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider Card (from original page) ──────────────────────────────────────
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ProviderInfo;
|
||||
defaultModel: ModelInfo | undefined;
|
||||
testStatus: ProviderTestStatus;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
defaultModel,
|
||||
testStatus,
|
||||
onTest,
|
||||
}: ProviderCardProps): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderAvatar id={provider.id} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
||||
<ProviderStatusBadge available={provider.available} />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TestConnectionButton status={testStatus} onTest={onTest} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
||||
>
|
||||
{expanded ? '▲ Hide' : '▼ Models'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result banner */}
|
||||
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
||||
<TestResultBanner result={testStatus.result} />
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{expanded && (
|
||||
<div className="border-t border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Model</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
||||
<th className="px-4 py-2 font-medium">Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{provider.models.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
isDefault={
|
||||
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelRowProps {
|
||||
model: ModelInfo;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
||||
return (
|
||||
<tr className="border-t border-surface-border">
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-sm text-text-primary">{model.name}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<CapabilityBadge label="chat" />
|
||||
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
||||
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||
{formatContext(model.contextWindow)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||
{model.cost.input === 0 && model.cost.output === 0
|
||||
? 'free'
|
||||
: `$${model.cost.input} / $${model.cost.output}`}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{isDefault && (
|
||||
<span
|
||||
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
||||
title="Default model used for new sessions"
|
||||
>
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
||||
const letter = id.charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{available ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TestConnectionButtonProps {
|
||||
status: ProviderTestStatus;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
||||
const isTesting = status.state === 'testing';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Test connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<span className="text-text-muted">Testing…</span>
|
||||
) : status.state === 'success' ? (
|
||||
<span className="text-success">✓ Reachable</span>
|
||||
) : status.state === 'error' ? (
|
||||
<span className="text-error">✗ Unreachable</span>
|
||||
) : (
|
||||
<span className="text-text-muted">Test</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-2 text-xs ${
|
||||
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
||||
}`}
|
||||
>
|
||||
{result.reachable ? (
|
||||
<>
|
||||
Connected
|
||||
{result.latencyMs !== undefined && (
|
||||
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
||||
)}
|
||||
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
||||
<span className="ml-2 opacity-70">
|
||||
— {result.discoveredModels.length} model
|
||||
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadge({
|
||||
label,
|
||||
color = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
color?: 'default' | 'purple' | 'blue';
|
||||
}): React.ReactElement {
|
||||
const colorClass =
|
||||
color === 'purple'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: color === 'blue'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-surface-elevated text-text-muted';
|
||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function formatContext(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
oauth2Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
signIn: { oauth2: oauth2Mock },
|
||||
}));
|
||||
|
||||
import { SsoCallbackPage } from './sso-callback';
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockResolvedValue([
|
||||
{
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
protocols: ['oidc'],
|
||||
configured: true,
|
||||
loginMode: 'oidc',
|
||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||
teamSync: { enabled: false, claim: null },
|
||||
samlFallback: { configured: false, loginUrl: null },
|
||||
warnings: [],
|
||||
},
|
||||
]);
|
||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
apiMock.mockReset();
|
||||
oauth2Mock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
describe('SsoCallbackPage', () => {
|
||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/auth/provider/:provider',
|
||||
element: <SsoCallbackPage />,
|
||||
},
|
||||
],
|
||||
{
|
||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||
},
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||
providerId: 'authentik',
|
||||
callbackURL: '/chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function SsoCallbackPage(): ReactElement {
|
||||
const { provider: providerId = '' } = useParams<'provider'>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { taskFixtures } from './page-fixtures';
|
||||
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
|
||||
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
|
||||
import { validateTaskCollection } from '@/lib/freshness/validators';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { TasksPage } from './tasks';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderTasksPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/tasks', element: <TasksPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** Flush pending promise callbacks inside the act environment. */
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TasksPage', () => {
|
||||
it('shows a visible loading state before the tasks request settles', async () => {
|
||||
const deferred = createDeferred<typeof taskFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading tasks...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => {
|
||||
apiMock.mockResolvedValueOnce(taskFixtures);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.textContent).toContain('Not Started');
|
||||
expect(container.textContent).toContain('In Progress');
|
||||
expect(container.textContent).toContain('Blocked');
|
||||
|
||||
const kanbanCard = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /projects/:id'),
|
||||
);
|
||||
expect(kanbanCard).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector('button[aria-label="Close task details"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('List');
|
||||
});
|
||||
|
||||
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /tasks'),
|
||||
);
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Wire list and kanban modal interactions');
|
||||
});
|
||||
|
||||
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
|
||||
// Negative controls: no board, no healthy empty-state markers, and the
|
||||
// surface is marked unavailable rather than current.
|
||||
expect(container.textContent).not.toContain('Not Started');
|
||||
expect(container.textContent).not.toContain('No tasks');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers to a current board after retrying a failed fetch', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'))
|
||||
.mockResolvedValueOnce(taskFixtures);
|
||||
|
||||
await renderTasksPage();
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Retry');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.textContent).toContain('Not Started');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
|
||||
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
|
||||
// Seed a last-known snapshot fetched five minutes ago; the page must
|
||||
// render it only under an explicit staleness label while the fetch is
|
||||
// still in flight.
|
||||
const restored = acceptSnapshot({
|
||||
value: taskFixtures,
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy: DEFAULT_FRESHNESS_POLICY,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: Date.now() - 5 * 60_000,
|
||||
});
|
||||
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
|
||||
writeSnapshotCache('tasks', restored.snapshot);
|
||||
|
||||
const deferred = createDeferred<typeof taskFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'stale',
|
||||
);
|
||||
const banner = container.querySelector('[role="status"]');
|
||||
expect(banner?.textContent).toContain('last-known');
|
||||
expect(banner?.textContent).toContain('may be out of date');
|
||||
expect(banner?.textContent).toContain('gateway:/api/tasks');
|
||||
expect(banner?.textContent).toContain('snapshot v1');
|
||||
expect(banner?.textContent).toContain('5m ago');
|
||||
|
||||
// Last-known data still renders as situational awareness under the label.
|
||||
expect(container.textContent).toContain('Route /tasks');
|
||||
expect(container.textContent).not.toContain('Loading tasks...');
|
||||
|
||||
// Verification lands: the banner clears and the surface becomes current.
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
|
||||
import { validateTaskCollection } from '@/lib/freshness/validators';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export function TasksPage(): ReactElement {
|
||||
const tasks = useFreshCollection<Task[]>({
|
||||
source: 'gateway:/api/tasks',
|
||||
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
|
||||
validate: validateTaskCollection,
|
||||
cacheKey: 'tasks',
|
||||
});
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
const retry = (): void => {
|
||||
void tasks.revalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('list')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('kanban')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'kanban'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tasks.freshness === 'stale' && tasks.snapshot ? (
|
||||
<div className="mb-6">
|
||||
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tasks.freshness === 'unknown' ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : tasks.freshness === 'unavailable' ? (
|
||||
<UnavailableDataNotice
|
||||
title="Tasks"
|
||||
detail={describeFailure(tasks.failure)}
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
|
||||
)}
|
||||
|
||||
{selectedTask ? (
|
||||
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { isValidElement } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { routes } from '@/routes';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||
import { ProjectsPage } from '@/spa/pages/projects';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
|
||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||
return routeObjects.flatMap((route) => [
|
||||
...(route.path ? [route.path] : []),
|
||||
...(route.children ? collectPaths(route.children) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
||||
for (const route of routeObjects) {
|
||||
if (route.path === path) return route;
|
||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('SPA route table', () => {
|
||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
||||
expect(collectPaths(routes).sort()).toEqual(
|
||||
[
|
||||
'/',
|
||||
'/admin',
|
||||
'/auth/provider/:provider',
|
||||
'/chat',
|
||||
'/login',
|
||||
'/projects',
|
||||
'/projects/:id',
|
||||
'/register',
|
||||
'/settings',
|
||||
'/tasks',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('separates guest and authenticated route groups', () => {
|
||||
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(guestPaths).toContain('/login');
|
||||
expect(guestPaths).not.toContain('/chat');
|
||||
expect(authPaths).toContain('/chat');
|
||||
});
|
||||
|
||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
||||
'renders a real guest page instead of the P1 placeholder at %s',
|
||||
(path) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(authPaths).toContain('/chat');
|
||||
|
||||
const element = findRoute(routes, '/chat')?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(ChatPage);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/projects', ProjectsPage],
|
||||
['/projects/:id', ProjectDetailPage],
|
||||
['/tasks', TasksPage],
|
||||
])(
|
||||
'renders a real authenticated page instead of the P1 placeholder at %s',
|
||||
(path, expectedType) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(expectedType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['/chat', '/projects', '/projects/:id', '/tasks'])(
|
||||
'defines an error boundary for %s',
|
||||
(path) => {
|
||||
const route = findRoute(routes, path);
|
||||
expect(route?.errorElement).toBeTruthy();
|
||||
expect(isValidElement(route?.errorElement)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user