- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.8 KiB
apps/web — BooChat frontend (deep reference)
Per-app engineering notes for
apps/web/src/. The frontend is a single React SPA that also hosts the BooCoder'coder'pane. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the rootCLAUDE.md. This file auto-loads when you read/edit files underapps/web/.
Stack
- React 18 + React Router v6 + Tailwind v4 + shadcn/radix-ui primitives.
- Shiki for syntax highlighting (async
codeToHtmlinCodeBlock.tsxandFileViewerinFileBrowserPane.tsx). - Path alias:
@/maps tosrc/. - Mobile interaction primitives:
useViewport(matchMedia; mobile <768 / tablet 768–1023 / desktop ≥1024),useSidebarDrawer/useRightRailDrawer(Context + auto-close onuseLocation().pathnamechange),useLongPress(500ms timer, syntheticcontextmenuon[data-tab-id]),usePullToRefresh(80px threshold, 600ms hold),SwipeablePaneTab(60px close, 30px vertical bail). Tap-target convention:max-md:min-h-[44px] max-md:min-w-[44px]. Mobile headers:border-b px-3 sm:px-4 py-2+paddingTop: 'max(0.5rem, env(safe-area-inset-top))'. Hamburger left, FolderTree right.
Key patterns
hooks/sessionEvents.ts— Module-singleton event bus (Set of listeners) for cross-component communication: session renames, file-open, attachment dispatch. 26-arm discriminated union (and growing). Adding an event type also requires acasein theapplyEventswitch inuseSidebar.ts(no-opreturn previs fine), and a subscribe in any hook that needs it (e.g.useSessionStreamforrefetch_messages).hooks/useSessionStream.ts— WebSocket per session;applyFramereducer builds the message list from streaming frames.hooks/useUserEvents.ts— Single app-level WS to/api/ws/userwith exponential-backoff reconnect. Forwards frames onto the sessionEvents bus.hooks/useSidebar.ts— Module-singleton withSet<setState>subscriber pattern; one bus subscription guarded byglobalThis.__boocode_sidebar_subscribedfor HMR safety. Every newSessionEventtype needs acaseinapplyEvent.api/client.ts— Centralized typed fetch wrapper. All endpoints underapi.*.
Font / CSS pipeline
- Tailwind v4's
@import "tailwindcss"strips font URLs from subsequent CSS@imports —@fontsource*packages must be JS side-effect imports inapps/web/src/main.tsx, not@importinglobals.css, or the woff2 files never reachdist/. - Lightning CSS (inside
@tailwindcss/postcssv4) collapses contiguous unicode-ranges to wildcard shorthand (U+0000-FFFF→U+????), which iOS Safari/Vivaldi mishandles (silently drops the font for those codepoints). Use explicit non-collapsible subranges (e.g.U+2500-259F, notU+2500-25FF). Theapps/webbuild script grepsdist/assets/*.cssforU+2500-259Fand fails the build if missing — preserve that guard. @font-faceblocks must live AFTER all@importstatements (CSS spec). Earlier placement silently breaks every subsequent@import.- JetBrainsMono Nerd Font self-hosted in
apps/web/src/fonts/(TTF from ryanoasis/nerd-fonts) —@fontsource-variable/jetbrains-monoships subsetted woff2s that don't coverU+2500-259F(box drawing/block elements, used by opencode's banner). "NL" = No Ligatures (matchesfont-feature-settings: "liga" 0); "Mono" = single-cell icon width so TUI layouts don't desync. - xterm-addon-webgl rasterizes glyphs via Canvas2D into a GPU atlas; Canvas2D does NOT honor
font-display: block— it uses whatever font is registered. Gate xterm init ondocument.fonts.load(<font-name>)resolving beforeterm.open()(fontsReadyinTerminalPane.tsx). iOS Safari/Vivaldi also reclaim WebGL contexts from backgrounded tabs: keepwebgl.onContextLoss(() => webgl.dispose())+ recreate via visibilitychange. Do NOT manually dispose+recreate the addon after font load — iOS silently fails the second GL context creation and drops to DOM renderer with stale metrics.
Multi-pane workspace
Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). Pane state lives in sessions.workspace_panes jsonb for cross-device sync. PATCH /api/sessions/:id/workspace persists; session_workspace_updated user-channel frame broadcasts to every device. useWorkspacePanes debounces saves 300ms and dedups echoes by JSON string (legacy localStorage key seeded once on first hydrate, then no longer written). validatePanes(validChatIds) prunes panes referencing deleted chats. Each chat lives in at most one pane; the per-pane tab strip tracks chatIds[] + activeChatIdx, reorder via native HTML5 drag. workspace_panes is a WorkspaceState envelope {panes, tabNumbers, nextTabNumber, closedPaneStack} (tabNumbers = stable session-scoped chatId→number, never reused; closedPaneStack = reopen LIFO, max 10, persisted); hydrate (toWorkspaceState) and the server PATCH validator (z.union([array, envelope])) both accept the legacy bare array and normalize. Closing a chat pane relocates its tabs to the oldest chat/empty pane; reopenPane strips restored chatIds from all live panes first. read_tab_by_number resolves number→chatId through tabNumbers.
Frontend conventions
overflowWrapnotwordWrap— TypeScript's CSSStyleDeclaration markswordWrapdeprecated (error 6385).- shadcn primitives live in
components/ui/. Don't modify them unless adding a new primitive. ui/primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a<button role="switch" aria-checked>toggle (a hand-rolledSwitchlives inSettingsPane.tsx) and a Dialog-based panel for "drawers".inferLanguage()fromlib/attachments.tsis the canonical file-extension→language map.CodeBlock.tsxkeeps its ownLANG_MAPbecause it also resolves markdown fence names.- Two UI event buses:
hooks/sessionEvents.tsfor DB-state events (chat_created, session_updated);lib/events.tsfor ephemeral UI (sendToTerminal,terminalsRegistry). Don't merge — different subscriber lifecycles. vite.config.tsproxy entries are order-sensitive: more-specific prefixes (/api/term,/ws/term) must come BEFORE/api.- Mobile pane URL sync (
Session.tsx): the?pane=<id>effect resetsactivePaneIdxwheneverpaneschanges. New-pane creation on mobile must push?pane=atomically —addPaneAndSwitchdoes this;addSplitPanereturns the new pane id. - A scrollable list inside a Dialog on mobile: cap
DialogContent(max-h-[85vh]+grid-rows-[auto_minmax(0,1fr)_auto]) and make the list the single scroll region withoverscroll-contain— otherwise touch-scroll drags the whole fixed modal / chains to the page. - xterm.js v5 uses canvas rendering — the browser doesn't see xterm's selection, so the native right-click Copy doesn't work for terminal text. App keybindings (
Cmd/Ctrl-C,Cmd/Ctrl-Shift-C) are the path. - React StrictMode is on (
main.tsx): an updater passed to onesetStatethat itself calls anothersetState(e.g.setClosedPaneStackinside asetPanesupdater) is double-invoked in dev. Make such nested updates idempotent —useWorkspacePanes'sappendCloseddedupes a value-identical top entry for this reason. - CoderPane uses ChatInput (
components/panes/CoderPane.tsx): shares BooChat'sChatInputfor full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send.sendOneMessageis the send callback; queued messages drain viauseEffectwhensendinggoes false. - AgentComposerBar filters
e.installed: provider snapshot entries withinstalled:false(loading/unavailable) are dropped from the dropdown.getProviderSnapshotmust await the full build — returning synchronousloadingplaceholders makes every provider vanish; surfacing loading states needs a client poll. - Pane header architecture (mobile vs desktop): desktop coder pane header (BooCode label + [+] [×]) lives in
Workspace.tsxgated byisCoder && !isMobile. Mobile coder controls (● ×) live inSession.tsxnext toMobileTabSwitcher/NewPaneMenu.AgentComposerBar(provider/mode/model pickers) renders insideCoderPane.tsxon both; the ● status dot is passed viaconnectedprop. - MessageBubble shared between BooChat and BooCoder (
components/MessageBubble.tsx): optionalactions?: MessageActions+hideActions?props; CoderPane overrides viaCoderMessageList.CoderMessageListpassesCoderMessageWire as unknown as Message— the coder shape lacksmetadata/kind/summary, so they'reundefined(notnull). Null-guards on anyMessagefield MUST use loose!= null, not!== null(undefined !== nullistrue→.kindthrows → blank-screen crash). The cast hides this from tsc; build passes while runtime crashes.