From b2e005f2b4914e86717fd4d5bdafa1c7c82f8af4 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 21:16:50 -0500 Subject: [PATCH 1/6] feat(web): add typed SPA chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 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. --- apps/web/src/lib/chat-contract.ts | 73 +++ apps/web/src/lib/socket.ts | 14 +- apps/web/src/routes.tsx | 3 +- apps/web/src/spa/chat/commands-panel.tsx | 108 ++++ apps/web/src/spa/chat/composer.tsx | 92 +++ apps/web/src/spa/chat/message-transcript.tsx | 39 ++ apps/web/src/spa/chat/session-panel.tsx | 43 ++ .../spa/chat/test-support/fake-chat-socket.ts | 77 +++ apps/web/src/spa/chat/tool-call-list.tsx | 16 + .../src/spa/chat/use-chat-connection.spec.tsx | 607 ++++++++++++++++++ apps/web/src/spa/chat/use-chat-connection.ts | 442 +++++++++++++ apps/web/src/spa/pages/chat.spec.tsx | 497 ++++++++++++++ apps/web/src/spa/pages/chat.tsx | 75 +++ apps/web/src/spa/routes.spec.tsx | 12 + 14 files changed, 2093 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/chat-contract.ts create mode 100644 apps/web/src/spa/chat/commands-panel.tsx create mode 100644 apps/web/src/spa/chat/composer.tsx create mode 100644 apps/web/src/spa/chat/message-transcript.tsx create mode 100644 apps/web/src/spa/chat/session-panel.tsx create mode 100644 apps/web/src/spa/chat/test-support/fake-chat-socket.ts create mode 100644 apps/web/src/spa/chat/tool-call-list.tsx create mode 100644 apps/web/src/spa/chat/use-chat-connection.spec.tsx create mode 100644 apps/web/src/spa/chat/use-chat-connection.ts create mode 100644 apps/web/src/spa/pages/chat.spec.tsx create mode 100644 apps/web/src/spa/pages/chat.tsx diff --git a/apps/web/src/lib/chat-contract.ts b/apps/web/src/lib/chat-contract.ts new file mode 100644 index 00000000..98c62477 --- /dev/null +++ b/apps/web/src/lib/chat-contract.ts @@ -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; diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts index 66cf64f4..ba4ae860 100644 --- a/apps/web/src/lib/socket.ts +++ b/apps/web/src/lib/socket.ts @@ -1,14 +1,20 @@ -import { io, type Socket } from 'socket.io-client'; +import { io } from 'socket.io-client'; +import type { ChatSocket } from './chat-contract'; -let socket: Socket | null = null; +let socket: ChatSocket | null = null; -export function getSocket(): Socket { +export function getSocket(): ChatSocket { if (!socket) { + // socket.io-client 4.8.3's `io()` factory declaration always returns the + // default unparameterized Socket (it accepts no + // generics), so this one cast is the unavoidable boundary between that and the + // typed `/chat` contract. Every other call site uses the resulting ChatSocket + // with no further assertions. socket = io('/chat', { withCredentials: true, autoConnect: false, transports: ['websocket', 'polling'], - }); + }) as unknown as ChatSocket; // Reset singleton reference when socket is fully closed so the next // getSocket() call creates a fresh instance instead of returning a diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 20556bad..1bc57bc3 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -3,6 +3,7 @@ import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-r import { LoginPage } from '@/spa/pages/login'; import { RegisterPage } from '@/spa/pages/register'; import { SsoCallbackPage } from '@/spa/pages/sso-callback'; +import { ChatPage } from '@/spa/pages/chat'; import { AuthGuard, GuestGuard } from '@/spa/guards'; import { Placeholder } from '@/spa/placeholder'; @@ -34,7 +35,7 @@ export const routes: RouteObject[] = [ element: , children: [ { path: '/', element: }, - { path: '/chat', element: }, + { path: '/chat', element: }, { path: '/projects', element: }, { path: '/projects/:id', element: }, { path: '/tasks', element: }, diff --git a/apps/web/src/spa/chat/commands-panel.tsx b/apps/web/src/spa/chat/commands-panel.tsx new file mode 100644 index 00000000..b21ef94e --- /dev/null +++ b/apps/web/src/spa/chat/commands-panel.tsx @@ -0,0 +1,108 @@ +import { useState, type ReactElement } from 'react'; +import type { PendingApproval } from './use-chat-connection'; +import type { + CommandManifest, + SlashCommandApprovalResultPayload, + SlashCommandResultPayload, +} from '@/lib/chat-contract'; + +interface CommandsPanelProps { + manifest: CommandManifest | null; + results: SlashCommandResultPayload[]; + approval: SlashCommandApprovalResultPayload | null; + pendingApproval: PendingApproval | null; + hasConversation: boolean; + onExecute: (input: { command: string; args?: string }) => void; + onApprove: (input: { command: string; args?: string }) => void; + onRunApproved: () => void; +} + +export function CommandsPanel({ + manifest, + results, + approval, + pendingApproval, + hasConversation, + onExecute, + onApprove, + onRunApproved, +}: CommandsPanelProps): ReactElement { + const [command, setCommand] = useState(''); + const [args, setArgs] = useState(''); + + const canRunApproved = + !!approval?.success && + !!approval.approvalId && + !!pendingApproval && + pendingApproval.command === approval.command; + + return ( +
+ {manifest && manifest.commands.length > 0 ? ( +
    + {manifest.commands.map((cmd) => ( +
  • + /{cmd.name} — {cmd.description} +
  • + ))} +
+ ) : null} + +
+ setCommand(event.target.value)} + placeholder="command" + /> + setArgs(event.target.value)} + placeholder="args (optional)" + /> + + +
+ + {approval ? ( +
+ + {approval.success + ? `Approved: /${approval.command}` + : `Approval denied: /${approval.command}`} + {approval.message ? ` — ${approval.message}` : ''} + + {canRunApproved ? ( + + ) : null} +
+ ) : null} + + {results.length > 0 ? ( +
    + {results.map((result, index) => ( +
  • + /{result.command}: {result.success ? 'success' : 'failed'} + {result.message ? ` — ${result.message}` : ''} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/spa/chat/composer.tsx b/apps/web/src/spa/chat/composer.tsx new file mode 100644 index 00000000..604f47d2 --- /dev/null +++ b/apps/web/src/spa/chat/composer.tsx @@ -0,0 +1,92 @@ +import { useState, type KeyboardEvent, type ReactElement } from 'react'; + +interface ComposerProps { + onSend: (input: { content: string; provider?: string; modelId?: string }) => void; + onStop: () => void; + streaming: boolean; + hasConversation: boolean; +} + +export function Composer({ + onSend, + onStop, + streaming, + hasConversation, +}: ComposerProps): ReactElement { + const [content, setContent] = useState(''); + const [provider, setProvider] = useState(''); + const [modelId, setModelId] = useState(''); + + function submit(): void { + if (streaming) return; + const trimmed = content.trim(); + if (!trimmed) return; + onSend({ + content: trimmed, + provider: provider.trim() || undefined, + modelId: modelId.trim() || undefined, + }); + setContent(''); + } + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(); + } + } + + return ( +
{ + event.preventDefault(); + submit(); + }} + className="flex flex-col gap-2 border-t p-4" + > +
+ setProvider(event.target.value)} + placeholder="Provider (optional)" + className="rounded border px-2 py-1 text-xs" + /> + setModelId(event.target.value)} + placeholder="Model (optional)" + className="rounded border px-2 py-1 text-xs" + /> +
+
+