Bring the chat experience into the Vite/React-Router SPA on the exact typed Socket.IO /chat contract from @mosaicstack/types, replacing the /chat placeholder behind AuthGuard. Surfaces message:ack (with an accessible status), agent:start, streamed agent:text/agent:thinking, tool start/end status, agent:end with usage, session:info (thinking controls + routing decision), commands:manifest, command:result, command:approval (with a one-time approved-run affordance), system:reload (refreshing the rendered manifest), and error, and emits message/abort/set:thinking/command:execute/ command:approve with exact payloads. The gateway does not guarantee message:ack is the first event for a new conversation (session:info, and error on auth/session-creation failure, can both arrive first) — conversation-scoped events now adopt the conversation from whichever scoped event names it first while a send is pending, then filter everything else against that established conversation. A typed error stops streaming instead of leaving Stop stuck active; agent:end no longer appends an empty assistant turn when there is no text or thinking; and a second message can no longer be sent while a turn is streaming. Command approval is now integrity-checked end to end: only one command:approve request may be outstanding at a time (a concurrent request is ignored rather than overwriting the pending command/args), a stale or mismatched command:approval response cannot replace active approval state, and running an approved command clears its approval state immediately (via a ref, before React re-renders) so a double-click cannot replay command:execute. The `/chat` socket is now typed at a single boundary: apps/web/src/lib/ socket.ts narrows socket.io-client's untyped `io()` return value to `ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at creation, via the one assertion the library's types force; every consumer (use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no further casts. The shared contract types live in the new apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts shim), which re-exports them via type-only imports resolved directly against packages/types/src (apps/web has no @mosaicstack/types package dependency, so this stays source-only and is erased at compile time — no package manifest or lockfile is touched). The two recorded-event test suites now drive a shared, typed fake socket (spa/chat/test-support/fake-chat-socket.ts) instead of an untyped `(event: string, payload: unknown)` harness, so a wrong event name or malformed payload fails to compile.
71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
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';
|
|
|
|
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);
|
|
});
|
|
});
|