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:
@@ -0,0 +1,75 @@
|
||||
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 { SessionPanel } from '@/spa/chat/session-panel';
|
||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
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">
|
||||
{state.systemReload.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? (
|
||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.ack ? (
|
||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||
Message accepted · conversation {state.ack.conversationId} · id {state.ack.messageId}
|
||||
</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">
|
||||
{state.usage.tokens.total} tokens · ${state.usage.cost.toFixed(4)} ·{' '}
|
||||
{state.usage.provider}/{state.usage.modelId}
|
||||
</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}
|
||||
hasConversation={hasConversation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user