feat(web): add typed SPA chat

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.
This commit is contained in:
shaggy (mosaic-dev box)
2026-08-09 21:39:16 -05:00
parent 87daa12976
commit b2e005f2b4
14 changed files with 2093 additions and 5 deletions
@@ -0,0 +1,442 @@
import { useEffect, useReducer, useRef } from 'react';
import { destroySocket, getSocket } from '@/lib/socket';
import type {
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
MessageAckPayload,
SessionInfoPayload,
SessionUsagePayload,
SlashCommandApprovalResultPayload,
SlashCommandResultPayload,
SystemReloadPayload,
ToolEndPayload,
ToolStartPayload,
} from '@/lib/chat-contract';
export interface ToolCallState {
toolCallId: string;
toolName: string;
status: 'running' | 'success' | 'error';
}
export interface ChatTranscriptMessage {
id: string;
role: 'user' | 'assistant';
text: string;
thinking?: string;
}
export interface PendingApproval {
command: string;
args?: string;
}
export interface ChatConnectionState {
conversationId: string | null;
/** True once a message has been sent while no conversation is active yet, so the
* first scoped server event naming a conversation may establish it (the gateway
* does not guarantee message:ack is the first event for a new conversation). */
pendingSend: boolean;
ack: MessageAckPayload | null;
streaming: boolean;
text: string;
thinking: string;
tools: ToolCallState[];
usage: SessionUsagePayload | null;
sessionInfo: SessionInfoPayload | null;
manifest: CommandManifest | null;
commandResults: SlashCommandResultPayload[];
approval: SlashCommandApprovalResultPayload | null;
pendingApproval: PendingApproval | null;
/** True while an approval request has been sent and no response has arrived yet. */
approvalRequestPending: boolean;
systemReload: SystemReloadPayload | null;
error: string | null;
messages: ChatTranscriptMessage[];
}
export interface ChatConnectionActions {
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
abort: () => void;
setThinking: (level: string) => void;
executeCommand: (input: { command: string; args?: string }) => void;
approveCommand: (input: { command: string; args?: string }) => void;
runApprovedCommand: () => void;
}
export interface ChatConnectionValue {
state: ChatConnectionState;
actions: ChatConnectionActions;
}
const initialState: ChatConnectionState = {
conversationId: null,
pendingSend: false,
ack: null,
streaming: false,
text: '',
thinking: '',
tools: [],
usage: null,
sessionInfo: null,
manifest: null,
commandResults: [],
approval: null,
pendingApproval: null,
approvalRequestPending: false,
systemReload: null,
error: null,
messages: [],
};
type Action =
| { type: 'server/message:ack'; payload: MessageAckPayload }
| { type: 'server/agent:start'; payload: AgentStartPayload }
| { type: 'server/agent:text'; payload: AgentTextPayload }
| { type: 'server/agent:thinking'; payload: AgentThinkingPayload }
| { type: 'server/agent:tool:start'; payload: ToolStartPayload }
| { type: 'server/agent:tool:end'; payload: ToolEndPayload }
| { type: 'server/agent:end'; payload: AgentEndPayload }
| { type: 'server/session:info'; payload: SessionInfoPayload }
| { type: 'server/commands:manifest'; payload: CommandManifestPayload }
| { type: 'server/command:result'; payload: SlashCommandResultPayload }
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
| { type: 'server/system:reload'; payload: SystemReloadPayload }
| { type: 'server/error'; payload: ErrorPayload }
| { type: 'local/send'; content: string }
| { type: 'local/approve-request'; command: string; args?: string }
| { type: 'local/consume-approval' };
/**
* Resolves whether a scoped server event (one carrying a conversationId) belongs to
* the active conversation. 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) — so while a message is pending and no conversation
* is active yet, the first scoped event names the conversation instead of being
* dropped. Once a conversation is active, only its own events pass.
*/
function resolveScopedConversation(
state: ChatConnectionState,
conversationId: string,
): { active: true; state: ChatConnectionState } | { active: false; state: null } {
if (state.conversationId === conversationId) {
return { active: true, state };
}
if (state.conversationId === null && state.pendingSend) {
return { active: true, state: { ...state, conversationId, pendingSend: false } };
}
return { active: false, state: null };
}
function reduce(state: ChatConnectionState, action: Action): ChatConnectionState {
switch (action.type) {
case 'server/message:ack': {
const { payload } = action;
if (state.conversationId === null) {
return {
...state,
conversationId: payload.conversationId,
pendingSend: false,
ack: payload,
};
}
if (payload.conversationId !== state.conversationId) return state;
return { ...state, ack: payload };
}
case 'server/agent:start': {
const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state;
return { ...resolved.state, streaming: true, text: '', thinking: '', tools: [], error: null };
}
case 'server/agent:text': {
const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state;
return { ...resolved.state, text: resolved.state.text + action.payload.text };
}
case 'server/agent:thinking': {
const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state;
return { ...resolved.state, thinking: resolved.state.thinking + action.payload.text };
}
case 'server/agent:tool:start': {
const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state;
const tool: ToolCallState = {
toolCallId: payload.toolCallId,
toolName: payload.toolName,
status: 'running',
};
return { ...resolved.state, tools: [...resolved.state.tools, tool] };
}
case 'server/agent:tool:end': {
const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state;
return {
...resolved.state,
tools: resolved.state.tools.map((tool) =>
tool.toolCallId === payload.toolCallId
? { ...tool, status: payload.isError ? 'error' : 'success' }
: tool,
),
};
}
case 'server/agent:end': {
const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state;
const next = resolved.state;
const hasContent = next.text.length > 0 || next.thinking.length > 0;
const messages = hasContent
? [
...next.messages,
{
id: `assistant-${payload.conversationId}-${next.messages.length}`,
role: 'assistant' as const,
text: next.text,
thinking: next.thinking || undefined,
},
]
: next.messages;
return {
...next,
streaming: false,
text: '',
thinking: '',
usage: payload.usage ?? next.usage,
messages,
};
}
case 'server/session:info': {
const resolved = resolveScopedConversation(state, action.payload.conversationId);
if (!resolved.active) return state;
return { ...resolved.state, sessionInfo: action.payload };
}
case 'server/commands:manifest': {
return { ...state, manifest: action.payload.manifest };
}
case 'server/command:result': {
const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state;
return { ...resolved.state, commandResults: [...resolved.state.commandResults, payload] };
}
case 'server/command:approval': {
const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state;
const next = resolved.state;
if (!next.pendingApproval || next.pendingApproval.command !== payload.command) {
// Stale or mismatched response for a request that is no longer (or never was)
// the one outstanding approval — do not let it replace active approval state.
return next;
}
return { ...next, approval: payload, approvalRequestPending: false };
}
case 'server/system:reload': {
return {
...state,
systemReload: action.payload,
manifest: {
commands: action.payload.commands,
skills: action.payload.skills,
version: state.manifest?.version ?? 0,
},
};
}
case 'server/error': {
const { payload } = action;
const resolved = resolveScopedConversation(state, payload.conversationId);
if (!resolved.active) return state;
return { ...resolved.state, error: payload.error, streaming: false };
}
case 'local/send': {
const message: ChatTranscriptMessage = {
id: `user-${Date.now()}-${state.messages.length}`,
role: 'user',
text: action.content,
};
return {
...state,
messages: [...state.messages, message],
error: null,
pendingSend: state.conversationId === null ? true : state.pendingSend,
};
}
case 'local/approve-request': {
// Only one approval request may be outstanding at a time; a second request
// before the first resolves is ignored so it cannot overwrite the original
// command+args pair with a mismatched one.
if (state.approvalRequestPending) return state;
return {
...state,
pendingApproval: { command: action.command, args: action.args },
approvalRequestPending: true,
approval: null,
};
}
case 'local/consume-approval': {
return { ...state, approval: null, pendingApproval: null };
}
default:
return state;
}
}
/**
* Owns the `/chat` socket for the lifetime of the consuming page: subscribes
* once, narrows the socket to the typed contract, and tears the socket down
* on cleanup so the next visit gets a fresh authenticated connection.
*/
export function useChatConnection(): ChatConnectionValue {
const [state, dispatch] = useReducer(reduce, initialState);
// Reducer state updates are batched/async; a ref lets a double-click on "Run
// approved command" be rejected synchronously, before React ever re-renders.
const executedApprovalIds = useRef<Set<string>>(new Set());
useEffect(() => {
const socket = getSocket();
const onMessageAck = (payload: MessageAckPayload): void =>
dispatch({ type: 'server/message:ack', payload });
const onAgentStart = (payload: AgentStartPayload): void =>
dispatch({ type: 'server/agent:start', payload });
const onAgentText = (payload: AgentTextPayload): void =>
dispatch({ type: 'server/agent:text', payload });
const onAgentThinking = (payload: AgentThinkingPayload): void =>
dispatch({ type: 'server/agent:thinking', payload });
const onToolStart = (payload: ToolStartPayload): void =>
dispatch({ type: 'server/agent:tool:start', payload });
const onToolEnd = (payload: ToolEndPayload): void =>
dispatch({ type: 'server/agent:tool:end', payload });
const onAgentEnd = (payload: AgentEndPayload): void =>
dispatch({ type: 'server/agent:end', payload });
const onSessionInfo = (payload: SessionInfoPayload): void =>
dispatch({ type: 'server/session:info', payload });
const onCommandsManifest = (payload: CommandManifestPayload): void =>
dispatch({ type: 'server/commands:manifest', payload });
const onCommandResult = (payload: SlashCommandResultPayload): void =>
dispatch({ type: 'server/command:result', payload });
const onCommandApproval = (payload: SlashCommandApprovalResultPayload): void =>
dispatch({ type: 'server/command:approval', payload });
const onSystemReload = (payload: SystemReloadPayload): void =>
dispatch({ type: 'server/system:reload', payload });
const onError = (payload: ErrorPayload): void => dispatch({ type: 'server/error', payload });
socket.on('message:ack', onMessageAck);
socket.on('agent:start', onAgentStart);
socket.on('agent:text', onAgentText);
socket.on('agent:thinking', onAgentThinking);
socket.on('agent:tool:start', onToolStart);
socket.on('agent:tool:end', onToolEnd);
socket.on('agent:end', onAgentEnd);
socket.on('session:info', onSessionInfo);
socket.on('commands:manifest', onCommandsManifest);
socket.on('command:result', onCommandResult);
socket.on('command:approval', onCommandApproval);
socket.on('system:reload', onSystemReload);
socket.on('error', onError);
if (!socket.connected) {
socket.connect();
}
return () => {
socket.off('message:ack', onMessageAck);
socket.off('agent:start', onAgentStart);
socket.off('agent:text', onAgentText);
socket.off('agent:thinking', onAgentThinking);
socket.off('agent:tool:start', onToolStart);
socket.off('agent:tool:end', onToolEnd);
socket.off('agent:end', onAgentEnd);
socket.off('session:info', onSessionInfo);
socket.off('commands:manifest', onCommandsManifest);
socket.off('command:result', onCommandResult);
socket.off('command:approval', onCommandApproval);
socket.off('system:reload', onSystemReload);
socket.off('error', onError);
destroySocket();
};
}, []);
const actions: ChatConnectionActions = {
sendMessage: ({ content, provider, modelId }) => {
if (state.streaming) return;
const socket = getSocket();
if (!socket.connected) socket.connect();
dispatch({ type: 'local/send', content });
socket.emit('message', {
conversationId: state.conversationId ?? undefined,
content,
provider,
modelId,
});
},
abort: () => {
if (state.conversationId === null) return;
const socket = getSocket();
socket.emit('abort', { conversationId: state.conversationId });
},
setThinking: (level) => {
if (state.conversationId === null) return;
const socket = getSocket();
socket.emit('set:thinking', { conversationId: state.conversationId, level });
},
executeCommand: ({ command, args }) => {
if (state.conversationId === null) return;
const socket = getSocket();
socket.emit('command:execute', { conversationId: state.conversationId, command, args });
},
approveCommand: ({ command, args }) => {
if (state.conversationId === null) return;
if (state.approvalRequestPending) return;
dispatch({ type: 'local/approve-request', command, args });
const socket = getSocket();
socket.emit('command:approve', { conversationId: state.conversationId, command, args });
},
runApprovedCommand: () => {
const { conversationId, approval, pendingApproval } = state;
if (conversationId === null || !approval?.success || !approval.approvalId) return;
if (!pendingApproval || pendingApproval.command !== approval.command) return;
if (executedApprovalIds.current.has(approval.approvalId)) return;
executedApprovalIds.current.add(approval.approvalId);
const socket = getSocket();
socket.emit('command:execute', {
conversationId,
command: approval.command,
args: pendingApproval.args,
approvalId: approval.approvalId,
});
dispatch({ type: 'local/consume-approval' });
},
};
return { state, actions };
}