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
+73
View File
@@ -0,0 +1,73 @@
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
// @mosaicstack/types' source.
//
// `apps/web` does not declare `@mosaicstack/types` as a package dependency (P3 scope
// forbids editing package manifests/lockfiles), so these are type-only imports
// resolved directly against the package source. `import type` is erased at compile
// time, so no runtime dependency is introduced — this only reuses the exact payload
// shapes instead of redeclaring them.
//
// This deliberately imports the narrow `chat/events` and `commands/index` entry
// points rather than the package's full `src/index` barrel: the barrel also re-exports
// class-validator/class-transformer decorated DTOs (chat.dto.ts, reflection.dto.ts,
// connector-lease.dto.ts) that require compiler options apps/web's tsconfig does not
// set, which breaks `tsc --noEmit` here even though only types are imported.
import type { Socket } from 'socket.io-client';
import type {
AbortPayload,
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ClientToServerEvents,
ErrorPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
SessionInfoPayload,
SessionUsagePayload,
SetThinkingPayload,
ToolEndPayload,
ToolStartPayload,
} from '../../../../packages/types/src/chat/events';
import type {
CommandDef,
CommandManifest,
CommandManifestPayload,
SkillCommandDef,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SlashCommandResultPayload,
SystemReloadPayload,
} from '../../../../packages/types/src/commands/index';
export type {
AbortPayload,
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
SessionInfoPayload,
SessionUsagePayload,
SetThinkingPayload,
SkillCommandDef,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SlashCommandResultPayload,
SystemReloadPayload,
ToolEndPayload,
ToolStartPayload,
};
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;