Files
boocode/apps/web/CLAUDE.md
indifferentketchup afaca9e426 feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)
- 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>
2026-06-02 17:01:03 +00:00

8.8 KiB
Raw Blame History

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 root CLAUDE.md. This file auto-loads when you read/edit files under apps/web/.

Stack

  • React 18 + React Router v6 + Tailwind v4 + shadcn/radix-ui primitives.
  • Shiki for syntax highlighting (async codeToHtml in CodeBlock.tsx and FileViewer in FileBrowserPane.tsx).
  • Path alias: @/ maps to src/.
  • Mobile interaction primitives: useViewport (matchMedia; mobile <768 / tablet 7681023 / desktop ≥1024), useSidebarDrawer / useRightRailDrawer (Context + auto-close on useLocation().pathname change), useLongPress (500ms timer, synthetic contextmenu on [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 a case in the applyEvent switch in useSidebar.ts (no-op return prev is fine), and a subscribe in any hook that needs it (e.g. useSessionStream for refetch_messages).
  • hooks/useSessionStream.ts — WebSocket per session; applyFrame reducer builds the message list from streaming frames.
  • hooks/useUserEvents.ts — Single app-level WS to /api/ws/user with exponential-backoff reconnect. Forwards frames onto the sessionEvents bus.
  • hooks/useSidebar.ts — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by globalThis.__boocode_sidebar_subscribed for HMR safety. Every new SessionEvent type needs a case in applyEvent.
  • api/client.ts — Centralized typed fetch wrapper. All endpoints under api.*.

Font / CSS pipeline

  • Tailwind v4's @import "tailwindcss" strips font URLs from subsequent CSS @imports — @fontsource* packages must be JS side-effect imports in apps/web/src/main.tsx, not @import in globals.css, or the woff2 files never reach dist/.
  • Lightning CSS (inside @tailwindcss/postcss v4) collapses contiguous unicode-ranges to wildcard shorthand (U+0000-FFFFU+????), which iOS Safari/Vivaldi mishandles (silently drops the font for those codepoints). Use explicit non-collapsible subranges (e.g. U+2500-259F, not U+2500-25FF). The apps/web build script greps dist/assets/*.css for U+2500-259F and fails the build if missing — preserve that guard.
  • @font-face blocks must live AFTER all @import statements (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-mono ships subsetted woff2s that don't cover U+2500-259F (box drawing/block elements, used by opencode's banner). "NL" = No Ligatures (matches font-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 on document.fonts.load(<font-name>) resolving before term.open() (fontsReady in TerminalPane.tsx). iOS Safari/Vivaldi also reclaim WebGL contexts from backgrounded tabs: keep webgl.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 15 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

  • overflowWrap not wordWrap — TypeScript's CSSStyleDeclaration marks wordWrap deprecated (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-rolled Switch lives in SettingsPane.tsx) and a Dialog-based panel for "drawers".
  • inferLanguage() from lib/attachments.ts is the canonical file-extension→language map. CodeBlock.tsx keeps its own LANG_MAP because it also resolves markdown fence names.
  • Two UI event buses: hooks/sessionEvents.ts for DB-state events (chat_created, session_updated); lib/events.ts for ephemeral UI (sendToTerminal, terminalsRegistry). Don't merge — different subscriber lifecycles.
  • vite.config.ts proxy 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 resets activePaneIdx whenever panes changes. New-pane creation on mobile must push ?pane= atomically — addPaneAndSwitch does this; addSplitPane returns 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 with overscroll-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 one setState that itself calls another setState (e.g. setClosedPaneStack inside a setPanes updater) is double-invoked in dev. Make such nested updates idempotent — useWorkspacePanes's appendClosed dedupes a value-identical top entry for this reason.
  • CoderPane uses ChatInput (components/panes/CoderPane.tsx): shares BooChat's ChatInput for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. sendOneMessage is the send callback; queued messages drain via useEffect when sending goes false.
  • AgentComposerBar filters e.installed: provider snapshot entries with installed:false (loading/unavailable) are dropped from the dropdown. getProviderSnapshot must await the full build — returning synchronous loading placeholders 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.tsx gated by isCoder && !isMobile. Mobile coder controls (● ×) live in Session.tsx next to MobileTabSwitcher/NewPaneMenu. AgentComposerBar (provider/mode/model pickers) renders inside CoderPane.tsx on both; the ● status dot is passed via connected prop.
  • MessageBubble shared between BooChat and BooCoder (components/MessageBubble.tsx): optional actions?: MessageActions + hideActions? props; CoderPane overrides via CoderMessageList. CoderMessageList passes CoderMessageWire as unknown as Message — the coder shape lacks metadata/kind/summary, so they're undefined (not null). Null-guards on any Message field MUST use loose != null, not !== null (undefined !== null is true.kind throws → blank-screen crash). The cast hides this from tsc; build passes while runtime crashes.