feat(chat): web chat client with streaming UI#66
Conversation
Full chat client SPA in chat-ui/ at repo root: Vite + React 19 + TypeScript + shadcn/ui (new-york-v4) + Tailwind v4. Core architecture: - useSyncExternalStore-backed chat store dispatching all 24 SSE events - SSE parser in use-chat hook with frame-by-frame state updates - Tool card state machine (8 states, 10 per-tool renderers) - Thinking blocks with auto-open/close on streaming transitions - Session sidebar with date grouping and pinned sessions - Auto-scroll with jump-to-bottom pill - Warm cream/dark token system (#faf9f5 light, #1a1917 dark, #4850c4 accent) Stack: 62 source files, 5,200 LOC, 159KB gzipped (budget: 500KB) - 21 shadcn primitives in src/ui/ - 21 composites in src/components/ - 6 hooks, 6 lib modules, 4 routes - Dockerfile chat-ui-builder stage Known gaps (fix pass): - Font files are HTML placeholders, not real WOFF2 (system fallbacks work) - Markdown uses hand-rolled renderer, not streamdown (React 19 compat)
Inter variable (230KB), JetBrains Mono variable (72KB), Instrument Serif (19KB), all Latin subset from Google Fonts gstatic CDN.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ceca7084ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let currentEvent = ""; | ||
| let currentData = ""; |
There was a problem hiding this comment.
Persist SSE parser state across reader chunks
currentEvent and currentData are reinitialized on every reader.read() iteration, so SSE frames get dropped whenever event:, data:, and the terminating blank line arrive in different network chunks (a normal condition on streamed responses). In that case dispatchFrame is never called for those frames, which causes missing/delayed message text and tool/thinking updates during streaming.
Useful? React with 👍 / 👎.
| .then((detail: SessionDetail) => { | ||
| const msgs = detail.messages.map(messageRowToChatMessage); | ||
| store.update((s) => ({ ...s, messages: msgs, sessionId: id })); | ||
| }) |
There was a problem hiding this comment.
Guard session hydration from clobbering newer chat state
This callback unconditionally replaces messages with the snapshot returned by getSession, even if newer updates have already been applied to the store (for example when SessionRoute sends an initial message immediately after navigation, or when users switch sessions quickly). A late getSession response can therefore overwrite in-flight streamed frames and show stale/incorrect conversation content.
Useful? React with 👍 / 👎.
| /\[([^\]]+)\]\(([^)]+)\)/g, | ||
| '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-primary underline underline-offset-2 hover:text-primary/80">$1</a>', |
There was a problem hiding this comment.
Sanitize rendered markdown link URLs before HTML injection
The markdown renderer injects $2 directly into href and then renders via dangerouslySetInnerHTML; because quotes and URL schemes are not validated/escaped here, model-generated content can produce malicious links (e.g. javascript: URLs or attribute-breaking payloads). This creates an XSS vector in assistant-rendered messages.
Useful? React with 👍 / 👎.
P0: thinking blocks wired through useChat -> SessionRoute -> MessageList (were tracked in store but never rendered) P1 fixes: - SSE parser state persists across network chunks (was re-initialized) - Generation counter guards against double-stream corruption on rapid sends - Reader explicitly cancelled on abort (was continuing after signal) - Session hydration checks staleness before overwriting messages - Text blocks preserve interleaving with tool calls (was merging all into one) - Multi-line SSE data fields accumulated per spec (was overwriting) - react-markdown + remark-gfm + rehype-sanitize replaces hand-rolled regex renderer (eliminates XSS via attribute injection and javascript: URLs) P2 fixes: - session.error/aborted update last message status (was stuck in streaming) - initialMessage skips loadSession for new sessions (was overwriting) - ToolCallCard auto-expands on transition to error/blocked state - MutationObserver scoped to childList with RAF debounce (was scroll storm) - Command palette searches by title not UUID - StrictMode guard prevents double session creation - Tool calls and thinking blocks cleared on session terminal events Bundle: 210KB gzipped (budget: 500KB). Server tests: 1,485 pass, 0 fail.
Summary
Full chat client SPA for Phantom, connecting to the PR1 backend (#65, merged). Users can chat with Phantom via the browser with real-time streaming, tool activity cards, thinking blocks, and session management.
chat-ui/at repo rootchat-ui-builderstage for production buildsArchitecture
useSyncExternalStore-backed chat store dispatching all 24 SSE wire eventsuse-chathook with streaming state management#faf9f5light,#1a1917dark,#4850c4indigo accentKnown gaps (fix pass)
Test plan
bun run --cwd chat-ui typecheckpassesbun run --cwd chat-ui buildpasses (159KB gzipped)bun testserver tests pass (1,485 tests, 0 failures, no regressions)/chatin browser, verify streaming, tool cards, sidebar