# 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 768–1023 / 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 `@import`s — `@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-FFFF` → `U+????`), 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 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

- `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.
