forked from mosaicstack/stack
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.
443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
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 };
|
|
}
|