ui-spa · diff

git:20261008.ec58209 to git:20261008.3d8aed9

1 added, 0 removed. Audit A to A.

---
description: Embedded SPA chat UI conventions (thinking row, context meter, rebuild)
globs: external/ui/**/*
alwaysApply: false
---
# Embedded UI (`external/ui`)
- Rebuild **`go:embed`** assets after UI changes: **`make build TAGS="http ui"`** (runs **`ui-build`**).
- **Prettier** - the SPA is Prettier-clean at the project's defaults (no config file; **`external/ui/.prettierignore`** keeps the build output and the vendored grammars out). Format what you touch with **`cd external/ui && npx prettier --write <file>...`**, or the whole tree with **`npm run fmt`**. **`make ui-format-check`** checks the whole tree: the pre-commit gate runs it whenever a commit stages SPA files (**`CODDY_HOOK_FORMAT`**, **`scripts/checks.sh`**), and so does CI's Lint job.
- **Thinking disclosure row** - duration must sit **next to** the **thinking** label, not at the trailing edge of the column. Markup keeps **`.thinking-dur`** inside **`.thinking-left`** in **`ThinkingMessage.tsx`**. Styles use **`.thinking-left { gap: 0 5px; }`** in **`styles.css`**. Do not drive label vs timer spacing with **`justify-content: space-between`** on **`summary.thinking-summary`**.
- **Workspace picks and the plate** (**`WorkspaceChips.tsx`**, the picks of the **plate over the composer** before a chat starts) - folder / branch picks plus a **worktree checkbox** (real **`input[type=checkbox]`**, **`composer-worktree-checkbox`**), backed by **`GET /coddy/workspace/context`** and **`POST /coddy/sessions/{id}/workspace`**. Keep the pure logic in **`chat/workspaceContext.ts`** and the MRU recents in **`chat/workspaceRecents.ts`** (do **not** inline them). The folder menu is Claude Desktop style: **Recent** rows (current marked **✓**) + **`Open folder…`** opening the **`WorkspaceFolderModal.tsx`** filesystem browser (rows navigate, **Open** picks the browsed folder). The branch pick and the checkbox render only when **`is_git_repo`**; the checkbox is checked+disabled when the session already runs in a linked worktree. The composer card has **no chip row**: the plate (**`chat/WorkspaceBar.tsx`**, joined to the top edge of the card, which squares its top corners with **`composer-card--joined`**) holds the workspace. Before the chat starts **`Composer`** builds it in pick mode (**`workspace-bar--pick`**: the picks are items of the plate's row, a lighter ground on hover and while their menu is open, never the accent; no git count before a session). **Chosen once**: with a non-empty transcript (**`workspaceLocked`**) the server returns **409** and **`ChatScreen`** hands the composer a static plate through its **`cardTop`** slot (a function handed the goal mark) - only in a git repository, or in any folder while the session has a goal; a running chat outside a repository and without a goal has no plate - which names the repository and the branch - the branch glyph in a linked worktree too, the tooltip naming the worktree; both icons a pixel above the middle of the lowercase letters - and, at its right edge, git's **`+A −D`** in a light frame that brightens with its ground on hover, never the accent, which opens the edits; its right edge is the improve-prompt wand's (both **12px** from the card's edge). The goal mark (**`.composer-goal`**, the target in the status's tone) stands on the plate left of git's count, only while the session has a goal; never on the bar under the field. Do not put a chip row back into the composer card. Menus reuse the **`mode-menu`** family: anchored portal on desktop (**`opens-down`** on hero, **`opens-up`** docked), bottom sheet on **`isMobileShell`**. Pre-session picks are pending in **`App.tsx`** (**`pendingWorkspaceRef`**) and applied on first send before **`POST /v1/responses`**. Contract: **`DESIGN.md`** (**Workspace picks on the plate**, **Bar over the composer**), **`docs/surfaces/web-ui.md`** (**Per-session workspace**).
- **Workspace of a new chat** - the start screen opens on the folder last picked in this browser (**`chat/workspaceCookies.ts`**: cookie **`coddy_workspace_dir`**, one per environment, written by **`switchWorkspace`** with no session; a folder that answers **400** is forgotten), else the server's default, and never on the folder of the chat just left (**`goHome`** hands nothing over). Its branch is read again on arrival and on the page's focus (**`refreshHomeWorkspace`**), a branch picked there overlaid. The worktree checkbox is one choice for every folder (cookie **`coddy_worktree`**). Before the first message there is no session, and the folder picked on the start screen lives in **`App.tsx`** (**`pendingWorkspaceRef`**, set through **`setPendingWorkspace`**, which mirrors the path into the **`pendingWorkspacePath`** state). **`chatWorkspacePath`** (**`chat/workspaceScope.ts`**) is the folder a chat runs in: the session's context path, else the pending pick, else the previewed default. Every cwd-scoped request - the composer's **`/coddy/slash-commands`**, **`/coddy/mentions`**, **`POST /coddy/mentions/check`**, **`/coddy/workspace/file`**, App's known skill names, Settings → Skills (its list and its Delete) and Subagents - is scoped with **`workspaceScope(sessionId, path)`**: the session header plus **`cwd=<folder>`** (query, never a header: a folder name may not be ASCII). Never fall back to the server default once a folder is picked. **`knownSkillNames`** is keyed by that folder (and **`configEpoch`**), not by the session id, so the first send of a new chat does not blank its own chips; another folder clears it at once and a late answer is dropped by a generation counter, as is a late **`workspace/context`** preview (**`workspaceCtxGenRef`**). The composer resets its pickers, its remembered no-match prefix and its fetch generations whenever **`sessionId`** or **`workspacePath`** changes. Contract: **`App.skillScope.test.tsx`**, **`Composer.test.tsx`** (*cwd-scoped requests follow the chat workspace*), **`SkillsSection.workspace.test.tsx`**, **`features/skills_session_workspace.feature`**.
- **Composer context ring** (indicator left of Send in **`Composer.tsx`**)
- The ring itself is **only** a stroked arc (relative context fill). Do **not** place a numeric percent label inside or on top of it. Percent usage and token counts belong in the tooltip only.
- **Idle home** (**`contextIdle`** when there is **no** `sessionId`): keep the arc at **zero** fill. Tooltip body is **exactly**: first line **`No context usage yet`**, second line **`Max context <n>`** (no session usage lines, **no** model name line).
- With an active session (including hero with **`#/s/...`** in the hash): show fill from **`tokenUsage`** vs **`maxContextTokens`**. Tooltip lists percent line, optional Input/Output/Total line(s) when **`tokenUsage`** is present, then Max context line. Do **not** add a **`Model …`** line in this tooltip (**Mode** exposes **`agent`** / **`plan`**; **`Model`** is the YAML backend pill next to it).
- Tooltip **presentation**: reuse the **`rail-tip`** styling (same family as narrow **navbar** hints). Anchor it **above** the ring, **horizontally centered** on it, with a **comfortable width** (use **`composer-context-tip-host`** / **`composer-context-tip`** rules in **`styles.css`**; avoid a cramped single-column tooltip).
- Prefer no extra chrome on the meter (no bordered "button tile" behind the SVG); **`.context-ring`** styling stays minimal. Stroke colors come from **`--coddy-context-ring-inner`** / **`--coddy-context-ring-fg`** (defined per theme in **`styles.css`**).
- **Transcript window** (issue #338; **`DESIGN.md`**, **Transcript window**; **`docs/surfaces/web-ui.md`**, **Long sessions**) - a long session is read page by page: **`loadMessages`** opens on the newest page (**`?limit=`**) and re-reads the live window it holds (**`?from=<offset>`**), never the whole history; **`loadOlderTranscript`** puts the page above (**`?limit=&before=`**) into **`olderTranscript`**, which no reload or merge touches and which is dropped when the reader is back at the newest message and the session is idle (**`slideTranscriptToTail`**); a large live window also starts over at the end of a turn (**`rebase`** in **`reconcileEndedTurn`**). A row with a focused text field and the tail while a permission or question prompt waits are never trimmed (**`pinTail`**). Page mapping lives in **`chat/transcriptFromMessages.ts`** (ids and notices numbered from the page's **`window`**), the window types and queries in **`chat/transcriptWindow.ts`**. On screen, **`chat/TranscriptList.tsx`** (**`useTranscriptWindow`**, arithmetic in **`chat/transcriptRenderWindow.ts`**) renders a bounded slice and keeps the reader's row still; its state stays there so a growing frame does not re-render the composer. Every row root carries **`data-row-id`** and is a direct child of **`.messages-inner`**; an edit's index is **`userMsgIndexBase`** plus the prompt's position. Browser check: **`npm run check:transcript`**.
- **Transcript scroll-to-bottom** (**`ScrollToBottomButton.tsx`**, rendered by **`ChatScreen`** inside **`.chat-bottom-inner`**) - a round control above the composer that takes the reader back to the newest message. Keep the arithmetic in **`chat/transcriptScrollPosition.ts`** (**`TRANSCRIPT_BOTTOM_THRESHOLD_PX`**, **`isTranscriptAtBottom`**, the metric and bottom readers, **`transcriptJumpDurationMs`**, **`easeTranscriptJump`**) and do **not** inline it again: one reading drives the stick-to-bottom follow, the button and the jump, on both scroll surfaces (**`.chat-scroll`** on wide shells, the document below **`1200px`**). The button **never unmounts** while a chat is open - **`is-visible`** toggles the transition, plus **`inert`** and **`tabIndex=-1`** when hidden. The jump runs in **`requestAnimationFrame`** (not **`scrollTo({behavior:"smooth"})`**), re-reads the end every frame so a streaming turn does not leave it short, and is cancelled by any **`wheel`** / **`touchstart`** / **`mousedown`** from the reader; **`syncTranscriptPosition`** must keep standing back while a frame is pending, or the button flashes back on for every frame above the band. The jump **never moves up**: an end at or above the current position writes nothing. On the stacked shell the document metrics read the **visual viewport** (**`visualViewport.height`** / **`offsetTop`**), because an overlaying on-screen keyboard (iOS Safari ignores **`interactive-widget=resizes-content`**) leaves **`innerHeight`** unchanged; the fixed docked block sits on **`bottom: var(--coddy-keyboard-inset, 0px)`**, which **`ChatScreen`** sets from **`keyboardInset`** on the visual viewport's **`resize`** / **`scroll`**. Do not measure the document against **`innerHeight`** again. Contract: **`DESIGN.md`** (**Transcript scroll-to-bottom button**), functional checklist: **`docs/surfaces/web-ui.md`** (**Transcript scroll-to-bottom**). A conversation opens on its newest message: **`ChatScreen`** re-arms stick-to-bottom and hides the button in a layout effect keyed on the session id, before the follow runs.
- **Composer field height and expand** (issue #342) - the field follows its text through **`chat/useComposerFieldHeight.ts`** with the arithmetic in **`chat/composerHeight.ts`** (floor read off the field, ceiling eight lines or 40% of the visible viewport); do **not** put a **`max-height`** back on **`textarea#composer`** or size the field anywhere else. The docked composer's expand control (**`chat/ComposerExpandButton.tsx`**, **`.chat-expand-composer`**, **`data-testid="composer-expand"`**) is the scroll-to-bottom circle's twin 10px over the composer (**`--coddy-composer-wrap-pad`** taken off), centred on the improve-prompt wand's vertical line (**`right: 8px`**), rendered and owned by **`ChatScreen`**; it never moves and the jump to the newest message stands 10px above it on the same line (**`.chat-bottom-inner:has(> .chat-expand-composer)`**), its glyph arrows to the corners (never a chevron, which read as a scroll arrow); the transcript has no jump up; do not put it back into the field. Expanded, header, control and card stand one step of **`--coddy-top-rhythm`** apart (the room **`ChatScreen`** passes as **`expandRoomPx`** subtracts the strip; it keeps to **`visualViewport.offsetTop`** under an overlaying keyboard), the jump is hidden, and the composer's pickers open at the field's foot within its height. The composer's pickers dim nothing (**`slash-sheet-backdrop--clear`**). On the stacked shell the pill and the title stand one step of the same variable apart; never give those gaps a number of their own. Send, queue, Escape and another chat fold it; nothing persists it. Contract: **`DESIGN.md`** (**Composer field height and expand**), checklist **`docs/surfaces/web-ui.md`** (**Long prompts in the composer**).
- **Quote from the transcript** (issue #342) - **`chat/TranscriptQuoteButton.tsx`** offers a selection inside **`.messages-inner`** as a quote; the draft stays the only record of it (Markdown **`> `** lines from **`quoteMarkdown`** / **`appendQuoteToDraft`** in **`chat/quoteDraft.ts`**, shared with the documentation reader), so do **not** add quote state beside the text. **`UserMessage`** draws the lines through **`splitQuoteBlocks`** as **`blockquote.msg-user-quote`**. Contract: **`DESIGN.md`** (**Quote from the transcript**), checklist **`docs/surfaces/web-ui.md`** (**Quoting the transcript**).
- **Send/stop circle** (**`Composer.tsx`** **`#btn-send`**) - **`composer-icon`** is a **perfect circle** (**`border-radius: 50%`**, square box). The shared **`composer-run-icon`** class is also used by scheduler run/stop buttons. **Play** is an **SVG triangle**, **17x17px**, never a text glyph; **stop** uses **`.composer-stop-square`** (**14x14px**, centered). Ring + stop stay in **`composer-bar-actions`** on the right (**`DESIGN.md`**, Composer primary action).
- **Model selector menu** (**`Composer.tsx`**, **`mode-menu--llm`**) - backend ids are **`vendor/model`**. Keep the filter/group/threshold logic in **`chat/llmModelMenu.ts`** (do **not** inline it): vendor headers (**`mode-menu-group-label`**) render only when more than one vendor is present (**`shouldGroupLlmModels`**); a filter input (**`mode-menu-filter`**, **`data-testid="model-menu-filter"`**, auto-focused) renders only when the backend count exceeds **`LLM_MENU_FILTER_THRESHOLD`** (5) and matches vendor / model name / full id case-insensitively (**`filterLlmModels`**); rows scroll under a ~5-row cap (**`mode-menu-scroll`**, **`max-height: min(175px, 50vh)`** in **`styles.css`**). Rows show **`displayLlmId`** (model name only) with the full id in **`title`**; **Enter** picks the first match, **Escape** closes, empty result renders **`model-menu-empty`**.
- **Mobile menu sheet** - on narrow shells (**`isMobileShell`** via **`shellBreakpoint.ts`**, **`max-width: 1199px`**) the **`Mode`** / **`Model`** / **`Reasoning`** portal menus render as a **full-width bottom sheet** (**`mode-menu--sheet`**, same family as the slash/at picker sheet) over a dimmed scrim (**`mode-menu-backdrop--scrim`**), not the anchored **`mode-menu--portal`** dropdown. Drive this from **`menuUseSheet = isMobileShell`** in **`Composer.tsx`**; the sheet overrides the desktop **`mode-menu--llm`** width cap and uses a **`46vh`** scroll cap. Keep the anchor positioning only on the desktop portal branch.
- **Tool call timer** - while an unresolved **`permission_prompt`** references a tool call id, **`ToolCallMessage`** freezes the **`thinking-dur`** label (**`permissionWaiting`** via **`permissionPendingToolCallIds`**).
- **Permission after reload** - SSE rows persist in **`localStorage`** (**`permissionPromptSessionStore.ts`**); pending **`run_command`** / fs tools without a tool result also get a synthetic **`permission_prompt`** on **`GET .../messages`** merge (**`restorePermissionPrompts.ts`**). Stop glyph is **`.composer-send-glyph` > `.composer-stop-square`** (never both classes on one node).
- **Composer auto-focus** - the app focuses the composer by itself (start screen, a conversation switch, History closing) only through **`composerAutoFocusAllowed()`** (**`chat/composerFocus.ts`**): never on a **touch-only** device, where focus opens the on-screen keyboard. A new place that focuses the composer without a tap goes through the same check.
- **Composer keyboard shortcuts** - what Enter does follows the **input device**, never the width: decide it with **`composerEnterAction`** in **`chat/composerEnter.ts`** (do **not** inline the rule again). With a keyboard (a narrow desktop window included) **`Enter`** and **`Cmd+Enter`** send; **`Shift+Enter`** is the browser's newline (not intercepted); **`Ctrl+Enter`** / **`Alt+Enter`** insert a newline at the caret through **`insertNewline`** + **`props.onChange`**, because browsers insert nothing for them. On a **touch-only** device (**`touchOnly`**, **`useSyncExternalStore`** over **`subscribeTouchOnly`** from **`shellBreakpoint.ts`**, query **`(any-hover: none) and (any-pointer: coarse)`**) Return stays the browser's newline and send is the button; **`enterKeyHint`** is **`enter`** there and **`send`** elsewhere. Enter during IME composition (**`nativeEvent.isComposing`** or **`keyCode` 229**) and a repeating held Enter never send; a composing key returns at the top of the composer's **`onKeyDown`**, before the pickers and the Ctrl+Z restore, so no picker takes a row, moves its highlight or closes on it either. There composing means **`isComposing`**, or keyCode 229 within **`COMPOSITION_END_KEY_WINDOW_MS`** of **`compositionend`** (Safari's order, tracked by **`compositionEndedAtRef`**, consumed by the next keydown, dropped by a new composition, and ignored by a keydown 100 ms or more after it); any other 229 is an ordinary Android key and must keep working the pickers. **`isMobileShell`** (the **`max-width: 1199px`** width query) still drives the bottom-sheet menus, not the keyboard. Outside composition, the picker **`Enter`** handlers (slash, at and command option menus) take precedence and run before this rule on all devices.
- **Multimodal model flag** - **`GET /v1/models`** exposes **`multimodal: bool`** per entry from YAML **`models[].multimodal`**. **`App.tsx`** reads it into **`ModelInfo.multimodal`**, derives **`llmModelMultimodal`** (**`useMemo`** over current **`llmModel`**), and passes it through **`ChatScreen`** → **`Composer`** as **`llmModelMultimodal?: boolean`**. Only render file attachment UI (file picker button, attachment previews) when **`llmModelMultimodal`** is **`true`**; keep the prop optional so the component degrades gracefully when models are not configured. After a successful **`PUT /coddy/config`** save, **`Settings`** fires **`onConfigSaved`** → **`App.tsx`** bumps **`configEpoch`** → re-fetches **`/v1/models`** so the attachment button appears without a page reload.
- **Live config reload** - the SPA reads every config-derived list once, at boot, so a model added mid-session is invisible until a page reload unless the server says otherwise. **`GET /coddy/events`** carries **`event: config_reloaded`** after **every** swap of the live configuration (the server publishes it from **`Server.ReplaceConfig`**, so the settings form, the agent's **`config_commit`** / **`config_rollback`** and a skill install are covered by one choke point). **`chat/serverEvents.ts`** parses it into the optional **`onConfigReloaded`** callback, delivered by **`subscribeSharedServerEvents`** (see the next rule); **`App.tsx`** bumps **`configEpoch`**, and every config-derived fetch depends on that counter - today **`GET /v1/models`** and **`GET /coddy/slash-commands`**. Add new config-derived fetches to the same counter rather than to a second one. The **Settings** drawer is the one reader outside that counter: its schema and config are the copy **`settings/settingsConfigStore.ts`** keeps for the life of the page (read on the first open only, never on every mount, which drew the drawer empty and rebuilt it - issue #359), and **`noteSettingsConfigReloaded`** reads that copy again on the event and on every reconnect of the stream. An open form takes a new copy only while it holds no edits of its own, and it takes it **while rendering**, never in an effect after it (a frame from an empty document makes a list read the address's **`?id=`** as a stale row and rewrite it); unsaved edits stay, and the form's **Reload** control is the deliberate way to drop them. A save puts the document it wrote into the copy at once (**`noteSettingsConfigSaved`**): a copy older than a save must never be drawn again, or the next save from it puts the replaced values back. On the server side the order inside **`ReplaceConfig`** is the contract - swap the live pointer, drop any cache derived from it (today the per-workspace slash-command cache), *then* announce - because a client re-reads the instant it sees the event and must never be handed the outgoing answers.
- **Shared events stream** - tabs of one environment share a single **`GET /coddy/events`** connection, because a browser keeps six HTTP/1.1 connections per host for all of its tabs and a stream per tab starved every other request once six tabs were open. **`App.tsx`** subscribes through **`subscribeSharedServerEvents`** (**`chat/sharedServerEvents.ts`**): a SharedWorker (**`chat/eventsWorker.ts`**, body in **`eventsWorkerHost.ts`**) where the browser has one, else the tab elected through Web Locks relaying on a BroadcastChannel, else a stream per tab. Do **not** open **`/coddy/events`** from anywhere else. A new event type goes into **`ServerEvent`**, **`parseServerEvent`** and **`dispatchServerEvent`** in **`serverEvents.ts`** - it must stay plain data, it crosses a MessagePort - and if the server replays it in the connect snapshot before **`ready`**, **`ServerEventsHub.track`** / **`hello`** in **`serverEventsHub.ts`** must replay it to a joining tab too. That module and the worker are loaded in a worker scope: no DOM, no **`window`**. The worker ships as the fixed file **`events-worker.js`**, listed in **`vite.config.ts`** (**`worker`**), **`scripts-sync-to-go.mjs`**, **`embed.go`**, the **`Dockerfile`** and **`TestEmbeddedUIPublicAssetsCacheControl`**; rename it everywhere or nowhere. Contract: **`docs/surfaces/web-ui.md`** (**Server events shared across tabs**).
- **File attachment flow** - Composer holds **`attachedFiles: File[]`** state; the hidden **`<input type="file">`** ref (**`data-testid="composer-file-input"`**) feeds **`setAttachedFiles`**. When sending, if files are present, **`onSend(text, files)`** passes them up; **`App.tsx`** reads each as a data URL (**`FileReader`**) and adds **`inline_files: [{name, data_url}]`** to the **`POST /v1/responses`** body. For **`agent`** / **`plan`** turns: the backend saves each file to `~/.coddy/sessions/<id>/assets/` with **`0o444`** permissions and injects a `<coddy_session_assets>` XML annotation into the user message content so the model can `read` or `cp` the files. The SPA strips this annotation from the display (and from copy-to-clipboard) via **`stripCoddyAttachmentsForUserDisplay`** in **`stripCoddyAttachments.ts`** and instead renders **file chips** (name + type icon) above the user bubble using **`msg-user-files`** / **`msg-user-file-chip`** CSS classes; **`parseSessionAssetFiles`** in the same file re-derives chip metadata from the XML on page reload so chips persist. For direct YAML model: each entry becomes an `image_url` content part sent inline to the provider. Multiple files in one request are supported; duplicate asset names are disambiguated with `_1`, `_2` suffixes by **`SavePartsToAssets`** in `internal/session/assets.go`.
- **Switching the environment in place** - between two remotes (a node and another node, a node and its relay) the page is not reloaded: **`switchTo`** (**`env/remoteEnv.ts`**) stores the environment without telling anyone, sets the hash, runs the **`onEnvironmentSwitch`** listeners, then notifies, and **`EnvScope`** (**`main.tsx`**) starts **`<App>`** over, keyed by the events stream's scope (**`serverEventsScope`**: the server and a fingerprint of its token) and **`switchGeneration`**, so the same remote chosen again and a token rotated under the one in use (**`syncActiveToken`**) start it over too - a long-lived reader holding the old token, the events stream first, starts again with the new one. To or from Local the page reloads, and nothing may be notified before that reload: an app started over on the new environment writes the hash, which cancels the reload. A module that keeps something of the environment at module level (a copy of its settings, its version, its host, its health) registers **`onEnvironmentSwitch`** to forget it, and an answer that lands after a switch is checked against the environment it was asked of (**`isAppEnvironment`** in **`App.tsx`**, the key comparison in **`env/activeHealth.ts`**) before it is kept. What must not start as "not known yet" after a switch - the page's own server and host name, which remotes are relays, whether the scheduler answered, the last picture of a relay's map - lives in **`env/pageMemory.ts`** for the life of the page and is reset for every test in **`vitest.setup.ts`**. Contract: **`DESIGN.md`** (**A switch does not blink**, **Swarm screen**); tests **`env/envRoutes.test.ts`**, **`env/EnvScope.test.tsx`**, **`App.swarmNode.test.tsx`**.
- **Server images in a remote environment** - an **`<img>`** does not go through the **`fetch`** shim, so a picture the server names by an API path (a user message's **`files[].preview_url`** / **`url`**, anything under **`/coddy/`** or **`/v1/`**) is shown through **`useApiImageSrc`** (**`external/ui/src/ui/env/apiImage.ts`**), never put in **`src`** as it is: in a remote environment or through a swarm relay it is fetched with the environment's **`remoteApiRequest`** (base URL in front, token in the header, never in a URL) and shown from an object URL released on unmount. Checked by **`UserMessage.test.tsx`**, **`apiImage.test.ts`** and **`npm run check:queue`** (the thumbnail and the original through the relay).
- **Layout grid** (**`DESIGN.md`**, **Layout grid**) - four tiers by viewport width: **phone** up to **599px**, **tablet** **600-1199px** (the stacked shell), **desktop** from **1200px**, **wide** from **1920px**. Every width query in **`styles.css`** and every **`matchMedia`** is a tier edge or a component threshold the grid lists by name (**640px** plan document card head, **700px** swarm screen padding, **761px** desktop rail pill, **1279px** documentation outline); the code reads the edges from **`shellBreakpoint.ts`** (**`PHONE_MAX_WIDTH_PX`** / **`phoneMaxWidthMediaQuery`**, **`SHELL_STACK_MAX_WIDTH_PX`**, **`WIDE_RAIL_MIN_WIDTH_PX`** / **`wideRailMinWidthMediaQuery`**) and never spells a width into **`matchMedia`**. A new width is a new row of the grid's component table first: **`layoutGridCss.test.ts`** reads that table as its allow-list (direction included) and fails on any other width, on range syntax or non-px units, and on a row no rule uses. A phone rule goes under **`@media (max-width: 599px)`**, never a threshold of its own (the phone rules used to be split between 480px and 520px).
- **Transcript containment** - nothing in the transcript is wider than the transcript, at any width: on the stacked shell its column is the page. A tool row's **`.thinking-head`** wraps (**`flex-wrap: wrap`**): **`.thinking-head .thinking-label`** is **`flex: 0 0 auto; max-width: 100%; overflow-wrap: anywhere`**, and the target, the failure marker and the duration sit in one group, **`.thinking-trail`** (**`flex: 1 1 7em; min-width: 0; max-width: max-content`**, **11em** with **`.thinking-trail--failed`**), which moves under the label as a whole when the label's line lacks that room; the target inside is **`flex: 0 1 auto; min-width: 0`** with the ellipsis. Do not put the three back into a non-wrapping unit, let the target drop under the label on its own (it left a one-pixel target and a lone duration), drop the group's **`max-width: max-content`** (a short group then wraps under a label it fits beside) or its **`min-width: 0`** (the automatic minimum is the target's whole text). **`.md`** carries **`overflow-wrap: anywhere`**; **`.md-inline-code`** is an 18px chip of a **14px** line box plus **3px 7px 1px** padding, **`max-width: 100%`**, wrapping inside itself. Contract: **`transcriptWrapCss.test.ts`**; live check **`npm run check:overflow`** (**`scripts/phone-overflow-check.mjs`** over **`src/phone-overflow-check.html`**) at every width of the grid.
- **Mobile MQ helpers** (**`shellBreakpoint.ts`**) - **`subscribeShellStack`**, **`snapshotShellStack`**, **`serverSnapshotShellStack`** (the **`max-width: 1199px`** layout query) and **`subscribeTouchOnly`**, **`snapshotTouchOnly`**, **`serverSnapshotTouchOnly`** (the input-device query **`touchOnlyMediaQuery`**) are exported for **`useSyncExternalStore`** use anywhere in the SPA. Layout follows the width, keyboard behaviour follows the device. Do not duplicate these in component-local functions.
- **Phone layout** (**`max-width: 599px`**, the phone tier of the grid) - one block at the **end** of **`styles.css`** (after the stacked-shell rules it narrows), nothing in it applies above **599px**, so tablets and desktops keep their layout; the Tasks control, the permission card and the live status line narrow themselves under the same query next to their own rules. Top bar: **`.rail-brand-sub`** hidden, icons **40px** with a **4px** gap, **`.rail-brand`** and its wrapper **`.rail-brand-tip-host`** (the bar's actual flex item) shrink and clip (**`flex: 0 1 auto; min-width: 0; overflow: hidden`**) while **`.rail-middle`** grows but never shrinks (**`flex: 1 0 auto`**, icons at the right edge), so icons never paint over the brand. What does not fit folds behind **More** (**`nav-more`**): keep the split in **`nav/navOverflow.ts`** (**`splitNavItems`**, **`navSlots`**; History, Swarm and the environment (**`nav/EnvironmentSwitcher.tsx`**, the rail item at the foot that opens the environment menu, beside the rail on a desktop and as a sheet on the stacked shell) never fold, return order Settings, Scheduler, Docs, Sign out, menu order Docs, Scheduler, Settings, separator, Sign out) and the measurement in **`NavRail`** (stacked shell only, pill width minus the brand's natural **`scrollWidth`**, so the answer does not depend on what is showing). Composer: **`.composer-tabs`** is a one-line strip that scrolls sideways under a right-edge fade with a trailing spacer; **`.composer-bar-actions`** (ring + Send) stay outside it and never shrink; the improve-prompt wand stands in the field's top right corner (**`position: absolute`**, the textarea and its mirror **44px** clear on the right). Under **`(max-width: 599px), (any-hover: none) and (any-pointer: coarse)`** the plate's picks and the worktree checkbox are **36px** tall, and git's count keeps its slim framed look with an invisible **40px** hit area (**`::after`**) over it, so a finger can hit them; do not make the framed count itself tall. **`.hero`** is **`grid-template-columns: minmax(0, 1fr)`** everywhere, so the start screen never widens the page. Settings tile names (**`.settings-tile-title`**) wrap to two lines instead of an ellipsis, since a phone has no hover for the tooltip. Text fields are at least **16px** under **`(max-width: 599px), (any-hover: none) and (any-pointer: coarse)`** (iOS Safari zooms into smaller ones), the composer textarea and **`.composer-mirror-inner`** together. Contract: **`phoneLayoutCss.test.ts`**, **`features/web_ui_phone.feature`**, **`DESIGN.md`** (**Phone layout**); live check at **360** / **375** / **393** / **430px**: no horizontal page scroll, Send intersects no chip, the brand intersects no icon.
+ - **Settings autosave** (issue #485) - the Settings form saves on its own: **`settings/settingsDraftStore.ts`** keeps the document for the life of the page (never in the drawer's state, which went away with the drawer) and sends it **`AUTOSAVE_MS`** after the last edit, under the revision the last save answered with. What waits for **Save** instead is marked in the schema (**`x-coddy-save`**, **`config.UISchemaSaveKey`** in **`internal/config/ui_schema.go`** / **`ui_schema_relay.go`**), never listed in the SPA; a new subsystem switch the form shows fails **`TestSubsystemSwitchesWaitForSaveInTheSettingsForm`** until it carries the mark. The rules over the document are pure in **`settings/settingsAutosave.ts`**: rows of a watched list are followed by id edit by edit (**`stepRowIds`**), so do not tell a removal from a rename by names. While a change waits, **Save** is highlighted (**`.has-pending`** with the count) and **`.settings-pending`** lists it; do not reduce that to a dot. Closing asks first only for a held change or a refused save. Contract: **`DESIGN.md`** (**Settings drawer**, **Saving**), **`docs/surfaces/web-ui.md`** (**Settings: opening and saving**), tests **`Settings.autosave.test.tsx`**, **`settingsAutosave.test.ts`**, **`settingsSaveStateCss.test.ts`**, **`features/web_ui_settings_autosave.feature`**; browser check **`npm run check:settings-autosave`** against a throwaway **`coddy serve`**.
- **Settings form layout** - a settings tab is fieldsets by meaning, never a column of loose fields. Lay a schema form out with **`SchemaForm`** **`groups`** (**`objectSectionGroups`** in **`settings/SettingsSection.tsx`** for the object tabs): the section's own **`enable`** switch opens the form outside every fieldset, a list or a nested object is a block of its own beside the groups, and a group stands where its first field stands. A list of plain values is bare inputs with a trash each and **Add** below (no **`dirs[0]`** label, no rule between rows); an entry of a list of objects is a frame without a legend. The tab order is **`rootOrder`** in **`internal/config/ui_schema.go`**, grouped by meaning, with the **Prompts** tab (id **`system`**) last. Contract: **`DESIGN.md`** (**Section form layout**), tests **`SchemaForm.groups.test.tsx`**, **`settingsSections.test.ts`**.
- **Settings field descriptions** - a settings form shows names and controls; a field's or a fieldset's description goes behind the **(i)** beside its name, never into a paragraph under it. Use **`FieldLabel`** (label + description), **`LegendWithHint`** (fieldset legend + description) or **`SwitchField`** (its **`description`** prop), all from **`settings/FieldHint.tsx`**; do **not** render **`<p className="settings-field-desc">{description}</p>`** for a description again. The tip is portalled to **`<body>`**, **`position: fixed`** above the (i), **`z-index: 1000`**: never move it back inside the field, where **`.settings-scroll`** clips it or grows a scrollbar for it. Paragraphs are for state only (fetch results, sign-in codes, warnings about a stored value). Contract: **`DESIGN.md`** (**Field descriptions**), tests **`FieldHint.test.tsx`**.
- **Escape and the screens of the rail** - every rail item but Sign out opens a screen (History, Scheduler, Swarm, Docs, Settings), and **Escape** closes the one on top the way its **×** does. The rule lives once in **`nav/railEscape.ts`**: **`App.tsx`** calls **`useRailScreenEscape`** with a **`Record<RailScreenId, RailScreen>`** of every screen (open, close), so a new rail item does not pass **`tsc`** (**`make lint`**, the pre-commit hook, CI; **`vite build`** does not check types) without its entry, its **`RAIL_SCREEN_DEPTH`** and its row in the page map of **`App.railEscape.test.tsx`**. Do **not** give a screen an Escape handler of its own. Escape undoes one step: a control that answers Escape **claims** it (**`preventDefault`** or stopping it) and the screen takes only an unclaimed one; a screen with a step of its own (the head's back arrow) registers it with **`useRailEscapeStep(<screen>, step)`**, or, for state App owns (the scheduler's open job), the table entry's **`close`** is that step. The screens listen on **`document`** in the bubble phase, after the page's controls and before **`window`** (the chat under the screen), and stop the key they took; a menu, popover or dialog answers Escape through **`useEscapeCloses(open, close)`** (**`components/useEscapeCloses.ts`**: capture phase on **`document`** plus **`preventDefault`**), or the screen, listening since before it opened, closes too; a control that answers only while focused claims the key in its own **`onKeyDown`**. Held, modified and composing Escapes close nothing. Contract: **`DESIGN.md`** (**Escape and the screens of the rail**), scenarios **`features/web_ui_rail_escape.feature`**, harness for component tests **`nav/railEscape.fakes.tsx`**.
- **Views of a chat** - a chat's files and background tasks are a row of buttons in the sticky chat header (**`HeaderViews`** in **`chat/ChatHeader.tsx`**, **`.chat-views`**, **`role="toolbar"`**), in this order: Files, and Background tasks at the right edge with the dot and **`running / total`** it always had; Files an 18px icon (the rail's and the top bar's size) with its short name on a desktop and a tablet, the icon alone in the top bar's 40px square on a phone; the full name in the tooltip (**`.chat-view-tip`**, the rail's tip tokens) and **`aria-label`** everywhere; the view on show pressed (**`aria-pressed`**) and put away by a second press. Pressed and pointed at, a button only brightens - text, border and ground from **`var(--text)`** - and never takes the accent, which stays the mark of tasks that run (**`is-running`**); so do the pressed toggles of the Files and edits windows, and the file on show in the tree (with a light inset outline) (**`chat/viewsLookCss.test.ts`**); a line of an open file is never painted, the line an address names is only scrolled to. Do **not** put a tab strip that switches views back into a panel head, or hide the views behind a menu: the dock (**`useRightDock`**) shows the background tasks only. The edits have one view, the **edits window** (**`changes/EditsView.tsx`**, headed **Edits**, never "Changed files"), framed and headed like the Files window - the same **`.files-dock-cluster`** sheet and distance from the edges, the tree switch and the title over the folder and git's **`+A −D`** on the left, **⋮** (Side by side, Collapse all / Expand all, Discard all), Expand and close on the right, and beside the diffs a tree of the changed files and only them (**`changes/EditsTree.tsx`**, git's letter at the end of a row), mounted next to the Files window under the shell's backdrop; do not give it a toolbar, a go-to-file list or a modal scrim of its own again - opened by git's count at the right edge of the plate over the composer (**`hasEdits`** over **`useWorkingCopy`**: there only while git reports uncommitted changes in the chat's folder), with **`#/s/<id>/changes`** as its address; do not bring back a dock face or a header button for them. The edits are git's report of the folder and nothing is recorded per turn: every view that shows them (the count on the plate, the window) reads the one store of **`changes/workingCopy.ts`**, never a fetch of its own, and it reads again on **`turn_ended`**, **`session_changes`**, a finished tool call and the page's focus. Discarding (a file or all, **`useDiscard`**) always asks first through **`useConfirm`**, and the window answers only an unclaimed Escape (**`isUnclaimedEscape`**). An icon beside a word sits on the middle of its lowercase letters (**`npm run check:files`** measures it at every width), and the files open in the **Files window** (**`files/FilesView.tsx`**, **`FilePreview.tsx`**), framed like the documentation reader over the chat and not a face of the dock: tree with a filter over the whole workspace (**`searchFiles`** over **`GET /coddy/mentions`**), open files as tabs kept per chat for the life of the page (**`openFilesMemory`**, forgotten on **`onEnvironmentSwitch`**), **`#/s/<id>/files?path=&line=`** as its address, **`Ctrl+Shift+F`** (**`isFilesHotkey`**, matched on **`code`**) and every **`openWorkspaceFile`** call opening it. An open file is its body alone: the tab names it, so do not put a head with its name, size, time, a rendered/source switch or a line field back over it; Markdown is shown as its source, never rendered, so nothing in a workspace file is loaded or run. A long file scrolls through (**`FilePreview`** reads the next 300 lines near the end of those on screen, the ones before near the top), so do not bring back page buttons; tabs that do not fit scroll sideways by the wheel and a swipe (**`TabStrip`**), with no arrows. Escape takes the window first and the dock next. Native media plays from the node's signed address, which a swarm relay carries to the node as it is; keep that address in the query and never put a bearer in a media URL. Contract: **`DESIGN.md`** (**Views of a chat**, **Workspace files window**), tests **`ChatHeader.test.tsx`**, **`files/FilesView.test.tsx`**, **`changes/EditsView.test.tsx`**, **`App.workspaceViews.test.tsx`**, **`features/web_ui_session_views.feature`**; browser check **`npm run check:files`**.
- **Telegram Mini App** (**`external/ui/src/ui/telegram/`**, issue #320) - everything Telegram-specific in the SPA lives in that folder. **`capture.ts`**, the first import of **`main.tsx`**, runs **`launch.ts`**: the launch parameters are read out of the fragment or the query, kept in **`sessionStorage`** (every access guarded) and taken out of the address before the router reads it; **`?session=<id>`** (the bot's link, held to the session id alphabet) opens that conversation in any browser. **`bridge.ts`** speaks Telegram's web events itself (**`TelegramWebviewProxy`**, **`window.external`**, the parent frame of Telegram Web, addressed by its origin (**`parentOrigin`**: the browser's **`ancestorOrigins`**, else the referrer, else Telegram Web's), never **`"*"`**, and heard from **`window.parent`** at that origin only): never load **`telegram-web-app.js`**, or any script from another origin, in **`index.html`**. **`miniApp.ts`**, called after **`bootstrapUiThemeFromCookie`**, configures the window (expand, swipes off, the header colours from the canvas tokens, Telegram's theme until the cookie says otherwise, ready last); **`backButton.ts`** makes Back an Escape and claims an unclaimed one, so the question card never takes it as a skip. A layer hears that Escape only through **`useEscapeCloses`** on the document: a click on Telegram Web's Back (a button of the parent page), like a tap on a sheet's title or a picture, leaves the frame's focus on **`body`**, so a layer listening in its own **`onKeyDown`** lets the press through to leaving the conversation. **`telegram.css`** (imported by **`miniApp.ts`** after **`styles.css`**, every rule under **`html[data-telegram-mini-app="true"]`**) lifts the bottom-anchored layers by **`--coddy-telegram-lift`** and grows the docked composer through a block in **`.chat-bottom-inner`**, never padding (the reserve's **`ResizeObserver`** reads the content box); it never sets **`overflow-x`** on **`html`** or **`body`**. A new bottom-anchored layer of the stacked shell gets its lift there. Contract: **`DESIGN.md`** (**Telegram Mini App**), **`ui/telegram/telegram.test.tsx`**, **`features/web_ui_telegram_mini_app.feature`**; browser check **`npm run check:telegram`** (the real **`coddy serve`** and its bot against **tgfake**, `CODDY_TGFAKE_BIN` or the version **`go.mod`** pins).
- **Diagrams and formulas** (issue #261) - a **`mermaid`** / **`svg`** fence is a **`DiagramBlock`** and LaTeX (**`$...$`**, **`$$...$$`**, **`\(...\)`**, **`\[...\]`**, a **`math`** fence) a **`MathInline`** / **`MathBlock`**, all chosen in **`markdown/Markdown.tsx`**. Mermaid and KaTeX are imported **only** through **`markdown/renderers.ts`** (dynamic **`import()`**): a static import of either puts it into **`app.js`**, which **`features/web_ui_diagrams.feature`** (**`appJSBudget`** in **`external/ui/bdd_diagrams_test.go`**) refuses. Lazy chunks go to **`chunks/`** under content-hashed names (**`vite.config.ts`**), copied whole by **`scripts-sync-to-go.mjs`**, embedded by **`embed.go`** and staged by the **`Dockerfile`**; **`ui.Handler()`** serves them **`immutable`**. A picture is always an **`<img>`** of a **`data:`** SVG (**`svgDataUrl`**), never SVG markup in the DOM, so an SVG the model wrote runs nothing. Single-dollar spans that are really text (prices, **`${NAME}`**, **`$HOME$PATH`**) go back to text in **`remarkLiteralDollars`** (**`markdown/mathDelimiters.ts`**); the vitest suite renders every page of **`docs/`** and fails on any formula there. Diagram colours come from the theme through **`mermaidThemeVariables`** (**`markdown/pictureRender.ts`**), every variable a diagram type draws with set, never left for Mermaid to derive; the contrast tests hold it. Every turn sends **`metadata.surface: "webui"`**, which gives the turn the system prompt block of **`external/httpserver/webui_prompt.go`** telling the model what the web UI draws (**`features/web_ui_surface_prompt.feature`**). Contract: **`DESIGN.md`** (**Diagrams and formulas**), **`docs/surfaces/web-ui.md`** (**Diagrams and formulas**); browser check **`npm run check:diagrams`**.
- Authoritative layout and tokens remain in the repo root **`DESIGN.md`**.
## Recoverable composer sends
- Optimistic composer clear remains reversible until the server accepts the turn.
- A network, file-read, or pre-response failure restores text and attachments and preserves the underlying cause where safe.
- Final reconciliation cannot erase the only error or recoverable draft.
- A report of a lost prompt is diagnosed from the session before the code: a request that never reached the server leaves no message in `messages.json` and no `serve.log` line after the turn, while the `session.json` mtime still matches the attempt (mark-read). Check `.coddy-turn.lock` with `flock` and `ui_log.json`, and tell a `409` refusal (not logged) from a connection that dropped.
## SPA tests and tooling
- Prettier breaks a long CSS value over several indented lines, so a source-contract test that matches a declaration by regular expression reads the rule body through a helper that squashes whitespace (**`squash`** in **`chat/viewsLookCss.test.ts`**, **`docs/docsReaderCss.test.ts`**, **`messages/artifactCubeCss.test.ts`**), never as the value spelled on one line.
- `SwarmView.test.tsx` reads the **last** `@media (prefers-reduced-motion: reduce)` block of `styles.css`. A new rule set with such a block goes before the swarm section.
- In Playwright, `goto` to the same URL that differs only in its `#fragment` is a same-document navigation and the page state survives it; vary a query parameter to start a page over.
- A zoomed image inside a flex stage needs `flex: none`, or the stage shrinks it back.
- `go test ./external/ui` can come back `(cached)` after a TypeScript edit, because the files vitest reads are not tracked by the Go test cache. Run it with `-count=1`.