DESIGN.md · git:20261016.cf535c8 · 2026-10-16 · sha256 51b5e65eb6b0463c

DESIGN.md git:20261016.cf535c8B

Immutable. This exact content never changes and is served at /api/v1/blob/51b5e65eb6b0463c.

# Coddy Agent UI Specification

Purpose: authoritative reference for the embedded SPA built from `external/ui/`. Tokens and layouts live here before CSS tweaks land in production stylesheets.

## Design references

Store the design reference images under `docs/assets/` and link to the specific file when describing a pixel sensitive UI detail. Navbar parity with Cursor - style mockups lives in [`docs/assets/INDEX.md`](docs/assets/INDEX.md) (see Navbar section).

## Foundations

### Contract ownership and verification

- **One fact, one visual location.** A status, result, duration or action is shown in the surface that owns it and is not repeated in adjacent transcript rows, panels or labels unless accessibility needs an equivalent hidden name.
- **Measure browser geometry at the browser boundary.** A geometry-sensitive contract uses a live-browser check when jsdom cannot perform layout. Component and source-contract tests still own DOM semantics, but they do not substitute for measured geometry.
- **External products are references, not contracts.** A product may explain historical inspiration; only the behavior specified here is normative for Coddy.

### Color

| Token | Hex | Usage |
|-------|-----|-------|
| background | `#121212` | main canvas |
| nav rail | `#252525` | icon rail |
| session list | `#1E1E1E` | secondary column |
| accent | `#9333EA` | actions, pills, emphasis |
| text primary | `#FFFFFF` | default copy |
| text muted | `#9CA3AF` | captions, timestamps |
| user bubble | `#2D2D2D` | outgoing chat |

### Light and dark theme

- **Default:** dark (`data-theme="dark"` on **`<html>`**).
- **Persistence:** cookie **`coddy_ui_theme`** (`dark` | `light`), same lifetime pattern as **`coddy_nav_rail`**.
- **Bootstrap:** inline script in **`external/ui/src/index.html`** applies the cookie before first paint; **`main.tsx`** calls **`bootstrapUiThemeFromCookie()`** on load.
- **Toggle:** **Settings** drawer (**`#/settings`**) → **Appearance** tab → theme swatch grid (**`AppearanceThemePicker`**, **`data-testid="theme-swatch-<id>"`**). Theme selection applies immediately and is client-side only (no config save).
- **CSS:** semantic tokens on **`:root`** / **`[data-theme="dark"]`**; **`[data-theme="light"]`** overrides **`--text`**, **`--bg`**, glass, canvas gradients, etc. Foreground tints use **`color-mix(in srgb, var(--text) …%, var(--coddy-blend-base))`** (**`transparent`** on dark, **`#ffffff`** on light) so text stays opaque and readable on each canvas. Hero headline gradient uses **`--coddy-hero-muted-mid`** / **`--coddy-hero-muted-end`** (no light-gray stops on a light background).

| Token (light) | Hex | Usage |
|---------------|-----|-------|
| background | `#F8F8FA` | main canvas |
| text primary | `#18181B` | default copy |
| text muted | `#52525B` | captions |
| glass panel | `rgba(255,255,255,0.9)` | composer, drawers (light frost, not dark tint) |

### Localization and language

- **Picker:** one native language select **directly under the theme swatch grid** in **Settings → Appearance** (**`AppearanceLanguagePicker`**, **`data-testid="appearance-language-select"`**). It renders **Auto** followed by every locale in **`UI_LOCALE_IDS`**; the current registry supplies **English** and **Русский**. The select fills the available settings width and remains usable on narrow shells. It lives inside `.appearance-sheet-body`, so it inherits the centered placeholder guard covered by `appearanceSettingsLayout.test.tsx`.
- **Persistence:** cookie **`coddy_ui_lang`** stores a registered locale id (currently `en` or `ru`), with the same lifetime/flags as **`coddy_ui_theme`** (path `/`, `SameSite=Lax`, `; Secure` on https, 1-year `Max-Age`). **Auto** stores no cookie and resolves from **`navigator.language`** on each load; the cookie is cleared when Auto is chosen. Purely client-side — no config save (matches the Appearance tab convention).
- **Bootstrap:** **`main.tsx`** calls **`initLocale(bootstrapUiLocaleFromUrlOrCookie())`** before React mounts; resolution order is **`?lang=<registered-id>`** (URL, also persisted to the cookie) > cookie > **`navigator.language`**. Sets **`document.documentElement.lang`**.
- **i18n engine:** **`external/ui/src/ui/i18n/`** — `translate(key, params)` / `t`, a locale store with `useSyncExternalStore` subscriptions, and **`I18nProvider`** + **`useT()`**. **`locales.ts`** is the single registry for supported locale ids, picker label keys, and dictionaries; **`main.tsx`** wraps the app and shared confirmation provider in **`I18nProvider`**. Dictionaries use dot-notation keys grouped by area. **`useT()` works without a provider** (falls back to `translate`), so components render in tests without wrapping; default-English values match the former hardcoded literals exactly so existing English-asserting tests keep passing.
- **Dictionary contract:** every registered dictionary must contain the same keys and interpolation tokens as the default locale. Add or change the key in every dictionary in the same patch; `messagesParity.test.ts` validates the contract and prevents a newly registered locale from silently drifting.
- **Counted copy:** anything that reads "N things" goes through `translatePlural` / `tp(key, count)`, not a `count === 1` ternary in the component. The dictionary stores one entry per CLDR category under `key.category` (`tasks.chip.running.one` / `.other` in English; `.one` / `.few` / `.many` / `.other` in Russian), the entry always receives `{count}`, and each locale supplies exactly the categories `Intl.PluralRules` says it can produce — the parity test derives the expected set from that, so Russian declensions cannot silently regress to a single form.
- **Russian wording:** the adjective for agent is **агентный** (never агентский), an agent a session spawns is a **субагент** (never сабагент), and a git worktree stays **worktree** in the Russian interface (never рабочее дерево: the composer's chip, its tooltips and the documentation all say the English word). This product contract is repeated in the paired Russian wording rule ([Cursor](.cursor/rules/russian-wording.mdc), [Claude Code](.claude/rules/russian-wording.md)); **`ruWording.test.ts`** reads every value of the Russian dictionary against it, since the parity test compares keys only.
- **Reactivity:** the controlled select value is driven by local `choice` state (cookie-derived), because picking **Auto** can resolve to the already-active locale (no locale-store notification); label translation re-renders via the locale store subscription.
- **Coverage:** Appearance + Settings surfaces are translated (Settings shell, sections, MCP, Skills, CodexAuth, ModelField/Picker, Combobox), the schema-driven settings fields are translated as well (labels and descriptions of providers, models, agent, tools, subagents, hooks, memory, compaction, scheduler, logger, gateways, sessions, and the Prompts tab's parts, resolved by section id and field path in `settings/schemaI18n.ts`, falling back to the schema's own text), and the conversation surfaces are translated too: nav rail, hero title, composer (modes, model picker, attachments, slash/@ menus, environment and folder modals), message rendering (thinking, tool calls, compaction, copy controls), permission and question prompts, plan document card, the session goal's chip, popover and transcript rows, History sidebar, scheduler drawer and job editor, background tasks panel, the env health banner, and the swarm screen with its topology graph. Shared destructive confirmations for drafts, chats, and scheduler jobs are translated as well.

### Frosted glass panels

Floating **composer** card, **History** drawer chrome, **skills** slash menu, **Mode**, and **Model** dropdowns share **`--coddy-glass-panel-*`**: tint plus **`backdrop-filter`** on that surface **only**, so frosting stays **inside** the panel outline. Dimming overlays behind History or the slash sheet use **`--coddy-overlay-scrim-bg`** (**no** fullscreen blur behind the overlay).

### Typography and spacing

- System stack: **`system-ui`**, `-apple-system`, **`Segoe UI`**, **`sans-serif`**
- Comfortable padding: **`12px`** grid, radius **`12px`** (pill buttons **`999px`**)
- Density tuned for dashboards: desktop layout (single navigation style + fluid chat). Sessions are a drawer overlay.

### Token usage HUD

Muted footer row under composer shows **`input` / `output` / `total`** counts from streamed **`token_usage`** SSE payloads. Numbers update after each backend LLM pass (between tool executions), not per model token emitted over the wire.

Token usage totals are persisted per session and restored after restart.

## Layout

Left-to-right zones:

1. **Nav rail**: **History** opens the session list overlay; **Scheduler** opens the cron jobs drawer (requires **`coddy serve`** built with **`http,scheduler`** and scheduler enabled); **Docs**, below the spacer and above **Settings**, opens the documentation reader (see **Documentation screen**). Background tasks are **not** in the rail: they belong to one chat, so their opener sits in that chat's header (see **Background tasks panel**). **Escape** closes whatever screen a rail item opened (see **Escape and the screens of the rail**). Brand goes to the empty start screen. **Brand is text only** (**Coddy** plus **agent**; a phone shows **Coddy** alone, see **Phone layout**), **no** circle or logo mark before the label, even if a reference mockup shows one. Optional **narrow vs wide rail** (**icons only vs icons plus labels**) on viewports **`min-width: 1920px`**, persisted in **`coddy_nav_rail`** cookie (**`narrow`** default). **Brand**, **History**, **Scheduler**, and **Settings** use real **`href`** fragment targets so **middle-click** or **Ctrl/Cmd-click** opens a **new tab** on the same origin for parallel chats. GitHub and API docs links are **not** in the rail — they appear only in the **start-screen footer** (see Repo links below).
2. **Session list**: **always a drawer overlay** with a dimming backdrop. It must **not** consume a second grid column or shrink the chat canvas (no inline sessions column beside the rail at any breakpoint). **Panel chrome title copy is History** (not "Chats"). There is **no** global hamburger that opens a separate app menu; the **stacked-lines control** in the wide rail header **only** collapses the rail to the narrow (icons-only) layout, matching the references. Each row is a real **`href`** to **`#/s/<sessionId>`** so **middle-click** opens that chat in a **new tab**.
3. **Chat canvas**: on **`min-width: 1200px`**, editable title and transcript share **`#messages`** with **`overflow-y: auto`**, and **`.chat-bottom`** is **`position: absolute`** with **`--coddy-chat-scrollbar-gutter`** padding so the composer does not cover the scrollbar track. The vignette above the docked composer remains a local transcript shield; in the light theme it blends toward **`--coddy-canvas-gradient-bottom`** instead of using the dark-theme black tint. The sticky title uses **`.chat-title-column`** (**`max-width: 920px`**, centered) so the title bar matches the composer stripe. The pinned title region (**`.chat-scroll-sticky-head`**) carries an **opaque** canvas backing (**`background-color: var(--coddy-canvas-gradient-top)`**) so the title stays fixed at the top of the scrollport while transcript text scrolls **behind** it — no see-through bleed. The shield matches the canvas top color in every theme, so it reads as the title card floating over the page rather than a separate bar. On **`max-width: 1199px`** (phones, tablets, and smaller desktops), **`body`** scrolls (native scrollbar); **`.rail-column`** (top bar with brand and links) is **`position: fixed`** to the **viewport top** (**`.shell-main`** gets **`padding-top: var(--coddy-mobile-top-inset)`** so content clears it). The chat title row (**`.chat-scroll-sticky-head`**) is **`position: sticky`** with **`top: var(--coddy-mobile-title-sticky-top)`** (**`--coddy-mobile-top-inset` plus `--coddy-mobile-chat-stack-gap`**, same **12px** token as title **`padding-bottom`**) so spacing under the rail matches title-to-first-message rhythm. Only **`.rail-pill`** is frosted. In active chat, **`.chat-bottom`** is **`position: fixed`** to the viewport bottom so the composer stays on screen while **`chat-scroll-tail`** reserves space, **`ChatScreen`** uses **`window`** for stick-to-bottom, and the skills slash menu uses the same **`slash-menu--portal`** path as desktop (**`createPortal`**).

The right insights rail is removed for the current milestone.

### Settings drawer (tabbed master–detail)

The **Settings** drawer (**`#/settings`**, **`Settings.tsx`**) is **tabbed**, not one long sheet. Tabs are derived from the live config JSON Schema (**`deriveSettingsSections`** over **`/coddy/config/schema`**): each top-level property is a tab (label from its **`title`**, or from **`settings.section.<id>.label`**) in the schema's own order (**`rootOrder`** in **`internal/config/ui_schema.go`**, pinned by **`TestUISchemaRootPropertyOrder`**), which reads in groups of meaning: where the models come from (**LLM providers**, **Logical models**), the loop that runs them (**ReAct loop**, **Context compaction**, **Memory**), what the agent can do (**Tools and permissions**, **MCP servers**, **Skills**, **Subagents**, **Hooks**), what runs it without a person at the composer (**Scheduler**, **Gateways**), and the operation of the process (**Logger**, **Metrics**, **Prompts**). A section the binary cannot run is left out of the schema (**`config.UISchemaJSONHiding`**, fed by **`httpserver.Options.UnbuiltSections`**: the **Metrics** tab in a build without the **`metrics`** tag), while it still travels in the settings document, so a save writes it back to the file untouched. The rarely edited tail (**`prompts`**, **`instructions`**; **`SYSTEM_KEYS`** in **`settingsSections.ts`**) folds into a single **Prompts** tab (id **`system`**, **`#/settings/system`**: the address and the id kept their old name), which always closes the list wherever its keys stand; the scheduler, the logger and the gateways are features with tabs of their own, and the **`sessions`** section (where the bundles are stored) is edited on the **Sessions** tab (**`SESSIONS_CONFIG_KEY`**). Two client-side tabs are placed **first** - **Appearance** (the default tab) and **Sessions** (**`sessions_manager`**, the session management table below). Both render before the schema has loaded; the Sessions tab adds its **Storage** fieldset once it has. There is no separate Appearance/Skills flyout or **`settings-dock-cluster`** side panel — both are tabs now.

- **Saving**: there is **no per-section save button**. The drawer footer keeps only **Reload** (**`data-testid="settings-reload"`**) and **Save all** (**`data-testid="settings-save"`**, PUTs the whole config after `/coddy/config/validate`). **Appearance** is client-side only and never touches the config PUT. **A save that went through says so on the Save button alone**: it turns green (**`.is-saved`**: green border, wash and a 2px halo, a short scale pop) for **`SAVE_SUCCESS_MS`** (2 s, **`Settings.tsx`**) and eases back to its own colour, with no line of text on screen (a screen reader hears **Saved** from an **`.sr-only`** status). The green is a state rather than a frame of the pop, so it holds under **`prefers-reduced-motion`**, where only the pop goes. **A failed save says why in words** in the lead pane (**`.settings-lead-pane`**, **`.settings-error`**), which stands between the head and the tabs only while a failed read or a failed save needs saying; the Save button stays as it was. The success line that used to sit in that pane (**All sections saved**) stayed on screen for as long as the drawer did, and it is gone. **Unsaved edits** (issue #485): a change applies only after Save, so while the form differs from the config it was read from or last saved (**`settingsFormDirty`** in **`settings/settingsDirty.ts`**: values compared as the form means them - empty values, a switch at its default and key order are no edit, the revision is left out) the Save button stands out (**`.settings-btn-primary.is-dirty`**: a solid accent fill, a white glyph and a 3px accent ring that breathes; the fill and the ring are the state and hold under **`prefers-reduced-motion`**, where only the breathing stops) and the footer opens with a line that says so (**`.settings-save-status`**, **`data-testid="settings-save-status"`**, **Unsaved changes**, empty otherwise, **`min-width: 0`** so it never pushes the buttons off a phone). Closing over unsaved edits - the close button, Escape through **`useRailEscapeStep`** and a click on the backdrop beside the drawer through **`useRailCloseGuard`** (**`nav/railEscape.ts`**) - asks first (**`ConfirmDialog`**, **`data-testid="settings-close-dialog"`**: **Save and close** / **Keep editing**), and a page reloaded or closed over them asks the browser (**`beforeunload`**), except for the reload of a switch to another environment: the form remembers the server it was read from, and Save on a page whose reload onto another server was stopped, or that switched while the save ran, refuses with a reason instead of writing there. An edit typed while a save runs stays unsaved against what was sent (a value put back included), and **Save and close** keeps the drawer open over it; a newer copy of the config does not replace a form edited during a save. Unsaved wins over the green of a save that just went through, and a close asked for while a save runs closes the drawer once that save leaves nothing unsaved. Contract: **`Settings.unsaved.test.tsx`**, **`App.settingsUnsaved.test.tsx`**, **`settingsDirty.test.ts`**, **`settingsSaveStateCss.test.ts`**, **`features/web_ui_settings_unsaved.feature`**; live check **`npm run check:settings-unsaved`**.
- **Loading**: the app keeps the schema and the config the drawer reads (**`settingsConfigStore.ts`**), so **only the first open of the page reads them**; every later open draws the tab the address names from that copy in its first frame, with no request and no other tab in between. The copy follows the server: **`config_reloaded`** and every reconnect of the events stream read it again in the background (**`noteSettingsConfigReloaded`** from **`App.tsx`**), and an open form takes the new copy while it holds no edits of its own - unsaved edits stay, and **Reload** is the deliberate way to drop the ones the form held when it was pressed (a field typed into while its read is on its way keeps what was typed). A save turns the button green only when the form was not edited while the request ran; a copy the untouched form took in the meantime is no edit. A save puts what it wrote into the copy at once (**`noteSettingsConfigSaved`**), so a reopen before the read after the save lands, or after that read failed, draws the saved values rather than the ones the save replaced; a read older than the save is dropped, and a copy older than a read that failed is read again by the next open. The first open, and an open after a first read that failed, shows **the requested tab as a skeleton** (**`SettingsSkeleton`**, **`data-testid="settings-skeleton"`**: a frame of grey label and input bars tinted from **`--text`**, shimmering unless **`prefers-reduced-motion`**), with grey rows in the rail (**`.settings-nav-item--placeholder`**) or the tile grid (**`.settings-tile--placeholder`**) for the tabs the schema will bring; the narrow shell's head names the section from the dictionary (**`knownSectionLabel`**). Appearance, which needs no schema, no longer stands in for the tab a deep link asked for. A copy that lands is taken **while rendering**, not in an effect after it: a frame drawn from an empty document showed a list with no rows, and the list read the **`?id=`** of the address as a stale row and rewrote it.
- **Layout**: on **`min-width: 1200px`** the drawer is **`min(680px, …)`** wide - as wide as the section rail plus a content column that holds **three theme cards a row** on Appearance (**`auto-fill, minmax(130px, 1fr)`**, 10px gap), the cards keeping their size and the drawer giving way; **`appearanceSettingsLayout.test.tsx`** computes the column count from the two rules - with a vertical section rail on the left (**`.settings-nav`**) and the content panel on the right (**`.settings-tabs-layout`** is `display:flex; row`). On **`max-width: 1199px`** (same breakpoint as the rest of the shell, via **`isMobileShell`** / **`shellBreakpoint.ts`**) the nav is a **2-wide tile grid** master–detail (**`SettingsTileGrid`**, **`.settings-tile-grid`**): each tile (**`data-testid="settings-tile-<id>"`**) shows the section **title** on top and a short 3–5 word **description** below (**`SECTION_DESCRIPTIONS`** in **`settingsSections.ts`**) styled after the Scheduler job rows. The description clamps to two lines with an ellipsis (**`-webkit-line-clamp: 2`**) and the full text shows in a native **`title`** tooltip on hover. Tapping a tile opens that section with a **← Back to sections** arrow in the drawer's head (**`.settings-head-back`**); the desktop rail (**`SettingsNav`**) is unchanged. On that shell the drawer has **one inline inset**, **14px**, set by the head and the lead pane: the tile grid, the section content (**`.settings-scroll`**) and the reload / save footer all ride it (**`padding-inline: 14px`** inside the **`max-width: 1199px`** block), and the tile grid keeps the same **14px** off the head's rule above it, so every band of the panel starts and ends on the same edge. The wide layout keeps the content panel's own **8px**, because there its left edge is the seam against the section rail rather than the drawer's edge.
- **Search** (**`settings/settingsSearch.ts`**, **`SettingsSearchView.tsx`**, **`settingsReveal.ts`**, **`SETTINGS_TEXTS`** in **`settingsSections.ts`**): one field finds a setting in any tab, and choosing a result takes the operator to it. It is client-side and reads what the drawer already holds - the served schema, both dictionaries, the tabs and the document - so it adds no config key, no route and no Go production code. Searching and choosing a result never write the form's document: **Unsaved changes** and the highlighted Save stay as they were.
  - **Placement.** On **`min-width: 1200px`** the field (**`.settings-search-input`**) is the first child of **`.settings-nav-wrap`**, at the top of the section rail inside its 8px inset (padding **`8px 8px 0`**); the results (**`.settings-search-results`**, a **`.settings-scroll`** of its own) take the section content's place, and the rail shows no active tab while they show. On **`max-width: 1199px`** the field stands over **`.settings-tile-grid`** inside the 14px inset and the results take the tiles' place; with a tab open the field is not drawn, and the head's back arrow leads back to it, and so does **`/`**. **The `/` badge** (**`.settings-search-kbd`**) advertises the key that puts the cursor in the field: it shows inside the field while the field is empty and does not have the focus (after a jump, for one, the focus sits in the revealed setting), on every width, since a narrow desktop window has a keyboard; it is hidden while the field has the focus or holds text, and it is never drawn on a touch-only device (**`snapshotTouchOnly()`**, read through **`useSyncExternalStore`**). **The results take the content column and are not a dropdown under the field**: the documentation reader and the swarm screen use a dropdown, but this field sits in a 200px rail, and a result needs a name, where it lives and a config key. A dropdown that narrow is unreadable, and a wider one would cover the content column anyway, over a form that may hold unsaved edits. A band across the whole drawer under its head was set aside: it costs a row of height on every view. The field and the results share the documentation search's rules (the settings selectors join the selector lists of **`styles.css`** that style the docs search field and **`.docs-hit`**: rows **`.settings-hit`** with padding **`8px 10px`** and a 10px radius, hover at 6% text, **`.is-selected`** at 14% accent with a 32% border, the name at weight 500, lines 2 and 3 at 12px and 58% text, the config key in **`ui-monospace`** 11.5px with **`overflow-wrap: anywhere`**, **`mark`** as **`.docs-hit-snippet mark`**) and introduce no token or colour.
  - **The query's life.** The query lives in a store the drawer owns (**`createSettingsSearch`**: the text, whether the results show, the highlighted row and the chosen result), so **`Settings`** re-renders only when the results start or stop showing (**`useResultsShowing`**) and typing never re-renders the tab under them. **Typing** shows the results. **Choosing a result** hides them, keeps the query, remembers the chosen result and asks **`Settings`** for a **trip** to it (one state, **`{entry}`**, a new object per request, which an address that names a setting sets too): every choice is a request of its own, so choosing the same result again reveals it again. **Clicking, tapping or focusing the field**, or **`/`** outside a text field, shows the results again with the chosen result highlighted - only while they are hidden: a click or focus while they show leaves the highlighted result where it is. **Emptying the field** (the clear button of a search field, or deleted text) clears the search as Escape does: the query and the chosen result go. A rail click or a tile tap hides the results and keeps the query. **Closing the drawer** forgets the query. A query of whitespace searches nothing. The field is disabled while the first read of the schema is on its way; when that read failed it searches what is drawn without a schema; a newer copy of the config or a language switch rebuilds the index and the query and the results follow it. A rename of a row rebuilds the index too (**`settingsIndexRowsKey`**: the names and types of the rows, and nothing else).
  - **Keys.** **`/`** follows the documentation reader's and the swarm screen's: it is matched on **`e.key`** (so Shift does not matter) and does nothing with Ctrl, Meta or Alt held, on a **`defaultPrevented`** event, with the focus in a text field or a **`<select>`** (over a checkbox or a button it fires), or while a modal dialog (**`[aria-modal="true"]`**) is open. It puts the cursor in the field and shows the results; on the narrow shell with a tab open it goes back to the tiles first and focuses the field once it is drawn. No two **`/`** handlers are live together: the rail keeps one screen at a time. In the field **ArrowDown** and **ArrowUp** move the highlighted result, wrapping at both ends and scrolling it into view (**`block: "nearest"`**), and **Enter** opens it; while an input method composes, Enter and Escape belong to it and the field leaves them alone. **Escape** has three steps: with results showing it clears the query wherever the focus is (the first step of Settings' Escape chain, **Escape and the screens of the rail**; Telegram's Back dispatches an Escape at the focused element, so it takes the same step); in the field with the results hidden and a query in it, the field claims the key in its own **`onKeyDown`** and clears the query; anywhere else the drawer takes its usual step. When the results change, the highlight stays on the chosen result if it is still among them and otherwise moves to the first. The highlight follows the pointer only when the pointer moves to another spot (**`mousemove`**, not **`mouseenter`**): a pointer resting over the results does not take the highlight when new results come in under it, and the first move seen after the results change only records where the pointer is.
  - **Matching.** The index is a flat list in the drawer's order, built by walking the served schema with the drawing's own layout function. Its entries are the **tabs** (the tile blurb is their second line), the **rows** of the two list tabs (the names as the list trims them), the **blocks** (every block of fields is a result in its own right and is found by its name: a schema group such as a provider's **Advanced settings** or the groups of the **ReAct loop**, a nested object or a list, a hand-drawn block, and the groups of a provider's or a model's row form), the **fields** (every field **`SchemaField`** draws, object and list fieldsets included) and the **landmarks**. A field of **LLM providers** or **Logical models** is indexed once per (row, field), under that row's name, for the rows the address can reach only: a row without a name and rows that share a name are not indexed (a row opens by its name, and the server refuses two of one name, so they exist only as unsaved edits), and neither is a field the row does not draw (an override decides, through **`overrideTexts`**). A field inside a list of objects (a Telegram user group's name, a web search engine's fields) is indexed once and remembers its list; its box repeats once per entry and the index keeps no count. The query is split at whitespace; case does not matter and **ё** matches **е**, and nothing else is folded. Every word has to occur, as a substring, in the entry's **own names** (the names drawn in its box, the primary one first, then the second names such as **Ignore system proxy** in a proxy box or **Provider** in a model's id box; a row's name; a block's or landmark's heading), its **config key** (the dotted YAML path, which two landmarks carry too: **`skills.auto_discovery`** and **`mcp.project_trust`**), its **surrounding names** (the tab, the row and the legends of the blocks it stands in) or its **descriptions** (every (i) text drawn in its box; a block's or landmark's heading's (i)), as the page draws them in its current language. Other locales are not matched: a match has to be shown marked, and other-language text is not on screen; the config key carries the English vocabulary of **`config.yaml`**, **`coddy -t`** and the docs, and where a Russian entry is missing **`schemaFieldLabel`** falls back to the English title, which the search reads too. Results fall into three **tiers**, each in the drawer's order: every word in the own names or the config key (tabs, blocks - a form group, a nested object, a list or a map - and landmarks first, then rows and fields); some word needed the surrounding names; some word needed the descriptions. At most **`SETTINGS_SEARCH_LIMIT`** (**50**) results show, followed by a line that counts the rest. There is no debounce: the text is normalized once, when the index is built. **Never indexed**: values (the only values read are the two lists' row labels, which the list renders anyway; secrets, URLs, prompts and header names and values never reach the index, their names do), the keys the form hides (**`uiHiddenConfigKeys`**), a tab the binary cannot run (**Metrics** without the `metrics` build tag: the server leaves the section out of the schema, **`config.UISchemaJSONHiding`**, and the index is built from that schema, so the tab and its fields are neither indexed nor offered), records (MCP servers, installed skills, subagent definitions, stored sessions, a provider's listed models), fuzzy matches and synonyms, and what a surface does not draw (the **Notifications** block inside Telegram, through **`notificationsShown()`**).
  - **A result row** is a **`button`** with **`role="option"`** (**`aria-selected`**, **`tabIndex={-1}`**, **`data-settings-target`** - the entry's anchor, or **`tab:<section>`** for a tab - and, for a row, **`data-settings-row`**) of up to three lines. **Line 1** is the **primary name**; when a matched word occurs only in a second name, ` · <second name>` is appended, and the matched words are marked. **Line 2** is where the setting is - the tab, the row and the blocks joined by ` › ` - muted at 12px, and the config key follows in monospace on a line of its own, so every row has the same shape whatever the length of the path. **Line 3**, for tier 3 only, is the description, starting at most **`SNIPPET_LEAD`** (**40**) characters before the first word found there, with an ellipsis where it is cut and a two-line clamp. The option's accessible name is lines 1 and 2; line 3 is its description through **`aria-describedby`**. On a touch shell a row is at least 44px tall. An empty result says **No matching settings; try other or fewer words**, and more than 50 end with **N more settings match - add a word to narrow the list**.
  - **Every target is a real box**, because an element with **`display: contents`** has no box to ring or focus. A field is wrapped by **`SchemaField`** in **`div.settings-field`** with **`data-settings-anchor="<domain>:<path>"`** (**`fieldAnchor`**; **`display: flex`**, column, the parent's gap, **`min-inline-size: 0`**, a 10px radius, no padding and no margin so every row keeps its height, **`settingsDrawerRhythmCss.test.ts`**); a fragment override such as the proxy switch plus the URL row gets one box. A group is its fieldset (**`group:<domain>:<id>`**, **`groupAnchor`**). A row form is **`.settings-detail`** with **`row:<domain>`**, **`data-settings-row`**, **`role="group"`** and **`aria-label="<row name>"`**, because a row form draws its name nowhere as text. A landmark is its root (**`landmark:<tab>:<id>`**, **`landmarkAnchor`** from **`settingsSections.ts`**). The **entry frame** of a list of objects (the unlabelled **`.settings-array-entry`**) gets **no** box - its children are the targets - and neither does a field **`overrideTexts`** hides. A **tab** is the one entry with no box: the reveal takes the tab's scroll area, **`.settings-scroll[data-settings-tab=<id>]`**, instead. Anchors are built from schema keys and fixed ids only.
  - **The reveal** (**`settingsReveal.ts`**; **`revealWhenReady(root, entry, {touchOnly, reducedMotion})`** returns the function that cancels it, the ring included, which a new choice or a closed drawer calls). Choosing a result runs these steps in order. (1) The results hide and a new trip is set; an address that names a setting hides results that show as well, or the setting would wait under them until the reveal gave up. (2) The tab opens through **`selectSection`**, a row through the same call with the row (**`setSettingsSectionHash(section, {item: row})`**). (3) The drawer **finds the mounted box**, waiting with a **`MutationObserver`** (child changes, and the **`hidden`** attribute) until it is in the document, at most **`REVEAL_WAIT_MS`** (**1 s**) in all, and giving up quietly when it never comes. A box that is mounted but hidden inside a folded block counts as found, since a folded body stays mounted; so does a block that is itself folded. The lookup compares **`dataset`** values as strings and never builds a selector from user text; a row's box is looked up only inside the row form whose **`dataset.settingsRow`** equals the row; a repeated box resolves to the **first one in document order**, and only when the list draws no entry to the list's own box (a folded entry is not an empty list). A **tab** has no box: the reveal takes its scroll area, **`.settings-scroll[data-settings-tab=<id>]`**. (4) **A folded block unfolds before the wait for visibility**: while something above the box is **`hidden`**, or the box is itself a shut fold (a group chosen by its name, **`.settings-fieldset--collapsible:not(.is-open)`**, whose body is hidden under it), the drawer dispatches **`SETTINGS_REVEAL_EVENT`** from the box (the fold it reaches first is the box itself), and dispatches it again at every mutation - a fold that is already open ignores it, one that was not listening yet hears the next, and the **`hidden`** attribute going away is the mutation that brings the next attempt. The fold (**`CollapsibleFieldset`**) listens in a **layout effect**, because the reveal asks the moment the box is in the page, before passive effects have run; it opens with an ordinary state update. Once nothing above the box is hidden, the drawer **waits a frame** so the page has laid out what opened, looks the box up again and only then goes on, all within the same second. (5) The box scrolls into the middle of its scroll area (**`scrollIntoView({block: "center"})`**, smooth unless **`prefers-reduced-motion`**), or is aligned by its top (**`block: "start"`**) when it is taller than the scroll area, so its name and its first fields are what shows; every box keeps a 12px scroll margin (**`[data-settings-anchor] { scroll-margin-block: 12px }`**), so the ring is never cut at the edge of the scroll area; a tab's scroll area scrolls to its top. (6) The box is **ringed** for **`SEARCH_TARGET_MS`** (**1.6 s**), a tab is not: **`.is-search-target`** paints a **2px outline** of the accent mix (**`outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent)`**) standing **3px off the box** (**`outline-offset: 3px`**, following the box's radius, so the switch and the label of a field never touch it) over a **light wash** (the inset shadow **`inset 0 0 0 100vmax color-mix(in srgb, var(--accent) 10%, transparent)`**, so no ground of the box is painted over); both come in over the first 8% of the time, hold, and fade over the last 0.6 s. The ring is declared as the state itself and the fade is only the animation, so under reduced motion it holds still and the script takes it off; its rule joins the existing reduced-motion block of the settings section of **`styles.css`**, because **`SwarmView.test.tsx`** reads the stylesheet's last reduced-motion block. A box the reveal focused by script is no tab stop, and the ring is its indicator, so once the ring has faded the browser's own focus ring does not take its place (**`[data-settings-anchor]:focus-visible:not(.is-search-target)`** has no outline); a box focused from the keyboard shows no second ring. The ring is never the only signal. (7) The **focus** moves. For a field on a **fine pointer** it goes to the control whose accessible name equals the entry's **primary** name (from its **`aria-labelledby`**, its **`aria-label`** or its **`<label>`**; disabled controls and **`.field-hint`** are skipped), so **Proxy URL** focuses the URL input and not the **Ignore system proxy** switch drawn before it, and a model's id box focuses the id input and not the **Provider** combobox; with no such control, or with the URL input disabled, the box itself takes the focus. On a **touch-only device** (**`snapshotTouchOnly()`**, **`(any-hover: none) and (any-pointer: coarse)`**), and for every row, block and landmark, the **box** takes the focus (**`tabIndex={-1}`**) and no keyboard rises. A **tab** result focuses the tab's scroll area; it is a focus target and not a tab stop, so its outline shows only where the keyboard put the focus (**`.settings-scroll:focus:not(:focus-visible)`**). The focused box always has an accessible name: a fieldset is named by its legend, a row form is a **`role="group"`** named by its row, and any other box, a tab's scroll area included, gets **`role="group"`** and an **`aria-label`** with the primary name (the tab's name for a tab) for as long as it holds the focus.
  - **Link to a setting.** The Settings address names the setting the operator was taken to: **`#/settings/<tab>?id=<row>&field=<anchor>`** (**`appNavHrefSettingsSection`** takes one **`SettingsAddress`**, **`{item, field, historySidebar}`**; **`id=`** only for a row of **LLM providers** or **Logical models**; the anchor is URL-encoded, a colon as **`%3A`**; **`history=1`** stays last). It is written with **`replaceState`**, so it adds no history entry. A choice that has a box - a field, a group, a block, a row - writes it; a tab result writes none, since a tab has no box. The address **loses** the setting when the operator goes elsewhere: a rail click or a tile tap, the head's back arrow, another row, closing the drawer; a new choice replaces it. **Opening an address that names a setting** - a pasted link, a reload, a change of the hash, a relay's own Settings, the Telegram Mini App (whose launch capture keeps it) - opens the tab and the row through the route and takes the operator to the setting exactly as a choice does: the drawer finds the entry in the index by its anchor, tab and row, asking again whenever the index changes, because the first read of the schema may still be on its way, and the address read back after a choice is not a second trip. An anchor the index does not know - a link written by another version - opens the tab and the row and does nothing else. To share a setting, choose its result and copy the address. The address names the setting and never the query: a query in the address stays a non-goal.
  - **The hidden tab.** On the wide drawer the tab under the results is hidden (the **`hidden`** attribute, **`.settings-scroll[hidden] { display: none; }`**), not unmounted, so the drafts of a tab survive a query that is cleared without a choice: an MCP JSON being edited, a waiting device sign-in, an unnamed map row, the Skills install query and the fold states. **`display: none`** also hides a **`position: fixed`** element inside the tab; only portals and document- or window-level listeners escape it. The drawer sets **`PaneHidden`** (**`components/PanePortal.tsx`**, a context that defaults to **`false`**) to **`true`** around the hidden tab, and **two chokepoints** read it. **`PanePortal`** draws its children into **`<body>`** through **`createPortal`** and draws nothing while the pane is hidden; **`FieldHint`** (the tip) and **`SessionTagEditor`** draw their portals through it. **`useEscapeCloses`** does not listen while the pane is hidden. **`FieldHint`** also reads the context for its own listeners and placement: its close-on-scroll, resize and outside-pointer listeners run only while its pane is shown, so scrolling the results cannot close a pinned tip that is hidden, and the tip is placed again when the pane is shown. The rule: **while results show, the tab under them is hidden and deaf** - its portals are not drawn, its Escape layers do not listen, and its state (drafts, an open tip, an open tag editor) is kept. The layers a hidden tab can hold today: the tip of **`FieldHint`** (also pinned by a touch), the Skills install results (**`useEscapeCloses`**; the results themselves are inline and hide with the tab, the query is kept), the tag editor of the Sessions table, the confirmation dialog of the Sessions table (a modal: nothing behind its backdrop can be reached and **`/`** does nothing, so results cannot show over it) and **`Combobox`** (its list is inline, hides with the tab and folds on a press elsewhere as before). **Tested**: one behavioural test in **`Settings.test.tsx`**, parameterized over the layers (a tip pinned by a touch, an open tag editor, the Skills install results), renders each under a hidden pane and checks that it draws nothing in **`<body>`** and claims no Escape; for the pinned tip it also scrolls the results before they go and checks that the tip is still pinned and placed against its (i) afterwards; **`components/useEscapeCloses.test.tsx`** covers the two pieces (a layer in a hidden pane leaves Escape to the screen under it, and **`PanePortal`** draws into the page and nothing while its pane is hidden). **By construction**: any future portal drawn through **`PanePortal`** and any future Escape layer built on **`useEscapeCloses`**. **Not guaranteed**: a future component under Settings that calls **`createPortal`** directly or adds a **`document`** listener of its own - catching that statically would take a TypeScript parser that follows imports, re-exports and dynamic imports and counts calls per owner and event, which is more machinery than the risk warrants. Draw a portal under Settings through **`PanePortal`**, build an Escape layer on **`useEscapeCloses`**, and let review check the rest. On the narrow shell no tab is mounted under the results: they replace the tiles.
  - **The head** reads **Settings** with no back arrow while results show, even over an open row; the narrow shell's results stand on the grid view, whose head already reads **Settings**.
  - **Unsaved edits and tab-local state.** The document is read only for the row labels. A jump to another tab is a rail click: the draft lives in **`Settings`**, so it survives. A query cleared without choosing shows the tab as it was. **Kept as it is today**: a result in another tab unmounts the open tab the way a rail click does, and the state a tab keeps outside the document (an MCP editor's JSON, a waiting sign-in, the Skills install query) goes with it. Guarding those drafts on a tab switch is a separate change.
  - **Accessibility.** The field follows the documentation search (**`docs/DocsView.tsx`**): **`type="search"`**, **`role="combobox"`**, **`aria-autocomplete="list"`**, **`aria-expanded`**, **`aria-controls`**, **`aria-activedescendant`** (naming an option only while that option exists), the placeholder as its name, and a visible focus ring. The results are a named **`role="listbox"`**. The number found (the shown results plus the counted rest) is announced through a polite **`role="status"`**, debounced by **`STATUS_DEBOUNCE_MS`** (**500 ms**). After a jump the focus is on a named control or a named box. The strings (**`settings.search.*`**: placeholder, listbox name, empty state, and the found and "more" counts through **`translatePlural`**) are in every registered dictionary.
  - **One source of truth.** The drawing and the index read the same declarations, so a setting cannot be drawn without being found. **`layoutSchemaForm(schema, groups)`** is extracted from **`SchemaForm.tsx`**, in the same file: **`SchemaForm`** renders from it and the index walks it (**`entriesInSchemaOrder`** and **`isScalarItem`** are exported; what nothing outside its file reads stays private). The schema's texts come from **`schemaFieldLabel`** and **`schemaFieldDesc`**. The per-tab declarations are exported where they live and nothing moves: **`objectSectionGroups`**, **`providerFormGroups()`** and **`modelFormGroups()`**, **`sessionsFormGroups()`**, **`systemPartGroups()`**, **`skillsFormGroups()`**, **`subagentsFormGroups()`**. A field override declares its texts in **`overrideTexts(domain, path, parent, schema)`** (beside the overrides in **`SettingsSection.tsx`**), which returns **`false`** (the field is not drawn), **`null`** (drawn as the schema says) or the box's texts **`{primary, primaryDesc?, second?, secondDesc?}`** as strings of the page's language (the schema's own through **`schemaFieldLabel`** / **`schemaFieldDesc`**, a drawn one through a constant of **`SETTINGS_TEXTS`**). **The drawing consumes that same value**, not a mirror of it: the override functions return **`false`** for a hidden field and otherwise pass the strings to the component as one **`texts`** prop - **`ProxySettingField`** draws the URL row from **`primary`** and the switch from **`second`**, **`ModelField`** the id from **`primary`** and **Provider** from **`second`** (no (i)), **`CodexAuthField`** and **`NeuralDeepAuthField`** theirs - and the index flattens the same descriptor. **`SETTINGS_TEXTS`** (**`settings/settingsSections.ts`**, beside the other registries of the tabs) holds the dictionary keys of every name and description drawn outside the schema, and the landmark blocks render their heading and its (i) from them; a test checks every one is a key of the default dictionary. A name drawn outside the schema is added to **`SETTINGS_TEXTS`**, never written inline in a component.
  - **Landmarks.** A hand-written, API-driven block is found by its **heading only**: its **`legend`** (an Appearance block's **`.appearance-section-label`**) and the heading's (i). What the block holds below the heading - the session table's Archive and Source filters, the MCP toolbar and editor cards, the Skills install box - is operations, not settings, and is not indexed. Each landmark is declared in **`SETTINGS_LANDMARKS`** as **`{section, id, heading, keyPath?, place, shown?, row?}`**: **Theme**, **Language** and **Notifications** (Appearance), **Session management** (Sessions), **MCP discovery** and **MCP servers**, **Skill auto-discovery**, **Marketplaces** and **Installed skills**, **Definitions** (Subagents) and **Models** (inside each LLM providers row). **`shown`** is the component's own predicate (**`notificationsShown()`** is **`!telegramLaunch()`**, and **`NotificationsSetting`** returns **`null`** on it), **`row`** says whether a list row draws the block. A new hand-drawn block under a Settings tab declares a landmark and draws its root with **`landmarkAnchor`**, a plain string builder in **`settings/settingsSections.ts`** beside **`SETTINGS_TEXTS`**: the components that draw a landmark never import the index, and an anchor drawn that the index lacks fails the comparison.
  - **The exact comparison, on the served schema.** Checked copies of the served form schemas live at **`internal/config/testdata/ui_schema.json`** and **`ui_schema_relay.json`**; **`TestUISchemaTestdataIsCurrent`** (**`internal/config/jsondto_schema_test.go`**) fails when a copy is stale, and after editing **`internal/config/ui_schema.go`** they are regenerated with **`go test ./internal/config -run '^TestUISchemaTestdataIsCurrent$' -update`**. The comparison tests (**`SettingsSection.test.tsx`**, **`describe("settings search index matches the drawing")`**, its helpers **`drawnBoxes`** and **`drawingDifferences`** in **`settings/settingsDrawing.fakes.ts`**; the stub server, the schema copies, **`configFromSchema`** and the mount helpers in **`settings/settingsServer.fakes.ts`**, whose stub fails a test that requests a route it does not serve) render, for the agent's and the relay's form, every tab and every row form from a document built out of the schema copy (the defaults, one entry in every list of objects, a provider row of every type, two model rows), against a stubbed server, and check three things **exactly and in both directions**: (1) **boxes** - every drawn anchor has an entry and every entry's anchor is drawn; an anchor outside a list of objects is drawn exactly once, and inside one the count is **read from the drawing** (each child anchor appears as many times as that instance of the list's box draws entry frames of its own, so nested lists are counted per actual parent entry and the index carries no count); a duplicate anchor fails, which is how an entry frame that took a box would show; (2) **names per box** - the names drawn directly in the box (**`legend`**, **`label`**, **`h1`**-**`h6`**, **`.settings-label`**, a switch's own **`aria-label`**), the **`aria-label`** of a row box, the heading of a block or landmark, equal the entry's names **compared as multisets**; (3) **descriptions per box** - each (i) drawn directly in the box is opened and its tip read, a block's or landmark's heading's (i) only, compared as multisets. Extra cases: a list drawn empty and once with two entries, an entry added to **`logger.levels`** after the index was built and removed again, the whole comparison in Russian and inside a faked Telegram launch (no Notifications block, no Notifications entry). It also checks that **every legend drawn in a tab, and every Appearance label (`.appearance-section-label`), is the heading of an indexed box**, so a block drawn with no entry of its own fails it. A **mutation trio** proves the comparison works - a leaf entry deleted from the index, a stale entry whose anchor is not drawn, an entry's primary name changed - and each must make it fail. What stays a mirror is the branching by tab kind in the index and the landmark list; the comparison holds those in step.
  - **The files and the tests.** The pure core is **`settings/settingsSearch.ts`** (words, tiers, limit, marks, description line, the index, **`SETTINGS_LANDMARKS`**), tested by **`settingsSearch.test.ts`**; the reveal is **`settings/settingsReveal.ts`** (**`findSettingsBox`**, **`controlNamed`**, **`revealWhenReady`**, which returns its own cancel); the store, the field and the results are **`settings/SettingsSearchView.tsx`**; **`Settings.tsx`** places them and **`SettingsNav.tsx`** has a slot for the field. The drawer is pinned by **`Settings.test.tsx`** (**`describe("settings search")`**: **`/`** on a wide and a narrow screen, typing and choosing, the same choice twice, the provider fold and another row, duplicates, the kept query and the kept highlight, an emptied field, composition leaving the field alone, the focus rules, unsaved edits, Escape's steps, Telegram's Back, each hidden layer, a list edited after the index was built, a reveal giving up within its budget, a new choice or a closed drawer cancelling the ring, typing not re-rendering the tab, the head, no match, the active option, Russian and a relay, the Metrics tab with a binary that does not have it, the status debounce; the layers and the Russian and relay cases are tables), the address by **`scheduler/hashRoute.test.ts`** (written and read back, with a row and without, the encoding, the history sidebar kept) and **`App.settingsLink.test.tsx`** (a link opening its setting through the app), the boxes and the fold by **`SchemaForm.groups.test.tsx`**, the hidden pane by **`components/useEscapeCloses.test.tsx`**, the CSS by **`settingsFieldsetCss.test.ts`**, the schema copies by **`TestUISchemaTestdataIsCurrent`**, and the happy path by **`features/web_ui_settings_search.feature`** through **`external/ui/bdd_settings_search_test.go`**. The browser checks are the only ones that see real boxes, paint, bounds and focus. **`npm run check:settings-search`** (the Settings script's **`search`** scenario against an agent and a relay, at 320 to 1280px, in English and Russian) runs in CI, in Chromium, as a step of the Tests on PR job for **`http,scheduler,ui,memory,cli,swarm`**: with **`CODDY_BIN`** set and **`CODDY_SERVE_URL`** not, it starts both stands from that binary itself (**`scripts/settings-search-stands.mjs`**: temporary homes, free ports, the relay's client token through the environment, both stopped at exit; the agent has one provider **`demo`** and three logical models, **`demo/qwen3-27b`**, **`demo/qwen3-8b`** and **`demo/llama-3`**), and with **`CODDY_SERVE_URL`**, **`CODDY_RELAY_URL`** and **`CODDY_RELAY_TOKEN`** (a relay asks for its client token even on loopback) it uses stands you started. Besides the reveals it holds the life of the query to account: kept after a choice and brought back with the chosen option selected, the tiers, a click keeping the highlight, an emptied field, a hidden tab keeping its scroll position, Escape's order, the address with **`field=`**, opening a link, a search for a group and the **`/`** badge. WebKit (**`CODDY_ENGINE=webkit`**) and the **`settings`** scenario of **`npm run check:telegram`** (a touch context) are run by hand and reported in the pull request. Both checks go through **`scripts/settings-search-assert.mjs`**, which polls until the ring, the bounds and the focus hold at once, because a smooth scroll may still be running when the ring appears. Design record: **`docs/plans/settings-search.md`**; guide: **`docs/surfaces/web-ui.md`** (**Settings: search**).
- **Secrets the server never sends back** (**`writeOnly`** in the schema, **`x-coddy-configured`** on the document): **`httpserver.auth_token`**, the sign-in password hash and the swarm tokens (**`swarm.auth_token`**, **`swarm.pairing_tokens`**, a join entry's **`pairing_token`** and **`token`**) come back from **`GET /coddy/config`** as a **`*_configured`** flag, never as a value. Such a field is a password input that is **empty** while a value is set and says so in its placeholder (**Set. Leave empty to keep it**, else **Not set**); leaving it empty keeps the stored value, typing replaces it. A list of secrets says how many are set and hidden in a state line under its legend (**`settings.secret.listSet`**, counted per locale), and the values entered in it replace them all. The two-way credentials - a provider's **`api_key`**, a remote's **`token`** - stay ordinary text fields, because the page needs their value.
- **The relay's own Settings.** On a relay's home (the app on a relay, not on a node behind it) the drawer edits the relay's deployment: **`GET /coddy/config/schema`** answers with the relay's form (**`config.RelayUISchemaJSON`**: **`swarm`** and **`logger`** only, **`x-coddy-relay: true`** at the root), and **`deriveSettingsSections(schema, {relay: true})`** draws **Appearance** and those two tabs; the **Sessions** tab, which manages the stored sessions of an agent, is left out, even before the schema has arrived. The document is scoped the same way (**`configapi.Backend.Sections`**): **`GET /coddy/config`** serves only **`swarm`**, **`logger`** and the revision, and a save takes only those and refuses any other section, so a relay client never reads the host's provider keys or writes its other settings. **Save all** PUTs that document, and the relay applies the **`swarm`** section at once (a listen address that moved takes a restart); the **`logger`** section is read when the process starts next, as an agent's is. A node reached through a relay shows that node's own Settings; a node that is itself a relay refuses a write sent through the mount (**403**), so a relay is edited only on its own page.
- **Section form layout** (**`SchemaForm`** **`groups`**, **`SchemaFormGroup`** **`{id, legend, description?, paths?, collapsible?}`**): a settings form is fieldsets by meaning, not a column of loose fields. A section's own **`enable`** switch opens the form **outside every fieldset**, above everything, wherever the schema puts it (Subagents, Hooks, Memory, Command safety, Context compaction, Scheduler). A group with **`paths`** takes exactly those keys; the **first** group without **`paths`** is the catch-all and takes every key no group names, except a list or a nested object, which stands as a **block of its own** beside it rather than inside it. Every group stands **where its first field stands** in the schema order, blocks stand in place, and a key no group takes (one added to the schema later) still renders in place, so nothing silently leaves the form. A group's **`description`** is the (i) of its legend. The groups of each tab live in **`objectSectionGroups`** (**`SettingsSection.tsx`**): **ReAct loop** (Model and turns, Retries, Stream timeouts, Loop guard, Usage limits), **Context compaction** (Automatic compaction: its switch and the threshold it fires at, inside the section's own switch rather than beside it; Summarization), **Memory** (Model and storage, then the fallback models as a block of their own, Instructions, Runs, Limits), **Command safety (decisions)** (Model and threshold), **Hooks** (Hook settings), **Scheduler** (Jobs), **Logger** (Logger settings); the **Logical models** row form (**Model**: provider, id, context window, multimodal; **Generation**: max tokens, temperature, streaming; **Reasoning**: the levels and the default; **Tools**: the toolset the model names, passed through **`SettingsArraySection`** like the provider form's blocks), **Subagents** (Subagent settings), the **Sessions** tab (Storage) and each part of the **Prompts** tab (**Prompt templates**, and **Instruction files** as a block of its own) pass their own. Inside a nested object a list keeps its frame - the Telegram admins, user groups and per-chat overrides, the web search engines: there it belongs to the block around it. **Lists**: an entry of a list of plain values is a **bare input** beside its trash (**`ArrayItemControl`**, named **&lt;list&gt; N** for assistive technology), with no per-row label such as **`dirs[0]`**, no rule under the row, and **Add** under the rows; an entry of a list of objects is a frame of its own **without a legend** (**`.settings-array-entry`**, named the same way). The trash carries no margin, so a row is exactly as tall as its input (**`settingsDrawerRhythmCss.test.ts`**). **Maps**: a map of plain values - an object with no properties of its own whose every value is a string (**`isStringMapSchema`**), such as **`tools.http_request.default_headers`** - is a block like a list, and an entry is a row of **two bare inputs**, the name and then the value (**`.settings-map-entry`**, the value wider), beside its trash, named **&lt;map&gt; N: name** and **&lt;map&gt; N: value**, with **Add** under the rows. The rows are the form's own (**`StringMapField`**): a row whose name is still empty stays on screen and out of the document, of two rows with one name the later one wins, and a document that stops matching the rows (a reload, a save from elsewhere) replaces them while rendering. An empty value is kept, since for the headers it means "leave this one out". Tests: **`SchemaForm.groups.test.tsx`**, **`SchemaForm.map.test.tsx`**.
- **List sections** (**LLM Providers**, **Logical Models**) are **master–detail** (**`SettingsArraySection`**): a list of named buttons (labelled by **`name`** / **`model`**) with **Add**/**Remove**; **Add** or selecting a row hides the list and shows the item form. **The way back is the drawer's head, on every width**: while a row form is open the head reads what the form is (**`settings.item.<section>`**: **Provider settings**, **Model settings**; the section's name for a list without one) behind the same round back arrow the narrow shell's section view uses (**`.settings-head-back`**, **`data-testid="settings-head-back"`**, named **Back to &lt;list&gt;**), and the arrow closes the form onto the list (**`closeSignal`**; the list reports **`onEditingChange`**, so a row with no name yet is covered too). With no row open the desktop head reads **Settings** and the narrow shell's head names the section with its arrow back to the tiles. **Escape** takes the step the arrow takes while the head shows one, and closes the drawer when it shows none (**Escape and the screens of the rail**). The form carries no back control of its own inside the drawer (**`hideBackLink`**); outside it, **`SettingsArraySection`** keeps a quiet link, **`.settings-back-link`**, the app's chevron pointing left and the list's name. The open row is in the address, **`#/settings/<section>?id=<name>`** (**`appNavHrefSettingsSection`**, the name URL-encoded, so **`demo/qwen3.8-27b`** travels as **`demo%2Fqwen3.8-27b`**): opening a row writes it, going back takes it out, renaming the open row rewrites it (all with **`replaceState`**, no history entries), the search adds **`&field=<anchor>`** for the setting it took the operator to (**Settings drawer**, **Search**, *Link to a setting*), and an address naming a row opens it - one that names no row (a typo, a row deleted since) shows the list and drops the dead name from the address. The row form is **keyed by the row**, so switching rows through the address (edit to edit, never through the list) mounts a fresh form and nothing a field remembers - the proxy URL, a filter, a fold - crosses rows; an open form whose index is gone from the document falls back to the list. A row with no name yet keeps the address on the list while its form stays open; the row already open under a name stays open when a rename makes two rows share it. **`SettingsArraySection.test.tsx`** pins both directions; the drawer hands the address row only to the section the address names (**`appearanceSettingsLayout.test.tsx`**), because the tab catches up one render late and another list used to take the name for one of its own, find none and rewrite the address. The component is **keyed by the section** in **`SettingsSection`**: both tabs render the same component at the same place in the tree, and without the key a row form opened under LLM Providers stayed on screen when the operator switched to Logical Models, showing the first model's form instead of the list.
- **Codex provider authentication**: when a provider row has **`type: codex`**, **`api_base`**, **`api_key`**, and **`api_key_command`** controls are replaced by **Sign In with ChatGPT**. The device-flow card shows the one-time code, a link to the official verification page, waiting/completed/error state, and **Sign Out** for Coddy-managed credentials. The token is server-side only.
- **Proxy settings** (**`ProxySettingField`**, rendered through **`SchemaForm`**’s **`fieldOverride`** hook on path **`proxy`** for every provider type, codex included, and on **`telegram.proxy`** in the Gateways tab's Telegram block): an **Ignore system proxy** **`SwitchField`** (**`data-testid="proxy-setting-direct"`**) above the **Proxy URL** input (**`data-testid="proxy-setting-url"`**), both direct children of the form so they keep its spacing. The switch description names whose requests go direct (the provider's, or the bot's). The switch owns the **`none`** value; with it on the input is disabled (the muted look **`.settings-input:disabled`** shares with read-only inputs), empty, and its placeholder reads **Direct connection**. With it off the input holds the proxy URL, and an empty one (no value, or a stored **`inherit`**, any case) reads **Follows the system proxy**. Turning the switch off restores the last value the row held that was not **`none`** (typed, pasted or reloaded); that memory lives in the field for as long as the item form is open - the form mounts afresh for every row - because the document keeps one value, so a saved **`none`** no longer knows the URL it replaced.
- **Fieldsets shrink to their column**: every **`.settings-fieldset`** sets **`min-inline-size: 0`**. A **`<fieldset>`** defaults to **`min-content`**, so nested frames (a per-chat override inside the Telegram or Pachca block of the Gateways tab) added their padding up past a 390px panel and scrolled it sideways; the rule is pinned by **`settingsFieldsetCss.test.ts`**.
- **Fieldset legends** start on the line their fields' labels start on: the legend's **6px** of padding, which opens the gap in the frame, is pulled back by **`margin-left: -6px`** (**`.settings-fieldset > legend`**, every settings fieldset); the provider **Models** rows hang their own 6px of padding the same way, so the ids share that line too.
- **Provider form blocks**: a provider's form is three fieldsets (**`SchemaForm`** **`groups`**, passed through **`SettingsArraySection`**): **Provider settings** (id, type, endpoint, key, the sign-in blocks, the usage switch - every key no other group names), **Advanced settings** (**`api_key_command`**, **`proxy`**, **`timeout_ms`**), folded until opened, and the **Models** list last. A folding fieldset (**`CollapsibleFieldset`**) has the app's chevron **hanging in front of its name** in the legend (**`.settings-fieldset-toggle`**, **`aria-expanded`**), the way a transcript row's hangs in front of its label: the legend reaches **18px** further left than a plain one (**`margin-left: -24px`**: the plain -6px, then the 14px chevron and its 4px gap), so the name starts on the same line as every other legend's, and opened, the frame's gap takes the chevron in too. **Folded, there is no frame at all** - no border, no background, only the chevron and the name (**`.settings-fieldset--collapsible:not(.is-open)`**: the border turns transparent rather than going away, so the name stays where the framed box puts it); opening it shows an ordinary fieldset. The body stays mounted, **`hidden`**, so the proxy switch keeps the URL it remembers across a fold.
- **Provider models list** (**`ProviderModelList`**, the **`itemFooter`** of the providers **`SettingsArraySection`**): the last block of a provider form lists what the provider advertises, fetched through **`POST /coddy/providers/models`** with the row **as the form holds it** (**`providerModelsRequest`**: name, type, api_base, api_key, api_key_command, proxy), so an unsaved row, a sign-in that exists only on disk and a provider the document does not have yet list their models like a saved one (issue #335). **`useAutoProviderModels`** fetches when the form opens and again when the row becomes **another provider** - its id or its type changed and stayed still for **`AUTO_FETCH_DEBOUNCE_MS`** (600 ms); an edit to the endpoint, the key or the proxy fetches nothing by itself and waits for the refresh icon, so a key typed next to a half-typed endpoint never travels to it. An automatic fetch **never carries `api_key_command`** (the server runs a posted command, and a half-typed one must not run); the refresh icon beside the legend name (**`.provider-models-refresh`**, 20px, the (i)'s metrics) fetches at once and is the only fetch that sends the command. There is **no count line**: a line under the legend (**`.provider-models-status`**) speaks only when there is nothing to list - fetching, the provider's error in **`.settings-error`** colour, an empty answer, a row without a name or a type. Rows are **compact and unframed**, no separators (**`.provider-models-item`**, 3px padding, 1px apart): the **id** in monospace (**`.provider-models-item-id`**, ellipsized), the **context window** the listing reports right of it (**`.provider-models-item-ctx`**, **`131k`**, the exact count, localized and pluralized, in its tooltip), then the **toggle** (**`.provider-models-toggle`**, 24px, **`aria-pressed`**): a plus adds **`provider/id`** to Logical Models, seeded through the same **`seedLogicalModel`** the Add button uses with the reported window written into **`max_context_tokens`**; an accent check says it is there and **takes it out again**. A row is only as wide as its content (**`align-self: flex-start`**, **`max-width: 100%`**), so the hover lights the id and its toggle, not the width of the box. An advertised display name that says more than the id is the row's tooltip (**`modelDisplayName`** drops one that only restyles the id). An id the document files under this provider that the provider no longer advertises stays in the list **amber**, checked, the reason in the row's tooltip and in the toggle's accessible name. Past **`PROVIDER_MODELS_FILTER_THRESHOLD`** (8) rows a filter field narrows the list, and the list scrolls inside the form past about twelve rows. Containment is pinned in **`ProviderModelList.test.tsx`**: the fieldset drops the **`min-content`** inline size every **`<fieldset>`** defaults to (**`min-inline-size: 0`**) and the id may shrink, because the first version of this box widened the form past a phone's panel.
- **Logical Models** model field (**`ModelField`**) is **Provider**, a combobox over the document's **`providers[].name`** rows, unsaved edits included (a name the document does not list yet can still be typed), and **Model id**, a **plain text input** with no description, list or fetch control of its own: choosing among the ids a provider advertises is the provider form's job (**Provider models list** above), and a second picker here only repeated it. The value composes as **`provider/id`** on the **first** slash, so an id that itself contains slashes survives. **ReAct loop** and **Memory** default-model fields and the **Context compaction** summarizer (**`compaction.model`**) use **`ModelPicker`**: they pick from the logical models of the settings document, unsaved edits included, or accept manual entry, and an empty field keeps the session model.
- **Logical Models** context window (**`ContextWindowField`**, the **`fieldOverride`** on **`max_context_tokens`**): **0** means "the window the provider's listing reports, else **`DEFAULT_CONTEXT_WINDOW`** (128000)" at run time, so the field asks the provider (**`POST /coddy/providers/models`** with the row the model id points at, as the document holds it) and shows the answer where a bare 0 used to stand: an unset value renders empty with the placeholder **`131072, reported by demo`** (or **`128000, the default`** when the listing names no window), which writes nothing. A 40px refresh icon beside the input (**`.context-window-controls`**, input at **40px** height) asks again and **writes** the reported number, for an operator who wants it pinned; a note under the field (**`context-window-note`**) appears only after that explicit ask, when the provider does not report a window for the id or the request failed. A model added from the provider form's list arrives with **`max_context_tokens`** already set to the reported window (**`seedLogicalModel`**).
- **Logical Models** toolset (**`models[].toolset`**, the **Tools** block of the row form, last, under **Reasoning**): a plain **`select`** (**`data-testid="model-toolset"`**) over the presets of the **Toolsets** tab, **None: every tool** first; a name the presets no longer have stays on offer, so the form never rewrites it. **Toolsets** is a list tab of its own right after **Logical models** (rows labelled by **`name`**): a row is **Name**, then **Allowed tools** and **Disallowed tools**, two lists of plain values drawn by the generic list editor - an entry is a **bare input** beside its trash with **Add** under the rows, named **Allowed tools N** and **Disallowed tools N**. Entries are free text: no endpoint lists the tools of a build, so the form offers no suggestions, and a name that matches no tool is saved like any other (**`coddy -t`** warns about it). A preset the form adds starts with both lists empty, which means no restriction, and an empty list is never written to **`config.yaml`**. Renaming a preset renames it on the models that name it (**`applyToolsetsChange`**). Tests: **`SettingsSection.test.tsx`** (the model form's blocks, the toolset select, the Toolsets tab, in English and in Russian), **`applyModelsChange.test.ts`**, **`features/toolsets_web_ui.feature`**.
- **Usage limits panel** (**`providers[].usage_limits_panel`**) is an ordinary **`SwitchField`** row at its schema position, rendered only for the provider types that have a usage source (**`neuraldeep`**, **`codex`**, **`devin`**, **`USAGE_PANEL_PROVIDER_TYPES`** in **`SettingsSection.tsx`**); for the other types the switch would change nothing, so it is not shown.
- **Logical Models** reasoning-levels field (**`ReasoningLevelsField`**, rendered through **`SchemaForm`**’s **`fieldOverride`** hook on path **`reasoning_levels`**) owns the three states that key can be in and names the current one in a status line (**`data-testid="reasoning-levels-status"`**): **key absent** = levels auto-detected from the model id, **`[]`** = the composer reasoning selector is hidden for this model, **non-empty** = exactly these levels. The **Add** under the list keeps its own width (**`.settings-row-action`**, **`align-self: flex-start`**) rather than stretching across the field. An action row above the list carries **Fetch reasoning levels** (**`data-testid="reasoning-levels-fetch"`**, **`GET /coddy/config/reasoning-levels?model=&provider_type=`** for the id currently in the form and the type of the provider row it points at in the unsaved document, so it works before either entry is saved) and, whenever the key is present, **Use auto-detected** (**`data-testid="reasoning-levels-auto"`**) which drops the key again. A fetch that detects nothing leaves the field untouched rather than writing **`[]`**, because that would hide the selector instead of restoring detection. The status line always describes the list on screen first (override / hidden) and only falls back to fetch feedback (nothing detected / error) while the key is absent; retyping the model id or editing the list by hand clears that feedback and abandons the request in flight, so an answer that lands after the id changed, after a manual edit, or after the row was removed is dropped; an answer that does land goes through the newest **`onChange`** the form handed the field, never the one captured when the request started, so sibling fields edited meanwhile keep their values.
- **Skills** tab (**`SkillsSection`**) combines the schema-driven form (**`auto_discovery`** in its own fieldset, the **`skills.dirs`** block, and **`project_trust`** in the **Marketplace discovery** fieldset, a **`SchemaForm`** group like the MCP tab's **MCP discovery**, saved with **Save all**), an **API-driven marketplaces list**, and the installed-skills list. The marketplaces list (**`MarketplacesEditor`**, a **`.settings-fieldset`** with the legend **Marketplaces**, **`data-testid="skills-marketplaces"`**, helpers in **`settings/marketplaces.ts`**) works like the MCP tab: it reads **`GET /coddy/skills/sources`** for the session on screen (**`X-Coddy-Session-ID`**), never edits the settings document (**Save all** does not touch it), and every control applies at once. A row (**`.skills-marketplace`**, laid out like an MCP row: **`.skills-marketplace-head`** with the text growing and the **40px** icon controls **`flex: 0 0 auto`**) shows the key in monospace (**`.skills-marketplace-key`**: a marketplace's name, a source's address), a kind badge (**all plugins** / **catalog**) and an origin badge (**built in** / **yours** / **from the project**, tooltip the declaring file), and under a catalogue's name its address (**`.skills-marketplace-source`**). Trailing controls: the **shield** (**`IconShield`**, the MCP tab's; amber **`.settings-btn-approve`** while unapproved, plain once approved) on a project row under **`ask`** only (**`showsEntryTrustControl`**), posting **`POST /coddy/skills/sources/trust`** with the row's **`fingerprint`** or **`untrust`**; on the built-in row a shield that is **disabled** (always trusted); **Sync** (**`POST /coddy/skills/sync?source=`**, disabled while held, a check mark after success); trash **Remove** (**`DELETE /coddy/skills/sources?source=`**, disabled on the built-in row). A held row is dimmed (**`.is-held`**, the text at **0.65** opacity) and carries a note (**`.skills-marketplace-note`**) naming its file, or under **`deny`** the policy. The footer (**`.skills-sources-footer`**, wrapping) holds the add form on the left (**`.skills-marketplace-add`**: the address grows, a **Yours / This project** scope picker and **Add** keep their size; the picker is disabled on **Yours** until the chat has a session, with a title saying why) and **Sync all** on the right (**`POST /coddy/skills/sync`**). Remove sends the row's **`origin`**, so it edits that file only; a draft chat's id (**`draft_…`**) is never sent as **`X-Coddy-Session-ID`** (**`liveSessionId`**), here and in the MCP tab; a save of the settings (**`settingsConfigStore`**) reads the list and the Subagents catalog again. On a phone the row's controls wrap to its end under the key and the add field takes a line of its own. Each installed-skill row **leads with its iOS-style enable switch** (**`.skill-switch`**, theme-tinted via **`var(--accent)`** when on, toggling via **`/coddy/skills/{name}/enable|disable`**), where a glyph that said nothing used to stand, then the name with its **version** (**`.skills-list-item-version`**, the skill's **`metadata.version`**, else a top-level **`version`**) and a **`remote`** badge when synced; rows carry no rule between them (**`.skills-list-item`**, 6px padding); a **Delete** icon (**`IconTrash`**) calls **`DELETE /coddy/skills/{name}`** and is **disabled for bundled read-only skills** (row **`readonly`** flag). When a skill is behind, a **Download-update** icon (**`IconDownload`**, tooltip naming the target version) appears and calls **`POST /coddy/skills/{name}/update`**.
  - **Sizing / alignment**: inputs inside an array row (**`skills.dirs`** and the sources editor) use **`box-sizing: border-box; min-height: 40px`** (**`.settings-array-row .settings-input`**) so they match the **40px** **`.settings-btn-icon`** buttons beside them and their bottoms line up; the source input fills its field (**`.settings-array-row-field > .settings-input { width: 100% }`**). Controls in a skill row never shrink (**`.skills-list-item > button { flex: 0 0 auto }`**), so the leading switch and the trailing **Update / Delete** keep their size regardless of description length. The enable switch is **38×22** with a 16px thumb (**`.skill-switch`**, the same control **`SwitchField`** wraps below).
  - **Sync feedback**: a successful sync flashes a checkmark on the button itself for **~1.6s** (**`is-synced`** class, tinted **`var(--accent)`**) — **Sync all** shows **Completed**, a per-marketplace **Sync** swaps its glyph to **`IconCheck`** — instead of a separate status line.
  - **Scroll stability**: enable/disable and sync refresh the installed list **without unmounting it**. The **Loading…** placeholder renders **only** on the initial empty load (**`loadInstalled(firstLoad)`** gates the **`loading`** flag), so a refresh never collapses list height and the Settings scroll position is preserved across a toggle.
  - **Installed skills box**: the installed list sits inside a **`.settings-fieldset`** (legend **Installed skills**, whose (i) says skills installed with **`npx skills`** / **`npx skillsbd`** land in **`~/.agents/skills`** and are picked up) so it matches the framed sections above; it also sets **`min-inline-size: 0`** (**`.skills-installed-box`**) because a **`<fieldset>`** defaults to **`min-content`** inline size and the **`white-space: nowrap`** skill descriptions would otherwise widen the box past the panel and add a horizontal scrollbar. At its top, an **install control** (**`.skills-install`**, **`position: relative`** anchor) is a filter input (**`.skills-install-input`**, 40px, full width) that lazily loads **`GET /coddy/skills/available`** on focus and, while a query is typed, shows a **floating** dropdown (Escape clears the query and takes the dropdown away before the drawer hears the key) (**`.skills-install-results`**, **`position: absolute`** with the shared **`--coddy-glass-panel-*`** tokens) of matching **not-installed** marketplace plugins. The dropdown **floats over** the installed list — it never reflows the rows beneath it — and is **capped at 10 matches** (**`INSTALL_MENU_LIMIT`**, pure filter in **`installableMatches.ts`**); a broader query appends a muted **`+N more — refine your search`** hint (**`data-testid="skills-install-more"`**) rather than silently truncating. Each result carries a **Download/install** icon (**`IconDownload`**, tooltip **Install &lt;name&gt;**) that calls **`POST /coddy/skills/install`** **`{source,plugin}`** and then refreshes the list. The text field is not cleared after an install.
  - **After install (no scroll)**: the list is **not scrolled** to the newly installed skill — the floating menu leaves the scroll position untouched. The new row briefly **flashes** (**`.skills-list-item.is-just-installed`**, **`justInstalled`** for ~2.4s), the **`Installed &lt;name&gt;.`** status line confirms, and the skill is usable from the composer **`/`** menu immediately because the server drops its slash cache on install (**`invalidateSlashCache`**). A just-installed plugin is dropped from the available dropdown optimistically.
- **MCP servers** tab (**`MCPSection`**, section kind **`mcp`** in **`settingsSections.ts`**) is **API-driven** (**`/coddy/mcp*`**) and uses the shared settings visual language; it never edits the settings document (**Save all** does not touch it). The list (**`.mcp-list`**) shows every merged server - the global **`~/.coddy/mcp.json`** (scope **`global`**), overlaid with the project **`./.coddy/mcp.json`** (scope **`local`**) - one row per server (**`.mcp-list-item`**): a **chevron** expander (**`.mcp-expand-btn`**, rotates 90° when open), a **status dot** (**`.mcp-status-dot`**: green **`is-connected`**, red **`is-error`**, gray **`is-disabled`**, amber **`is-unsupported`**, amber **`is-needs_approval`**, red **`is-denied`**; tooltip carries the probe error or the trust verdict), the name with a **scope badge** (**`global`** / **`local`**, reusing **`.skills-list-item-badge`**; the tooltip names the owning file via **`originLabel(row.origin, row.source_path)`**, which prints the **real path the API reported** (`source_path`) and falls back to the generic label - `~/.coddy/mcp.json` or `./.coddy/mcp.json` - only when a row carries none) plus an uppercase transport badge for non-stdio entries, and a dimmed **monospace command line** (**`.mcp-command`**; shows the probe error text for error/unsupported rows). Trailing controls: a **shield trust button** for project-local rows only (**`gated: true`**, **`data-testid="mcp-trust-{name}"`**, **`.settings-btn-approve`** amber while unapproved) posting **`POST /coddy/mcp/{name}/trust|untrust`**, the shared **`Switch`** (server-level enable via **`POST /coddy/mcp/{name}/enable|disable`**), a **pencil Edit** and **trash Delete**, available on every row, since every server lives in an mcp.json file the tab writes. All trailing controls are **`flex: 0 0 auto`** (**`.mcp-list-item-head > button`**) so long command/error text cannot squeeze the 40px icon buttons. The row carries no glyph of its own: the chevron and the status dot lead it and the name follows them, and everything under the row starts where the name does - the expanded tools (**`.mcp-tools`**), the empty-tools line and the trust note (**`.mcp-trust-note`**, which wraps the long path of its file anywhere rather than run past the row) - through one inset on the list (**`--mcp-row-inset`** on **`.mcp-list`**: the 22px chevron, the 9px dot and their two 8px gaps). On a phone the controls wrap under the name (**Phone layout**): four 40px controls beside it left the name 35px at 390px and broke **project-search** in two. A listing the server cannot build is said above the list (**`.settings-error`**, **`data-testid="mcp-load-error"`**) with the server's own message - a broken **`mcp-overrides.json`** names its path - and never shown as the empty state; a refresh that fails keeps the rows already on screen.
  - **Workspace trust**: a project-local declaration lives in the checkout, so a row with **`status: needs_approval`** is **reported, never probed** — it lists no tools, keeps its monospace command line visible (that is what gets approved), and renders a note (**`.mcp-trust-note`**, **`data-testid="mcp-trust-note-{name}"`**) naming the **`source_path`** it came from plus a **`<dl>`** of the declaration the approval covers (**`.mcp-trust-facts`**, built by **`declarationFacts`** in **`mcpServerJson.ts`**): **transport**, **`runs`** (command + args) or **`contacts`** (url), the **names** of env vars and headers, **`reads`** (the variables of the Coddy process its values name as **`${NAME}`**, from the row's **`reads`**: a header value is never shown, so the secret it would send is named here), and the **workspace**. Values are never rendered - the decision is about which variables reach the child, not about what is in them. The shield button records the approval, bound to the workspace and the row's **`fingerprint`**; it flips to a withdraw action once **`trusted`**, and is **disabled** under **`status: denied`** (**`mcp.project_trust: deny`**). Global rows carry no shield at all.
  - **Per-tool switches**: expanding a row reveals **`.mcp-tools`** — indented rows (**`.mcp-tool-row`**, dashed separators) with the tool name in monospace, its description, and a **`Switch`** per tool (**`POST /coddy/mcp/{name}/tools/{tool}/enable|disable`**). Tool switches are disabled while the server itself is off. Disabled rows dim their text but keep the switch crisp (same rule as skills).
  - **MCP discovery fieldset**: the tab opens with a **second, separate** **`.settings-fieldset`** (**`.mcp-discovery-box`**, legend **MCP discovery**) sitting **above** the server list box. It explains why project-local entries are gated and carries the **`mcp.project_trust`** **`select`** (**`data-testid="mcp-project-trust"`**, **`ask`** / **`allow`** / **`deny`**, capped at **`420px`**) posting **`POST /coddy/mcp/project-trust`** on change. It lives **here**, next to the servers it governs, rather than as its own settings section; like the rest of the tab it is **not** part of **Save all**. Both fieldsets share the panel's inline edges. The per-server **shield renders only under `ask`** (**`showsTrustControl`** in **`mcpServerJson.ts`**): under **`allow`** every project server starts anyway and under **`deny`** none does, so an inert shield would imply a per-server choice the policy has already taken.
  - **Toolbar**: **Add server** (opens the editor card prefilled with **`MCP_SERVER_TEMPLATE`**) on the left, a **refresh** icon (**`GET /coddy/mcp?refresh=1`**, re-probes) on the right (**`.mcp-toolbar`**, space-between).
  - **JSON editor card** (**`.mcp-editor`**, **`MCPEditorCard`**): inline JSON editing of one mcp.json entry — a name input (only when adding; names with **`__`**, spaces, or path separators are rejected client-side, mirroring the server), a **scope radio picker** (**`.mcp-editor-scope`**, only when adding, default **Local**: local saves to `./.coddy/mcp.json`, global to the agent home's `mcp.json`; the footer note echoes the target file, taking the global path from **`globalMCPPath(servers)`** - the `source_path` of a listed home-scope row, or the default `~/.coddy/mcp.json` when the agent home holds no server yet), a monospace **`<textarea>`** (**`.mcp-editor-json`**) holding the entry JSON (**`command`/`args`/`env` object/`disabled`/`disabledTools`**), validation via the pure helpers in **`mcpServerJson.ts`** (do **not** inline them), and **Save** (**`PUT /coddy/mcp/{name}?scope=`**) / **Cancel** actions. Editing an existing row renders the card inline beneath that row with the scope pinned to the row's owning file; its JSON holds **`<redacted>`** for every env and header value (the list never carries one), a second footer note (**`mcp-editor-values-hint`**, editing only) says that the placeholder keeps the stored value, and **Save** adds the row's **`fingerprint`** to the query, a **`409`** keeping the card open with the reason while the list is read again.
  - **Scroll stability**: same **`loadServers(firstLoad)`** gating as skills — refreshes never unmount the list.
- **Subagents** tab (**`SubagentsSection`**, section kind **`subagents`** in **`settingsSections.ts`**, keeping **`schemaKey: "subagents"`**) is **hybrid** like Skills: the generated **`SchemaForm`** of the **`subagents`** section (**`i18nDomain="subagents"`**), in a fieldset of its own (**Subagent settings**, one **`SchemaForm`** group), saves with **Save all**, and below it, **12px** apart like every block of the drawer (**`.settings-subagents-section`** is a flex column with a 12px gap), a **`.settings-fieldset`** (**`.subagents-catalog-box`**, legend **Definitions**, **`data-testid="subagents-catalog"`**) lists **`GET /coddy/subagents`** for the **workspace of the session on screen** (**`workspacePath`**, the chat's folder **`chatWorkspacePath`** in **`App.tsx`**: the session's workspace, or before the first message the folder picked on the start screen; **`spawn_agent`** resolves definitions against the session's own cwd; the Skills tab lists **`GET /coddy/skills?cwd=`** for the same folder); the tab does not print that path. The section's **Enabled** switch opens the tab above both blocks, and **Definition directories** is a block of its own between it and **Subagent settings**, whose legend carries an (i). A row's controls are its fold and, on a project definition under **`ask`**, the **MCP shield** (**`.subagents-trust`**, **`data-testid="subagent-trust-<name>"`**, amber **`.settings-btn-approve`** while unapproved, plain once approved), which posts **`POST /coddy/subagents/{name}/trust`** with the workspace and the **`digest`** the row showed (a file rewritten since answers **409**, said above the list) or **`untrust`**, then reads the list again. Built-in and user definitions, and every definition under **`allow`** or **`deny`**, carry no shield.
  - **Rows** (**`.subagents-list`**, **`.subagents-item`**, no rule between them, 10px apart): the **name behind the app's chevron** (**`.subagents-toggle`**, one button holding **`<Chevron />`** and the name, **`aria-expanded`**, **`aria-controls`** the facts, titled **Show / Hide declared bounds**; the chevron is centred on the name's first 20px line), a scope badge (**built in** / **yours** / **from the project**), **hidden**, the description as **plain text** (it comes out of a file that may have arrived with the checkout: never markdown, never a link), and the file in muted monospace (**`.subagents-file`**); description, file and facts start where the name does (**`.subagents-item-body`**, 18px: the 14px chevron and its 4px gap). A project definition still awaiting a receipt adds an amber **needs approval** badge (**`.subagents-badge-pending`**) whose tooltip names the shield and **`coddy agents trust <name>`**.
  - **Declared bounds** open under the row from its chevron, folded by default so the list stays scannable, with no line of their own and no browser disclosure triangle (**`chevronContract.test.tsx`** fails on a **`<summary>`** in a component that draws no **`<Chevron />`**): a **`<dl class="subagents-facts">`** from **`subagentDeclaredFacts`** in **`settings/subagentCatalog.ts`** - model, mode, permissions, tools, denies (only when declared), timeout (**`45s`** / **`10m`**), max turns, runs detached (only when forced), instructions size (**`B`** / **`KiB`**). A bound the definition leaves out reads as **inherited** (*the parent's model*, *everything the spawning session can call*, …) rather than disappearing. Keep the helpers in **`subagentCatalog.ts`** and the route in **`subagentsApi.ts`**; do **not** inline them.
  - **Containment**: **`.settings-subagents-section`** gives the catalog box, the list and its rows **`min-inline-size: 0; max-width: 100%`**, the file and the fact values **`overflow-wrap: anywhere`**, and a **104px** label column (**`.subagents-facts dt`**, **`flex: 0 0 104px`**) for labels like *permissions* / *инструменты*. An absolute path or a tool list must never widen the fieldset past the panel.
- **Field descriptions** (**`FieldHint`**, **`FieldLabel`**, **`LegendWithHint`** in **`external/ui/src/ui/settings/FieldHint.tsx`**): a settings form is a list of names and controls. The description of a field or a fieldset is never a paragraph under its name: it is a quiet **(i)** right after the name (**`.field-hint`**, 18px, **`color-mix(var(--text) 42%)`**, **`var(--accent)`** while hovered, focused or open), and its text shows in a tip on **hover**, on **keyboard focus** and on a **tap**; leaving, blur, **Escape**, a tap elsewhere and any **scroll** close it. The tip (**`.field-hint-tip`**, the **`--coddy-tip-*`** tokens, 12px / 1.5, at most **`min(320px, 100vw - 24px)`**) is **portalled to `<body>`** and positioned **`fixed`** **above** the (i), centred on it (below when there is no room), clamped **12px** inside the viewport, at **`z-index: 1000`**, the highest in the stylesheet, and with **`pointer-events: none`**. Portal and fixed placement are the point: a tip rendered inside the field lived in **`.settings-scroll`**, where it was clipped or grew a stray scrollbar. It exists only while open, and the (i) (**`aria-label`** **About &lt;name&gt;**) points at it through **`aria-describedby`** then. Every settings control with a name and a description goes through **`FieldLabel`** (text, number and enum fields of **`SchemaForm`**, **`ModelPicker`**, **`ReasoningLevelsField`**, **`ContextWindowField`**, the proxy URL, the NeuralDeep endpoint and both sign-in blocks), **`LegendWithHint`** (object and array fieldsets of **`SchemaForm`**, its groups that carry a **`description`** - Subagent settings, Hook settings, Model and threshold, Storage, Prompt templates - the Skills sources, MCP discovery and servers, the Subagents catalog) or **`SwitchField`**. What stays a paragraph is **state**, not description: status lines (reasoning levels, the model list, the context window note), the device-flow code prompts, the unknown-endpoint warning. Tests: **`FieldHint.test.tsx`** (hover, focus, tap, the close paths, the portal, the click never reaching a surrounding label, the CSS layering).
- **Boolean switch fields** (**`SwitchField`**, **`external/ui/src/ui/settings/SwitchField.tsx`**): every on/off setting in a settings form - the schema-driven **`SchemaForm`** booleans (**`models[].multimodal`**, **`models[].stream`**, **`tools.background.enable`**, **`memory.enable`**, gateway **`enable`** flags, ...) and the Skills **auto-discovery** row - renders through this one component, never as a bare **`Switch`** beside a **`<span>`**. It lays the shared **`Switch`** (**`.skill-switch`**, **38×22**, 16px thumb) and a **label cell** (**`.settings-switch-field-label-cell`**, a flex row centring its children with a **6px** gap) on a **two-column grid** (**`.settings-switch-field`**: **`grid-template-columns: auto minmax(0, 1fr)`**, **`column-gap: 8px`**, **`row-gap: 4px`**, **`align-items: center`**). The cell holds the label (a real **`<label htmlFor>`**, **`.settings-switch-field-label`**, **0.9rem**, **`var(--text)`**, so clicking the text toggles the control) and, when the field has a description, its **(i)** (**`FieldHint`**, see **Field descriptions** below) outside the **`<label>`**, so opening the tip never flips the switch.
  - **Alignment contract**: the label's vertical centre matches the switch's centre (**within 1px**), the label starts **8px** after the switch, and the (i) sits **6px** after the label on the same centre line. There is no description paragraph under the label any more; the checkbox-era **`padding-left: 28px`** rule (**`.settings-field-desc-below-checkbox`**) and the later **`.settings-switch-field-desc`** grid rule are both gone and must not come back.
  - **Naming**: the visible label names the control through **`aria-labelledby`**; pass **`ariaLabel`** only when the visible text is state copy (**Enabled** / **Disabled**, as on Skills auto-discovery). **`Switch`** enforces the precedence itself: with **`ariaLabel`** set it emits no **`aria-labelledby`**, so a direct caller cannot invert it. The former **`.settings-row-inline`** helper is deleted with its last consumers: used without **`.settings-row`** it fell back to inline flow (no gap, label baseline on the switch's bottom edge), which is how this bug happened, and there is no inline-flex variant to reach for.
  - **Coverage**: **`SwitchField.test.tsx`** pins the DOM (switch and label cell in one grid, label click toggles, a click on the (i) does not, explicit **`ariaLabel`** wins) and, since jsdom does no layout, the exact source rules: **`grid-template-columns: auto minmax(0, 1fr)`**, **`column-gap: 8px`**, **`row-gap: 4px`**, **`align-items: center`**, the flex label cell, and the absence of both legacy description classes. **`SchemaForm.switch.test.tsx`** and **`SkillsSection.autoDiscovery.test.tsx`** pin the two call sites. The live check measures **`getBoundingClientRect()`** of switch, label and (i) on **1280px** and **390px** (see **`docs/surfaces/web-ui.md`**, **Settings: boolean switch fields**). Reference captures: **`docs/assets/settings-switch-field-models-rows-1280.png`** (wide), **`docs/assets/settings-switch-field-models-390.png`** (phone), **`docs/assets/settings-switch-field-skills-rows-1280.png`** (state-copy label).
- **Sessions** tab (**`SessionsManager.tsx`**, section kind **`sessions`**, id **`sessions_manager`** because **`sessions`** is already the config key for the storage directory, which this tab edits above the table in a **Storage** fieldset saved with **Save all**) is the stored history as a **table** inside the **Session management** fieldset (**`SessionsManager`** renders the **`<fieldset>`**, **`min-inline-size: 0`**; what the table is for is the (i) of its legend, not a paragraph above the toolbar; cells are **5px** a side so the seven columns fit the 680px drawer without a sideways scroll), API-driven over **`/coddy/sessions`** and outside **Save all**. Reference capture: **`docs/assets/sessions-management-table-dark-1280.png`**.
  - **Toolbar** (**`.sessions-manager-toolbar`**, **`flex-wrap: nowrap`**): a search field (the same **`q`** filter History uses, debounced **250 ms**) taking the leftover width, and **one** icon action to its right at **every** width - **Delete selected (N)** (**`.settings-btn-danger.settings-btn-icon.sessions-manager-action`**, 40px square, **`data-testid="sessions-manager-delete-selected"`**, disabled with nothing ticked), behind the shared **`useConfirm`** dialog with **`variant: "danger"`**. Deliberately **one** button: the scope of a delete is what the operator can see ticked, never a label they have to read carefully, and the row and header ticks are how a wider scope is chosen. The narrow shell keeps the same single row - the field shrinks (**`flex: 1 1 auto; min-width: 0`**), it does not wrap the action onto a line of its own.
  - **Filters row** (**`.sessions-manager-filters`**) under the toolbar, at every width: the **archive** select (**`sessions-manager-archive-filter`**: working list, archive, everything) and the **source** select (**`sessions-manager-origin-filter`**: all sources, local, gateway, CLI runs - the **`origin`** query), half of the row each (**`flex: 1 1 0; min-width: 0`**, the select **`width: 100%`**). They are not in the toolbar: beside the field and the actions they left a phone's field a sliver and pushed the actions past the edge. The table asks for the runs of one-shot print mode (**`include_print=true`**), which History leaves out, and such a row leads its title with a terminal sign (**`.sessions-manager-print-mark`**, the archived mark's look, tooltip and accessible name **CLI run**).
  - **Icon language**: the trash can (**`IconTrash`**, inline SVG in **`SessionsManager.tsx`**) with a **check** inside its body is the toolbar action; the plain can is the per-row delete. The action's words are its **`title`** and its **`aria-label`**, never a label on the button. The one thing drawn on it is the **selection count** (**`.sessions-manager-action-count`**, **`…-selected-count`**, absolute at the top-right corner, **`var(--coddy-on-accent-fg)`** on **`var(--accent)`** - not **`--coddy-blend-base`**, which is transparent in the dark themes), because a tooltip cannot show it at a glance; with nothing ticked the badge is absent rather than a zero.
  - **The open conversation is protected.** The row whose id matches **`activeSessionId`** carries **`is-active-session`** and an **open** badge on its second line, its tick box and its row trash are **disabled** with **`sessions.manage.protectedRow`** as both **`title`** and accessible name, and **`selectableRows`** (everything else) is what the header checkbox, the selection pruning and the request body are computed from. The table can therefore never delete the chat behind the panel; History remains the place to do that, with its own goHome handling. A client-only draft protects no row, because no row is its own.
  - **Table** (**`.sessions-manager-table`** inside **`.sessions-manager-tablewrap`**): tick column, **Conversation** (title on the first line, then a muted second line, **`.sessions-manager-sub`**, carrying the workspace basename with the full path in its title and, for the protected row, the **open** badge - both are context for the title and neither earns a column in a drawer), **Model** (**`default`** when the session never overrode **`agent.model`**), **Msgs**, **Tokens** (compact **`1.5k`** / **`2.4M`**, the input/output split in the cell title), **Created**, **Updated** (short date, exact instant in the cell title, **—** when the bundle predates the stamp) and the row trash. Header cells are **`position: sticky; top: 0`**. Numeric columns are right-aligned with **`font-variant-numeric: tabular-nums`**.
  - **Wrapping contract**: the table sets **`white-space: nowrap`** and **`.sessions-manager-tablewrap`** is the **only** horizontally scrollable element of the tab (**`overflow-x: auto`**), so a narrow shell scrolls the columns and never the page. Long title, model and workspace values are capped in **`ch`** with an ellipsis and keep their full text in a **`title`**.
  - **Header checkbox** (**`…-select-all`**) covers the **rendered** rows only and shows **`indeterminate`** for a partial selection (**`toggleAllRows`** in **`sessions/sessionManagerRows.ts`**; keep the selection and formatting helpers there, do **not** inline them).
  - **The selection is a subset of what the table renders.** An effect over **`rows`** drops the tick of any row that is no longer listed, so a search that narrows the table takes its ticks with it and clearing the search brings the rows back **unticked**. A destructive action must never reach a row that is off screen, and a tick must never reappear later because it survived out of sight - the badge, the request body and what the operator can see are then the same three things.
  - **Scope**: the SPA sends only **`{ids}`** - exactly the ticked rows. The endpoint also accepts **`{scope:"all", except}`** for API clients that want the whole stored history without paging; that shape refuses an **`except`** naming no stored session and spares the **ancestors** of what it keeps (see **`docs/reference/http-api.md`**), but no button in the table reaches for it.
  - **The refresh after a delete reads the search on screen**, not the one captured when the confirmation opened (**`loadRef`**): a debounce that lands while the dialog is up would otherwise be overwritten by the older query, leaving the field showing one search and the rows another.
  - **Partial failure** is the normal answer when one session is mid-turn: the response lists **`deleted`** and **`failed`** separately, the failed rows stay, and the reason is reported in **`.settings-error`** (**`…-error`**) **after** the list re-reads, never before - the refresh clears the error slot.
- Object sections render their sub-schema fields directly (the tab already names the section); custom model editors are injected via the **`SchemaForm`** **`fieldOverride`** hook, not by forking the generic renderer.

### Session identifier in URL

`#/s/<sessionId>` survives reload/share as long as the browser hits the **same Coddy http instance** backing the **`sessions`** root hash. SPA keeps **`X-Coddy-Session-ID`** synced with whichever id anchors the fragment.

### Multi-session streaming and Stop

- The SPA may run **more than one** **`POST /v1/responses`** at a time, each with its own **`X-Coddy-Session-ID`**, while the user switches **`#/s/...`** quickly. Each session keeps a **shadow transcript** in memory so streamed rows from session **A** are never appended to session **B**. Routing uses **`pickStreamMutationBase`** in **`external/ui/src/ui/chat/streamMutationBase.ts`**.
- **Server activity and local reader lifetime are separate.** On session open and reconnect, the SPA reads **`GET /coddy/sessions/{id}/activity`** for the selected id, independently of the filtered or paginated History list. **`chat/useSessionTurnActivity`** skips both activity and queue hydration while this tab's own POST awaits admission. An active turn keeps Stop and queue controls available without a local reader. A reader closing does not mark the server idle; a **`turn_ended`** overlapping this tab's pending or admitted POST requires fresh REST activity confirmation before the SPA declares idle.
- The shadow transcripts form a **small LRU** (**`ShadowTranscriptCache`** in **`external/ui/src/ui/chat/sessionTranscriptCache.ts`**, cap **`SHADOW_TRANSCRIPT_CACHE_CAP`** = 3 non-pinned entries). The **viewed** session and every session with a **live composer stream** (own **`POST`** or a relay attach) are **pinned**; the least recently used entries beyond the cap are dropped whenever a session is picked, the user goes home, a stream finishes, or **`loadMessages`** stores a fresh snapshot. An evicted session is simply **re-fetched** from **`GET /coddy/sessions/{id}/messages`** on the next visit, the path a finished dialog already takes today, so eviction costs no extra request. A **`loadMessages`** response that lands after the viewer moved to another session only refreshes that session's shadow; it never paints under the current route.
- Message rows (**`UserMessage`**, **`AssistantMessage`**, **`ThinkingMessage`**, **`ToolCallMessage`**, **`SystemNoticeMessage`**, **`CompactionMessage`**) and **`Markdown`** are **`React.memo`** components, so unchanged rows skip rendering and markdown re-parsing while a token streams. **`MessageList`** itself still maps the transcript on every update, and the plan document, permission and question rows are not memoized. Handlers passed into the rows go through **`useStableHandler`** (**`external/ui/src/ui/components/useStableHandler.ts`**) to keep their identity across shell re-renders.
- **Attaching to a running turn replays only what the loaded transcript lacks.** A tab that opens or reloads a session mid-turn loads **`GET /coddy/sessions/{id}/messages`** first and attaches to **`GET .../composer-stream`** second. The relay used to replay the **whole** turn on top of that transcript: the reasoning and the answer of every finished step landed a second time at the foot of the column, and each finished tool row was re-stamped by the burst - its start and its end replayed in the same millisecond - so it read **`0ms`**, until the turn ended and a reconcile put things back. Now every relay frame is stamped with the revision of the session's message history it was written at (**`State.MessagesRev`**), the messages response carries the revision it was read at (**`messagesRev`**), and **`rejoinComposerLiveStream`** passes it back as **`?since_rev=`**: every frame written before that revision describes something the transcript already holds and is left out. The revision is remembered per returned transcript (**`snapshotRevByTranscript`**, a **`WeakMap`**) and only for one that is the server's snapshot and nothing more; a transcript that kept local rows past the snapshot, and a reconnect that holds a frame cursor (**`Last-Event-ID`**), attach as before. A frame older than **250ms** when it reaches a subscriber carries an **`age:`** field, and **`consumeComposerSse.ts`** dates every row it touches by it (**`eventNow`**) rather than by its arrival, so the reasoning block still streaming after a reload keeps its real start and a replayed tool call its real duration. Covered by **`features/composer_live_watch.feature`**, **`composer_stream_resume_test.go`**, **`consumeComposerSse.order.test.ts`** and **`App.stopQueue.test.tsx`**.
- **Every transcript row lays itself out**: no **`content-visibility`** on the rows, so the scrollport's height is the transcript's real height from the first frame. Off-screen rows used to skip layout and paint behind **`content-visibility: auto`** with **`contain-intrinsic-size: auto 120px`**, but the last remembered height only exists for a row rendered at least once, and a session opens scrolled to its end - so every row above the first screen was the fallback guess. Measured on a 360-row transcript, the scrollport reported **42114px** against a real **50342px**; scrolling up materialised those rows a screen at a time, the height grew under the reader, and **scroll anchoring** pushed the position down by the difference on every wheel notch. Row memoisation and the bounded shadow cache above carry the long transcripts instead. Covered by **`transcriptRowContainmentCss.test.ts`**.
- **Stop** (**`#btn-send`** as stop) calls **`POST /coddy/sessions/{id}/cancel`** and aborts this tab's own streaming **`fetch`** of the turn (the POST or the relay) as soon as the request is sent, not after the answer: over HTTP/1.1 a browser keeps six connections to a host for all of its tabs, a few open tabs can hold every one of them with event and turn streams, and the cancel request then waits for the connection this reader holds. The server turn does not depend on the reader. A failed request leaves a visible error and Stop available for a retry, and the tab rejoins the still running turn through the relay. Success acknowledges cooperative cancellation, not turn completion; server activity confirms idle. The server **persists** assistant tokens already received for that turn when cancel lands mid-stream (**`internal/llm`** stream implementations return a partial **`Response`** with **`context.Canceled`** wrapped, then **`internal/agent`** **`Run`** appends **`RoleAssistant`** before surfacing **`StopReasonCancelled`**).
- Right after Stop, **`GET /coddy/sessions/{id}/messages`** can briefly omit or shorten the in-progress assistant row versus what is already on screen. **`loadMessages`** merges the server snapshot with the **local shadow** or **visible items** when the server list is a strict prefix of local (or the last **`assistant_message`** is a shorter prefix of local); see **`mergeTranscriptPreferLocalSuffix`** in **`external/ui/src/ui/chat/transcriptServerSnapshot.ts`**. A full page reload still converges once persistence matches **`messages.json`**.

### Scheduler hash routes

- The scheduler jobs drawer footer is a single **Add job** control (**plus icon**, native **`title`** tooltip), **right-aligned** in the drawer (no manual **Refresh** button, list still reloads when the drawer opens and after saves). The job editor uses the same **`sessions-head`** / **`sessions-close`** chrome as **History** and the scheduler list. The job editor footer uses **pause or resume**, **delete** as icon buttons with the same **`title` / `aria-label`** pattern; on **`max-width: 1199px`** those actions are **end-aligned** for reach. While the drawer stays open, the client **polls `GET /coddy/scheduler/jobs` about every 12 seconds** (silent, no list loading chrome) so **running**, **next_run_utc**, and **paused** stay in sync with the server.
- Each scheduler job row uses **two lines** - **job_id** on the first line with either the **paused** badge or **`Next … (UTC)`** beside it (same line, muted), then **description** on the second line. The row body is a real **`href`** to **`#/scheduler/jobs/<job_id>`** so **middle-click** opens that job in a **new tab**.
- The job editor footer keeps **Resume** or **Pause** and **Delete** on the **left** for shorter pointer travel.
- **`#/scheduler`** opens the **Scheduler** jobs list drawer. **`#/scheduler/new`** opens the list with the **new job** editor (Add job sets this hash). **`#/scheduler/jobs/<job_id>`** opens that drawer with the **job editor** docked **next to** the list on desktop (**no** fullscreen scrim over the list). Encode **`job_id`** in the path segment when it contains special characters. The job row open in the editor uses the same **`session-item active`** highlight as **History** for the current chat. On **`max-width: 1199px`**, the **`.scheduler-dock-cluster`** matches **History** (**same `left` / `right` / `top` / `bottom` inset pattern**, full viewport height between insets). The jobs list alone fills that height. When the job editor is open, **`.scheduler-dock-cluster-editor-active`** hides the list and shows only the editor at **full cluster height** so it covers the list (**stacked overlay**, not a short bottom sheet). The cluster sits **above** the shared dim **`.backdrop`** (**`z-index: 70`**) so controls stay clickable while the drawer is open.
- **`#/history`** opens the **History** drawer alone. On **`min-width: 1200px`**, opening **History** while **Scheduler** is already open keeps both drawers by adding **`?history=1`** to the scheduler hash (example **`#/scheduler/jobs/<job_id>?history=1`**). Choosing another chat from the list keeps the drawer open by using **`#/s/<sessionId>?history=1`** while **History** stays visible. The main chat shell still uses the shared dim **backdrop** when a drawer is open.
- Deleting the **active** chat from **History** moves the shell to the **new chat** home (empty start screen), clears the session route, and **closes** the **History** drawer. Deleting **another** row removes it from the list and **keeps** the **History** drawer open; the URL and transcript stay on the chat that was on screen. After **`window.confirm`**, the client **briefly ignores** shell **backdrop** closes so a stray pointer event does not dismiss **History** or change the route. The row **trash** control calls **`stopPropagation`** on **`click`** before **`deleteSession`** so an **`async`** delete cannot bubble to the row and accidentally **`pickSession`** the deleted id.
- Field edits in the job editor **auto-save** with a short debounce (no separate **Save** button) without a footer status line. **`job_id`** is editable in the editor; changing it renames the on-disk job via PATCH. **Pause**, **Resume**, and **Delete** stay explicit.
- The URL still carries **one** primary route at a time for **`#/s/...`** vs **`#/scheduler...`** vs **`#/history`**; the optional **`history=1`** query only augments scheduler (or session) URLs for the dual-drawer desktop case.
- **`404`** from **`GET /coddy/scheduler/jobs`** means the server build has no scheduler HTTP surface; **`503`** means **`scheduler.enable`** is false for that process. The drawer shows a plain-language line instead of crashing.
- The job **`body (markdown)`** field uses the shared **`MarkdownLineEditor`** (see **Markdown line editor** below). Gutter, active-line highlight, wrap-aware numbering, and content-driven height match the plan card markdown pane.
- **Project jobs.** With a session (or a picked folder) the list carries the project jobs too, and the drawer groups the rows under small uppercase headings (**`.scheduler-job-group-head`**): **Global** (the user jobs), **This project · `<folder>`** for the chat's workspace, then **Project · `<folder>`** for every other workspace the scheduler runs, the full path in the heading's **`title`**. With no project job at all there are no headings and the list looks as before. A row that does not run carries a trust badge in place of the next-run text (**`.scheduler-job-trust--<state>`**: amber **needs approval**, red **denied**, **id conflict**, **invalid**; the reason in its **`title`**) and its run circle is disabled; a row that **needs approval** shows an amber shield (**`.scheduler-job-approve-icon`**, **`IconShield`**) in place of the run circle, which opens the job. Inside the SPA a project job is addressed by a **reference**, **`<workspace>/<job_id>`** (a job id never contains a slash), so its routes are **`#/scheduler/jobs/<encoded reference>`** and the API calls carry **`scope=project&workspace=`** (**`schedulerJobUrl`**); a user job keeps its bare id and its address. The editor of a project job opens with the **approval block** (**`.scheduler-trust-block`**, bordered amber or red by state): the state, the shield toggle (approve the digest it showed, or withdraw; under **`project_trust: ask`** only, the subagents shield's classes), where the job comes from, the reason, and for a job that is not trusted the raw file in a monospace box (**`.scheduler-trust-raw`**, max 260px, scrolls). A new job has a **Where the job lives** radio pair above the id (**Global** / **This project (`<folder>`)**); the project option is disabled without a session, and picking it empties **cwd** (a project job's cwd is relative to its workspace).
- **Runs of a job.** A run is a background agent task under the job's own session, so a job's history is the **background tasks panel** (**`BackgroundTasksPanel`**, unchanged) docked in the scheduler cluster where the editor docks (**`.bgtasks-panel.scheduler-runs-dock`**: relative, the editor's width rule, full cluster height; on **`max-width: 1199px`** it covers the list like the editor). Route **`#/scheduler/jobs/<job_id>/runs`** (**`.../runs/<task_id>`** is parsed, opens that run's card through the panel's **`focus`**, and is never written); the panel's heading reads **Runs of `<job_id>`** and its empty copy says no run happened yet. A run's card is the same card a chat's agent task gets. Each job row carries a **Runs** control (**`SchedulerIconRuns`**, a real **`href`** to the runs route, same-tab handler for a plain click) before the run/stop circle, and a **last-run mark**, absent until the first run: the panel's **`bgtask-dot`** in the status tone on the name's line, in a marks column laid out like a History row's (**`.scheduler-job-row-mark`**, the 24px first track of **`.scheduler-job-row-text-block`**, every line under the name indented to that column), and, once the run has ended, the status word with the local clock under the description (**`.scheduler-job-row-last-run`**); a running job has no such line, its pulsing dot and the Stop button say it. The editor footer carries the same Runs control first. Data comes from **`GET /coddy/sessions/{job session}/background-tasks`** on the panel's cadence (2.5 s while a run is in flight, 15 s idle); a run in flight is stopped from the job's row (its circle is Stop while the job runs) and the panel is given no **`onStopTask`**, so a run card carries no Stop of its own, **Clear** is **`DELETE /coddy/scheduler/jobs/{job_id}/runs`** (the transcripts go with the records), **Open transcript** opens the run session, whose read-only notice names the job and links back to the runs route instead of a parent chat.
- The job editor has two more fields after **`model`**: **`agent`** (a subagent definition name, free text with the definition help line) and **`permission mode`** (a select: **bypass (default: unattended)**, **accept_edits**, **ask**, **bypass**); both autosave like the rest.

### Background tasks panel

**`#/s/<sessionId>/tasks`** opens the panel. **`#/s/<sessionId>/tasks/<task_id>`** is still parsed - it opens the panel with that task's card open - but never written: which cards are open is the panel's state, and the address drops the id as soon as the card is open. The route hangs off the **session segment** on purpose: a task belongs to one chat, so an address that does not carry the chat reloads into a panel with no session behind it. Closing the panel writes the plain **`#/s/<sessionId>`** back.

The panel is **docked inside the session**, to the right of the transcript, rather than floating over the shell like **History** or **Scheduler**. A background task belongs to the conversation that started it, so being part of that conversation is what tells the operator which session a process came from; there is no session label to add. Implementation lives in **`external/ui/src/ui/tasks/`** (**`BackgroundTasksPanel.tsx`**, pure helpers in **`taskStatus.ts`**, REST client in **`api.ts`**).

- **Placement.** **`.bgtasks-panel`** is **`position: fixed`** against the right viewport edge (14px inset, full height), **380px** wide, using the shared **`--coddy-glass-panel-*`** tokens. On **`min-width: 1200px`** the chat column yields **only what the panel actually takes from it** (**`.shell-main.shell-tasks-open`** derives **`--coddy-tasks-reserve`** and pads **`#messages`** and **`.chat-bottom`** with it): while the centred transcript stripe still clears the panel the reserve is **`0`** and **nothing moves**, and once the stripe would run under the panel the reserve grows to exactly that overlap and stops at the panel's own band. Opening the panel must not slide the transcript sideways on a window that has room for both. The composer pays the scrollbar gutter back on its end edge so it stays on the transcript's centre line. It carries **no backdrop**: it sits beside the chat, it does not block it. On **`min-width: 1200px`** it belongs to the chat on screen and not to the rail, so it **stays open** while History picks another chat and shows that chat's tasks (the address follows, **`#/s/<sessionId>/tasks`**), across the rail's screens, and when the **backdrop** takes History or another screen down (**`closeAllShellDrawers`** leaves it alone there). On the stacked shell the panel is full screen, and a rail screen or its backdrop closes it.
- **Polling, not SSE.** A background task outlives the turn that started it, so the SSE stream cannot keep the panel honest. The shell polls **`GET /coddy/sessions/{id}/background-tasks`** every **2.5s** while anything runs and every **15s** otherwise (**`tasksPollIntervalMs`**), and the open cards read their output on a timer of their own (**`loadOutput`**, below). A poll against an unreachable server degrades to a normal error result, never an unhandled rejection once per tick.
- **Ordering** is **purely by start time, newest first** (**`sortTasksByStart`**), among the live cards and inside the finished history alike. Running tasks are **not** floated to the top of the history: they stand above the **Finished N** counter already, and mixing two orderings makes a list that never sits still to read.
- **One step, 10px**, separates every seam of the list: the head to the first live card, the last card to the **Finished N** counter, and the head to that counter when nothing is running. The cards carry **8px** under them, so the counter tops that up rather than adding its own; the empty note, the loading line and a list error sit on the same step and share the list's inline padding instead of nesting a second one.
- **One card for every task** (**`.bgtask-card`**, **`TaskCard`** in **`BackgroundTasksPanel.tsx`**) - a shell command, a subagent run, the memory run of a turn, a preview server, running or finished. Every card has the same parts in the same place, so a column of them reads at a glance: a **status dot** (**`.bgtask-dot--running|success|danger|warning|muted`**, the same vocabulary as the MCP server rows; the running dot pulses and the pulse is dropped under **`prefers-reduced-motion`**), a **tag** (**`.bgtask-tag`**, accent wash, **`taskTag`**) that says what stands behind the task - the agent's name (**`explore`**, **`general`**), **`memory`** for the memory run, **`server`** for a preview server, **`shell`** for a command; left out when the title already begins with that name (**`taskCardHeading`**: the title is the name, or the name and **` · `**, the shape of a scheduled run's **`<job> · <trigger> <when>`**), so a name is never written twice, and left out for an agent run that names no agent rather than reading a bare **`agent`**, which every agent card is - then the **title**; a scheduled run's title is its job alone, the trigger and the fire slot going to the hover text, so its first line is the dot and the job (**`taskTitle`**: the command, or the description of an agent run without the **`agent <name>:`** / **`memory:`** prefix the pool writes, because the tag already says that half), and under them a **meta line** (**`.bgtask-card-meta`**) indented to the title. Its left part (**`taskMetaLine`**) is **start · elapsed · est. · overdue** while the task runs (**`12:49 · 1m 28s`**) and **`12:49 · 20s · 12:50`** - when it started, how long it ran, when it ended - once it has finished; a day other than today puts the date in front, and an end on another day than the start is dated too. The full start and end, date and seconds, are lines of the card's hover text (**`taskTimesTitle`**: *Started …*, *Finished …*), under the task's whole label. **How a task ended is not written on a folded card**: the dot says it in colour, and the open card names it first in its foot; a word next to a green dot only repeated it. A running task that will wake the agent when it ends (**`notify_on_finish`**) carries a **bell** right after the title (**`.bgtask-notify`**, the shared **`BellIcon`**, **13px**, the tag's accent tint; **`title`** and accessible name *Wakes the agent when it ends*), and a finished task whose end did wake it (**`woke_agent`**) keeps the bell (*Woke the agent when it ended*): the title gives way before the bell does. A finished task that was to wake the agent and has not - the turn has not begun yet - carries none. The bell is the one place the web UI says what woke the agent, since the woken turn shows nothing of its own ([Woken turn](#woken-turn)). An agent run - a subagent, a scheduled run, the memory run - carries on the right of the same line the **model** it runs on, the **reasoning level** it calls it with when the model has levels, and the **tokens** its calls spent (**`.bgtask-card-usage`**, **`agentUsage`**, from **`agent.model`**, **`agent.reasoning`**, **`agent.input_tokens`** and **`agent.output_tokens`** of the row): **`qwen3.8-27b · High · 212k tokens`**, the model by its short name (the vendor prefix of **`vendor/model`** dropped), the level written as the composer's chip writes it (**`.bgtask-card-reasoning`**, which, like the tokens, never gives way), the tokens as input and output together, shortened like the turn line (**`212k tokens`**), the way a table of agents reads Model and Tokens across. The usage is muted and keeps its colour on an overdue card. When status and usage do not fit side by side the usage drops under the status **whole**, still at the right edge (**`flex-wrap`**), instead of cutting the model's name; only a name too long for a line of its own is clipped. The full model id and the exact input / output split are the card's hover text: the opener is stretched over the summary, so its **`title`** is the only one that can show, and the card stays one control. Under the status and the usage the card carries **the way into the task**, and at most one of them, because a task is one kind or the other. An agent run carries **Show transcript** (**`.bgtask-card-transcript`**, **`data-testid="bgtask-open-transcript-<id>"`**, accent text, no chrome of its own), which routes to **`#/s/<child session id>`** through the same in-place session open as a History pick; a running preview server carries **its address** (**`.bgtask-card-link`**, **`data-testid="bgtask-link-<id>"`**, the same accent text, **`target="_blank"`**, **`rel="noopener noreferrer"`**), which opens the page in a new tab. The run **is** that conversation and the server **is** that page, and an operator who had to open a card to find either was made to take a step on the way to the one move the panel exists for. Both stand above the stretched opener the way **Stop** does, so they open the transcript or the page and not the card under them, and both take a **row of their own** (**`.bgtask-card-transcript-row`** and **`.bgtask-card-address-row`**, **`flex: 1 0 100%`**): in a 380px panel a control sharing the meta line would be paid for by the model's name, the one thing there that cannot be cut down to something still readable. Until the row carries **`agent.session_id`** the transcript control is disabled with a title saying why, rather than absent, so the card does not change shape under the reader when the id arrives. An address is as long as it is and **wraps whole** rather than trailing off in an ellipsis that names no port, and a server that has **stopped** carries none at all: the page it named answers nothing now. Titles are set in the UI face at **13px / 600**; monospace is kept for the command block of the open card. A progress bar is drawn **only** while running **and** when the model supplied **`expected_seconds`**: the UI never implies knowledge it does not have.
- **The card is one control.** Its summary (**`.bgtask-card-summary`**) is the click surface: the opener button (**`.bgtask-card-open`**, **`aria-expanded`**) is stretched over the whole summary by an **`::after`** at **`inset: 0`**, so a click anywhere expands the card, and the summary answers the pointer with a **5%** text tint. **Stop** (**`composer-run-icon--stop`**, **24px** here, sized to the title row; **30px** on phones) is a sibling of the opener, not a child, and stands above the stretched surface (**`z-index: 1`**), so it keeps working on its own and never toggles the card.
- **A card opens in place.** There is no second pane and nothing to go back from: the open card (**`.bgtask-card-body`**, outside the stretched opener so its text selects) shows, top to bottom, the **command** in a monospace block with the shared copy control (**`CodeBlockCopyButton`**) in its top right corner - neither an agent run nor a preview server has a shell behind it, and nothing to put there - then the **error** - unless it only repeats the exit code, as the **`exit status 2`** the pool records for a command that exits 2 does (**`taskErrorText`**) - the captured **output** in a box of its own height (**56px** to **260px**, **46vh** on phones, its own scroll, following the tail unless the reader scrolls up; a dropped in-memory window is flagged **truncated**), and once the task has finished a **foot**: how it ended (**Succeeded**, **Failed**, **Timed out**, **Stopped**, **Orphaned**) and the **exit code** - **`Failed · Exit code 2`**. The exit code is said here and nowhere else on the card, and **how long the task ran is not repeated here**: the summary says it, folded or open. Neither an agent run nor a preview server has a process behind it, so their foot carries the outcome alone: the pool's exit code for them is synthetic. The way into the task is not said again here either: **Show transcript** and the address are the folded card's, like the elapsed time. **Any number of cards are open at once**, each reading its own output (**`loadOutput`**), again every **2.5s** while its task runs and once more when it ends. Which cards are open is the panel's own state and is **not part of the address**: the route says only that the panel is showing (**`#/s/<id>/tasks`**). The shell can still point at a card - a link that names a task (**`#/s/<id>/tasks/<task_id>`**, parsed, never written) - through the panel's **`focus`** prop: the card opens, its section with it, scrolls into view, and the address drops the id. The pointer names its chat and is spent once honoured (**`onFocusHonoured`**), because every session numbers its tasks from **`bg_1`** and the panel unmounts with the drawer; a card that is open renders even past the **40**-card cap of the finished list. An open card whose task has just ended moves under the counter, and the **Finished** section opens with it, so the card the reader was watching does not vanish.
- **Finished N** is a **counter** (**`.bgtask-section-toggle`**); expanding it renders the same cards, capped at **`FINISHED_RENDER_CAP`** (40) with a muted note naming what stays on disk. This is how "keep every log" and "do not load the app" hold at once: the history is counted, cards render on demand, and a task's output is fetched only when its card is opened. Running tasks carry **no heading of their own**: everything above the counter is running.
- **Clear** (**`.bgtask-section-action`**) appears only when there is history, and drops the finished tasks of this session via **`DELETE /coddy/sessions/{id}/background-tasks`**. Running tasks are untouched.
- **Empty state** names the chat, not the app: *No background tasks in this chat yet*.
- **Phones** (**`max-width: 1199px`**) give the panel the screen between the standard insets — there is no room to sit beside a transcript, and the output is what the operator came for. The cards take **12px** of padding and the output box up to **46vh**.
- **Tasks button** (**`.chat-view-btn--tasks`**, **`data-testid="chat-views-tasks"`**, the last of the view buttons in **`ChatHeader.tsx`**, below, at the right edge of the sticky chat header) is the opener of the panel and the one place a chat says how much runs in its background. The panel belongs to one chat, so its opener lives in that chat and not in the nav rail; the header does not scroll away, so the button stays in reach however far the reader has gone. It is the control the header always had: the **dot** (**`bgtask-dot`**, muted, running in the accent while work is in flight) and the word **`Tasks`** from the first message, so the header does not jump when the first task starts, and once the chat has tasks **`running / total`** (**`chat-view-count`**, tabular digits): **`Tasks 1 / 3`** with the accent border and tint (**`is-running`**) while work is in flight, **`Tasks 0 / 3`** once everything has finished. On a phone the counts speak for it and the word gives way. Both numbers come from **`countTasks`** (**`tasks/taskStatus.ts`**), which leaves out system tasks such as the memory run of a turn: they are not work the model or the operator started. Its tooltip and **`aria-label`** spell it out (**`Background tasks: 1 running, 3 in total`**). While a turn runs the **live line** names the running tasks too and opens the same panel (**`typing-dots-turn-tasks`**, see **States → Working**), and it stays at the tail with that count alone while tasks outlive the turn. Nothing is rendered under the transcript: the chip that used to sit there is gone.

### Views of a chat

A chat has three views beside the conversation: its **files**, its **background tasks** and its **edits**. The files and the background tasks are a row of buttons at the top of the chat, the way the views of a session sit at the top of Claude's app; the edits open from git's count on the plate over the composer (**Bar over the composer**), beside the folder and the branch they belong to. No view carries a tab strip that reaches the others: a strip inside a panel made every face of the dock repeat the same switcher, a menu hid them behind a click, and the files did not fit the dock's width anyway.

- **View buttons** (**`.chat-views`**, **`role="toolbar"`**, **`HeaderViews`** in **`ChatHeader.tsx`**, **`data-testid="chat-views"`**) at the right of the sticky chat header, in this order: **Files**, and **Tasks** at the right edge (above). There is no Edits button: git's count on the plate over the composer opens the edits. Files is an **18px icon**, the size of the rail's and the top bar's icons, in a rounded square of the top bar's look (**12px** radius, the faint border and fill), with its **short name** beside it on a desktop and a tablet (**`.chat-view-label`**, **13px**); a **phone** (**`max-width: 599px`**) has room for the icon alone, in the top bar's **40px** square beside the title. Heights follow the top bar: **36px** in a desktop header, **42px** on the stacked shell, **40px** on a phone. The **full name** is the button's **`aria-label`** and its **tooltip** everywhere (**`.chat-view-tip`**, the rail's tip tokens, under the row and kept to the button's right edge so the last one stays in the window): **`Workspace files (Ctrl+Shift+F)`** (**`⇧⌘F`** on Apple keyboards), **`Background tasks: 1 running, 3 in total`**. A button opens its view and, pressed again, puts it away; the button of the view on show is pressed (**`aria-pressed`**, **`is-active`**) and only brightens, as a pointed-at one does: the text at full strength, the border at **22%** and the ground at **9%** of the text colour, the way the Tasks control always marked its open panel. The accent is kept for work in flight (**`is-running`**), which wins over the pressed look.
- **The dock beside the chat** shows the background tasks (**`.bgtasks-panel`**, **`useRightDock`** in **`App.tsx`**). Its head is the panel's title in small capitals and the close control, nothing between them.
- **The edits** open in the **edits window** (**Edits of a chat**, below) from git's count on the plate over the composer, their one view: there is no dock face and no header button for them.
- **The Files window** (below) is not a face of the dock either: it opens over the chat, and whatever the dock showed stays under it.

### Workspace files window

**`external/ui/src/ui/files/FilesView.tsx`** (window, tree, tabs), **`FilePreview.tsx`** (one open file), REST client **`api.ts`**, the key in **`filesHotkey.ts`**. The window is framed like the documentation reader (**`.docs-dock-cluster`**), because both are a document read beside a conversation rather than a panel of it: the same glass sheet (**`.files-dock-cluster`**, **`--coddy-glass-panel-*`**), right of the rail, no wider than **`--coddy-dock-width`** and centred in what is left, over the shell's backdrop, which closes it on a click. **Expanded** (**`is-expanded`**, the arrows in its head) it takes the whole window, the rail included (**14px** from every edge), so the control does something at 1280px too, where the sheet already spans the space right of the rail.

- **Head** (**`.files-header`**): on the left the **tree switch** (a list icon, **`aria-pressed`**, accent-tinted while the tree shows) and the title spelled like the documentation's (**FILES** in small capitals, **12px**, a **72%** text tint) over the workspace folder's name (**11px**, its full path in **`title`**); on the right **More** (**⋮**), **Expand** and the close control (**`.sessions-close`**, **30px**). The icon controls are **30px** squares. **More** is a **`mode-menu`** hung inside the window (never clipped by the sheet): **Show hidden files** and **Wrap lines** as checkable rows, and **Preview** as a third one while the file on show is an SVG or an HTML file (below), then, with a file open, **Reload**, **Copy path** and **Download**.
- **What is on show brightens, it is never in the accent**: the pressed tree switch (**`.files-icon-btn.is-active`**, in the edits window too) takes the text at full strength, a border at **22%** and a ground at **9%** of the text colour, like the view buttons of the chat header; the file on show in the tree (**`.files-tree-row.is-active`**) a **9%** ground with a **1px** inset outline at **18%**. Pinned by **`chat/viewsLookCss.test.ts`**.
- **Tree** (**`.files-sidebar`**, **220-300px**): the filter on top, the rows as wide as it (the same **10px** side padding, the marked row's frame included, here and in the edits window), then one folder level at a time from **`GET .../workspace/tree`**, which lists **folders first, then files**, each by name, and pages with an opaque cursor (**`d/<name>`** / **`f/<name>`**). A folder is read when it opens, on its own; the open folders are read again when the page comes back into view, after a tool call, on Reload and on the hidden-files switch, each as far as it was read (**`rereadTree`**: the rows **Load more files** added stay; a page that brings nothing ends the walk), and a folder that is gone or refused folds away instead of failing the rest (**`Promise.allSettled`**). The answers are merged into the tree, never put in its place, so a folder opened or paged while they were on their way keeps its rows. Rows are **28px**, **13px** text, indented **14px** a level, and the column reads as one edge from the filter down: a top-level row's glyph stands where the filter's magnifier does (**20px** from the sidebar's edge) and its name starts where the filter's text does (**43px**), here and in the edits window (**`files/treeGeometry.ts`**, held to the stylesheet by **`files/filesWindowCss.test.ts`**): a folder carries the fold chevron (**`Chevron`**), a file an icon by what it holds (source, document, picture, other). A symlink or a special file is listed with **↗** and is not opened. The active file's row is brighter, with a light outline (above).
- **Filter** (**`type="search"`**, a magnifier inside it) searches the **whole workspace by name**, folders nobody opened included, through the same ranked index as the composer's **`@`** picker (**`GET /coddy/mentions`**, scoped like every cwd request with **`workspaceScope`**); only files and folders inside the workspace come back (the index also browses the disk for **`/`**, **`~/`** and **`../`** queries; those hits, and rooted or UNC Windows paths, are dropped). A new query clears the hits of the one before at once. A hit that is a folder opens the tree down to it. **Escape** with text in the filter clears it and is claimed; the next one closes the window. The filter takes the focus as the window opens, except on a touch-only device (**`composerAutoFocusAllowed`**).
- **Open files** are **tabs** (**`.files-tabs`**, **`role="tablist"`**, the close **×** a sibling of the **`role="tab"`** button), on a strip that stands as far under the head as the filter beside it (**10px**); a tab is **33px** and the strip's **1px** line under it makes the filter's **34px**, so the line the file starts under is level with the filter's bottom edge. Tabs that do not fit scroll sideways with no scrollbar and **no arrows**: the wheel turned over the strip moves it sideways, a finger swipes it, an end that has more fades out (**`has-more-left`** / **`has-more-right`**), and the tab on show is brought into the strip's view whenever it changes, by the strip alone (**`TabStrip`**): a file picked in the tree or a path clicked in the conversation opens a tab or shows its own; closing the tab on show shows the one beside it; a middle click closes one; two tabs with one name add their folder. The tabs outlive the window for the life of the page, per chat (its workspace is chosen once), each with its line (**`openFilesMemory`**, forgotten on an environment switch), so reopening the window finds them where they were, and the address learns the file on show. With none open the area says so in the middle: a folder icon, **Open files appear here**, and where they come from.
- **An open file** (**`FilePreview`**) is its body and nothing over it: the tab names the file, so there is no second name, no size or modification time, no switch over it between a rendering and the source (the one there is, **Preview**, is a row of **More**) and no line field (the address and the links set the line). Text is read **300 lines** at a time with line numbers and the chat's highlighting: the grammars of its code blocks (**`markdown/syntaxLanguages.ts`**), chosen by the extension or the file name (**`changes/diffLanguage.ts`**, every name of which is a registered grammar, **`diffLanguage.test.ts`**), and the lines on screen coloured **as one text** (**`highlightLines`**), so a comment or a string that spans lines keeps its colour on each; a line past **4096** characters stays plain and the text on either side of it is coloured on its own, and past **200000** characters on screen nothing is coloured, and a long file **scrolls through**: the next 300 lines are read as the reader nears the end of those on screen (**600px** ahead), the ones before as they near the top when the file opened at a line further down, the view kept where it was as lines go in above it; there are no pages to click. A file rewritten while it is read on answers **409**, is checked again, says so and starts over at its top. For now a file is only read, so the line an address names is scrolled to and not painted. A line's number and its text are one line box: **`.files-code`** sets one monospaced stack (the diffs'), **12px** at **1.6**, and the code element inherits it instead of the browser's own monospace, so the numbers never drift from their lines (**`files/filePreviewCss.test.ts`**; a **`var()`** naming a property nobody defines voids the whole declaration, which is how they once drifted, so **`cssVarsDefined.test.ts`** refuses one). **Markdown is text like any other**, its source with line numbers, so a picture or raw HTML in it is a line to read and nothing is loaded or run; a raster picture comes as bounded authenticated bytes (**20 MiB**) with fit / actual size; audio and video play natively from a **signed address** valid for at most an hour, which a swarm relay carries to the node without vouching for it (**`docs/operate/swarm.md`**). A **PDF** opens in the browser's own viewer where **`navigator.pdfViewerEnabled`** says the browser has one (**`FilePdf`**): bounded authenticated bytes (**50 MiB**), taken only when the node serves them as **`application/pdf`**, in a **`blob:`** address an **`<iframe>`** shows **without `sandbox`**, since every engine's viewer refuses to run in a sandboxed frame, so the viewer itself is the boundary; the **`blob:`** document belongs to the web UI's origin, where the raw route would serve the PDF in the node's origin under its sandbox policy, so a flaw in a browser's viewer would reach further, a risk the operator accepted for the preview; elsewhere (Chrome on Android) and when the read fails, a PDF downloads. **An SVG is a picture and an HTML file its source**, and **Preview** in **More** switches the kind to its other view for every file of the kind, remembered in this browser (cookie **`coddy_files_preview`**, **`files/previewPrefs.ts`**): an SVG drawn is an **`<img>`** of its bytes typed **`image/svg+xml`** (the node serves it as a download), which runs nothing the SVG holds and loads nothing it names, never SVG markup in the page, from a **`data:`** address and never a **`blob:`** one (a blob carries the page's origin, so the SVG opened from it in a tab of its own - dragged to the tab strip, or through a browser's own menu - would run its script beside the tokens; a **`data:`** document has an opaque origin), and not draggable; an HTML page drawn is read whole through the text reader (at most **2000000** characters, a longer one says so) and shown in an **`<iframe sandbox="">`** over the **`srcdoc`** **`htmlPreviewDocument`** builds (**`files/htmlPreview.ts`**): a policy first in its head that loads nothing but inline styles and **`data:`** pictures and fonts, its scripts, **`base`**, **`link`** and **`http-equiv`** metas dropped, a declarative shadow root kept an inert template and an SVG animation of a link's **`href`** or **`target`** removed, a link out of the page (an inline SVG's included) asking for a window the sandbox refuses; the page's relative pictures and stylesheets are not loaded, and it draws on a white canvas whatever the theme. The frame of a PDF or a page fills the file's place (**`.files-file-body--frame`**). Other binaries download. Each renderer shows only its own content, so a file rewritten as another kind under the same path never keeps the old text on screen. A failed read shows the server's message; the download hint goes with it only for a file that is not text (**415**). Returning to the page and a finished tool call check the file with its ETag, and a file that did not change is **not read again**: the text keeps its place and a playing sound or film keeps its address. A file rewritten under the preview says so, until another file opens or Reload, and starts over at its top. **Reload** reads it again whatever the ETag says, a picture, a PDF and a page included (they take **`epoch`**), so a read that failed on its way is not stuck; a media address is signed again after **50 minutes** at the next check, and once a minute at most when the media element fails.
- **Opened from** the Files button of the chat header, **`Ctrl+Shift+F`** / **`Cmd+Shift+F`** (matched on **`code === "KeyF"`**, so the Russian layout works; a second press closes it), a workspace mention in a sent message, a file tool's path and a permission preview's path (**`openWorkspaceFile`**, **`files/fileBus.ts`**). Opening it puts the edits window (**`EditsView`**) away, and opening that one puts it away, so one Escape never closes two layers. **`#/s/<id>/files?path=<relative-path>&line=<one-based-line>`** is its address: a reload opens it on that file and line, and closing it gives the address back to the chat (**`#/s/<id>/tasks`** while the tasks show under it on the stacked shell). The window is built once per chat and folder (**`useFilesWindowKey`**, **`files/filesWindowKey.ts`**): an address opens it before the chat's workspace context answers, and that folder becoming known must not build it again, which read the open file a second time; only a folder that gives way to another one (a worktree the first send moved the chat into) starts it over.
- **Escape** closes the window when nothing nearer claimed the key (**`useRightDockEscape`**); the dock's own Escape waits while the window is open, so Escape takes the window first and the dock next. A click on the backdrop beside the window closes it the same way, the address going back to the dock's. Closing gives the focus back to whatever opened the window.
- **Stacked shell** (**`max-width: 1199px`**): the sheet is inset like the documentation's (below the top bar, **8px** from the sides) at **`z-index: 72`**, over the dock (**70**), which the window leaves open under it and which the address of the window keeps (**`applyLocationHash`**); expanded (**85**) it also covers the top bar (**80**). The tree keeps its column (**200-260px**) while it fits beside the file. On a **phone** (**`max-width: 599px`**) one column shows at a time: the tree until a file opens, then the file; the tree switch brings the tree back over it. A file opened by its address shows at once, not under the tree.

Checks: **`files/FilesView.test.tsx`**, **`chat/ChatHeader.test.tsx`**, **`App.workspaceViews.test.tsx`**, **`features/web_ui_session_views.feature`**, and in a real browser through a swarm relay **`npm run check:files`** (**`scripts/files-check.mjs`**: the header buttons, the plate over the composer joined to the card with git's count at its right edge, the dock without a tab strip, the window at every tier of the grid in English and in Russian, a sound playing from its signed address through the relay, and the node's own web UI reading through the relay from another origin).

### Edits of a chat

**`external/ui/src/ui/changes/`** (**`workingCopy.ts`**, **`EditsView.tsx`**, **`EditsTree.tsx`**, **`useDiscard.ts`**, REST client in **`api.ts`**, pure helpers in **`sessionChangesText.ts`**, **`diffRows.ts`**, **`fileTree.ts`**, **`diffLanguage.ts`**, **`highlightLine.ts`**).

- **The edits are git's report of the folder**, never a recording: every tracked file that differs from **`HEAD`**, staged or not, and every new file git does not ignore (**`GET /coddy/sessions/{id}/changes`**, **`internal/gitws`**). Whoever made a change - the agent, a shell command, an editor beside Coddy - it reads the same. A folder in no repository has no edits, and the views say so instead of showing an empty diff. Without the git binary the server answers through its built-in implementation (go-git), which detects no renames. A repository nested in the folder is one entry, read and discarded as nothing.
- **One read per chat** (**`workingCopy.ts`**: a store keyed by session, **`useSyncExternalStore`**): git's count on the plate over the composer and the edits window share it. It reads when a view of the chat mounts, on the server's word about any chat (**`turn_ended`** after any turn, whichever surface ran it, and **`session_changes`** after a discard, both put on **`sessionChangesBus.ts`** by **`App.tsx`**; chats share folders, so another chat's turn moves this one's too), **400 ms** after the last of a burst of finished tool calls, and when the page gets the focus back, since the folder may have been edited elsewhere. A read asked for while one is in flight runs once after it; a failed read keeps the last answer on screen and says why (**`error`**), so a window with nothing known yet shows the reason instead of a blank; another environment forgets every chat's (**`onEnvironmentSwitch`**).
- **The edits window** (**`EditsView.tsx`**) is the one view of the edits, a window over the chat **framed and headed like the Files window** (**Workspace files window**, above), so the two read as one kind of window: the same glass sheet (**`.files-dock-cluster`**: right of the rail, **14px** from the top and the bottom of the viewport like the documentation and the files, no wider than **`--coddy-dock-width`**, the same inset on the stacked shell), over the shell's backdrop, which closes it on a click; **Expand** takes the whole window. Git's count on the plate over the composer (**Bar over the composer**) opens it; **Escape** closes it when nothing nearer claimed the key (the confirmation dialog over it answers its own, the tree's filter clears itself, the menu closes first; **`useRightDockEscape`**). **`#/s/<id>/changes`** is its address, so a reload opens it again; closing it gives the address back to the chat. The dock under it stays as it was. Layout: the head, then the tree of the changed files beside one scrolling document of file sections (**`.files-layout`**).
  - **Head** (**`.files-header`**): on the left the **tree switch** and the title (**EDITS**, spelled like the Files window's) over the folder's name and git's **`+A −D`** (where the line is short, on a phone, the folder's name takes the ellipsis and the count stays whole); on the right **More** (**⋮**), **Expand** and the close control, the Files window's **30px** squares. **More** holds **Side by side** (a checkable row; not offered on the stacked shell), **Collapse all** / **Expand all**, and after a separator **Discard all** in the colour of a deletion. Nothing else stands in the head: no toolbar of the window's own and no go-to-file list, which the tree replaces.
  - **Tree** (**`EditsTree.tsx`**, the Files window's **`.files-sidebar`** with its filter and rows): **only the files git reports as changed and not committed**, in their folders (a run of single folders is one row, **`fileTree.ts`**), each with an icon by what it holds and git's letter at the right end of the row (**`.edits-tree-status`**: **A** in the colour of an addition, **D** of a deletion, **M** muted). The filter narrows it by path; a folder folds on a click. The diffs read in the tree's order (**`treeOrder`**), so the mark walks the tree from top to bottom: the file at the top of the diffs is marked like the file on show in the Files window, a file picked scrolls the diffs to its section, and as the reader scrolls the mark follows, the tree bringing the marked row into its own view when many files make it scroll. A pick scrolls the diffs alone (never **`scrollIntoView`**, which moves every scrollable box around them), the file's card **10px** under their top, and **pins** it there while the diffs above it load or fold, until the reader scrolls away; a pick made while the patches were still on their way was otherwise carried down by every one that landed above it. The tree shows as the window opens; on a **phone** the window opens on the diffs, the tree switch brings the tree over them, and a file picked there gives the screen back to the diffs.
  - **The diffs' column keeps the tree's distances**: **10px** from its sides and its top (**`.dv-scroll`**), like the filter in the tree's column, the cards **10px** apart; the first card starts where the filter does.
  - **File section** (**`.dv-file`**) has a **sticky** header **33px** tall, which with the card's **1px** border is the filter's **34px**, so its line is level with the filter's bottom edge; left to right: the fold chevron, the muted folder and the bold file name (one button, which folds the diff; no status dot, the tree's letter says it and the button's tooltip names it), then **copy path** and **discard**, always in sight, never revealed on hover, and git's **`+a −b`** closing the row at its right end. There is no second chevron at the right: the name already folds the diff. The body renders **unified** (default) or **split** from **`toUnifiedRows`** / **`toSplitRows`**; a run of removals is zipped against the following run of additions, and context ends a run so unrelated edits are never drawn as a replacement.
  - **Between hunks** is a wordless separator (**`.dv-gap`**). The reference UI writes **`N unmodified lines`** there; this does not, because that is a number the reader cannot act on.
  - **Syntax colouring** comes from **`lowlight`** - highlight.js behind a tree API, the same engine the markdown renderer already uses, with the same grammars (**`markdown/syntaxLanguages.ts`**) - so the **`hljs-*`** classes already in the stylesheet apply and no markup is ever injected. The grammar is chosen from the file extension (**`diffLanguage.ts`**), never auto-detected: a diff hands the highlighter a few lines at a time and detection on a fragment that short changes its mind between hunks. An unknown extension renders as plain text. Colouring is per line, so a construct spanning lines is coloured as if it began on the line drawn - the trade every line-addressed diff view makes.
  - **Discard** puts a file back at **`HEAD`** (its section's action), **Discard all** every change (the last row of **More**). Each asks first in the shared confirmation dialog (**`useConfirm`**, the danger variant), whose text says what happens to that file - restored to the last commit, or deleted when **`HEAD`** does not hold it - then posts **`{"paths":[...]}`** or **`{"all":true}`** to **`.../changes/revert`** and reads the folder again whatever the answer (**`useDiscard.ts`**). A failure is a line over the sections. Discarding is the one thing the window writes; a commit is not offered. With nothing left the window says the folder is clean.
  - **Patches load per file**, four at a time, so the list and its counts paint at once and one huge file cannot hold up the rest; when git's report moves, only the files whose state moved are read again. The view choice persists in the **`coddy_diff_view`** cookie. On the stacked shell (**`isMobileShell`**) the split option is hidden and every diff reads inline.

### Background task on a transcript row

A **run_command** call with **`background: true`** returns the instant the task starts, so the call's own duration (**`0ms`**) is not the duration of anything. When a transcript tool call matches a task's **`tool_call_id`**, **`ToolCallMessage`** puts the **task's clock** in that slot instead (**`.thinking-dur`**, **`data-testid="tool-bgtask-elapsed-<id>"`**, ticking with the shared poll while the task runs), and the row's label already says the run is in the background. That is all the transcript says about it.

- **The outcome is not on the row.** The status (**Running**, **Succeeded**, **Failed**, **Timed out**, **Stopped**, **Orphaned**), the estimate and the exit code belong to the **task**, and are read where a task is read: its card in the **Tasks panel** (the meta line, and in the open card the error, the captured output and the foot). A transcript is a log of what the agent did, not a status board - a row that kept re-writing its own outcome made a background run look like a different kind of row.
- **Expanded**, the body keeps the task's clock and gains nothing else: there is no way into the task and no Stop on the row (no **`.tool-bgtask-actions`**). The task is opened and stopped where it lives, on its card in the **Tasks panel**, which a link that names it (**`#/s/<id>/tasks/<task_id>`**) opens at that card.
- The mapping comes from the same poll that feeds the drawer (**`backgroundTasksByToolCallId`** in **`App.tsx`**), so the transcript and the drawer can never disagree.

### Woken turn

A turn nobody typed - background tasks the model started ended and the server woke the agent (**`notify_on_finish`**, on by default) - shows **nothing of its own** in the transcript: no user bubble, which would put words in the operator's mouth, and no note either. The agent's answer follows the previous turn as the work carrying on, and the **bell** on the task's card in the Tasks panel says what woke it.

- The first message of the turn still arrives, as a **`background_wake`** item (**`chat/backgroundWake.ts`**) built from the relay frame live and from the message's **`background_wake`** field after a reload, and **`MessageList`** renders it as nothing.
- The item **opens a turn**: the answer that closes a woken turn keeps its action row, the live status line counts from it, the edit index of a later message counts it the way the server counts user messages, and a failed woken turn offers no retry.

### Subagent transcript (read-only)

A child session is the transcript of one delegated run. **`GET /coddy/sessions/{id}/messages`** marks it with **`subagent {parentSessionId, name, taskId}`** and **`readOnly: true`**; an ordinary session carries neither field, so their absence means a normal chat (**`parseSubagentTranscriptMeta`** in **`chat/subagentTranscript.ts`**). Either field alone is enough to lock the composer.

- **Composer slot.** **`ChatScreen`** renders **`SubagentReadOnlyNotice`** (**`.subagent-readonly-notice`**) in place of the **`Composer`**, in both the hero and the docked layout: *Read-only transcript of subagent `<name>`. Prompts go to the parent chat.* plus an **Open parent chat** link. The notice reuses the composer's glass card tokens so the bottom of the column keeps its weight, and it sits inside **`.chat-bottom-inner`**, so the transcript's bottom reserve is measured from it exactly as from the composer.
- **Nothing is sent.** With no composer there is nothing to submit; the shell additionally drops **`onSend`**, retry, message editing and the plan card's **Run plan** / **Discard** for such a session, because the server answers **409** to every prompt against a child. The plan handlers are withheld, not stubbed (the same conditional spread as **`onEdit`**), so a **`plan_document`** card on a child renders without its footer and with a read-only markdown editor (**`.plan-document-card--readonly`**) instead of showing controls that do nothing.
- **Back to the parent.** The link is a real **`#/s/<parentSessionId>`** href (middle-click and Ctrl/Cmd-click open a tab); a plain click goes through the same in-place session open as a History pick.
- **Header.** A child has no History row to name it, so the chat header falls back to *Subagent `<name>`* (**`chat.subagentTitle`**) instead of *New chat*.
- **Reaching it.** Child sessions are hidden from History, and nothing about a child's id sets it apart. They are reached from the Tasks panel (**Show transcript** on the run's card, which does not have to be opened for it) or by URL; the shell fetches any id it is given even when the sessions list does not carry it, and lets the messages endpoint answer. While the child runs the live state is served, afterwards the bundle, so the transcript shows the child's tool calls and its final answer through the ordinary renderer.

### Markdown line editor (shared)

Single implementation: **`MarkdownLineEditor`** in **`external/ui/src/ui/markdown/MarkdownLineEditor.tsx`**. Gutter math lives in **`external/ui/src/ui/markdown/markdownLineGutter.ts`**. Scheduler imports the same module via **`external/ui/src/ui/scheduler/MarkdownLineEditor.tsx`** (re-export only). Styles: **`.md-line-editor`** and **`.md-line-editor--plan`** in **`external/ui/src/styles.css`**.

**Consumers**

- Plan document card, markdown mode (**`PlanDocumentSection`**).
- Scheduler job editor **`body (markdown)`** (**`SchedulerJobEditorSheet`**).

**Layout**

- Horizontal flex: **gutter** (line numbers) + **stack** (highlight backdrop + **`textarea`**).
- Uses the **full width** of the parent. The **`textarea`** has **no vertical or horizontal scrollbar** (**`overflow: hidden`**, **`overflow-wrap: anywhere`**). **Height grows with content** (plus a **minimum logical row** count). When the surrounding UI needs scrolling (scheduler editor scroll region, long chat transcript), the **outer** container scrolls, not the inner editor.

**Line numbers**

- Numbers mark **logical** lines (split on `\n`).
- When a logical line **wraps** to multiple visual rows, only the **first** visual row shows a number; wrapped continuation rows keep an **empty** gutter cell at the same row height.
- When the document has fewer logical lines than **`minRows`**, pad the gutter with numbered blank rows up to **`minRows`** (scheduler default **10**, plan card **4**).

**Active line**

- The logical line that contains the caret is highlighted across **every** visual row it occupies: one **`md-line-editor-hl-band`** per visual row with **`is-current`** (semi-transparent background). The matching gutter number uses **`is-active`**.

**Measurement**

- A hidden probe (**`md-line-editor-measure`**, same font and wrap rules as the textarea) measures each logical line at the textarea **text width** (inside horizontal padding).
- Visual row count per logical line: **`ceil((measuredHeight - 1) / lineHeightPx)`** (see **`measureLineVisualRows`**). Gutter rows and highlight bands share the fixed band height **`--md-editor-line-px`** set on the editor root from computed line height.

**Typography**

- Default (scheduler): **12px**, line height **1.45** (**`--md-editor-fs`**, **`--md-editor-lh`**).
- Plan variant (**`.md-line-editor--plan`**): **13px**, line height **1.5**.

**Tests**

- **`external/ui/src/ui/markdown/MarkdownLineEditor.test.tsx`**
- **`external/ui/src/ui/markdown/markdownLineGutter.test.ts`**
- Plan card: **`external/ui/src/ui/chat/PlanDocumentSection.test.tsx`**

### Plan mode plan document card

**Component**: **`PlanDocumentSection`** (**`external/ui/src/ui/chat/PlanDocumentSection.tsx`**). Rendered from **`plan_document`** transcript items (**`MessageList`**).

**Collapsed**

- **Title**: frontmatter **`name`** or **`slug`**. **`title` attribute** (native tooltip) shows absolute file **`path`** when known (for example **`…/plans/<slug>.plan.md`**).
- **Description**: one line from **`overview`** or the first non-empty body line.
- **Footer**: **Discard** and **Run plan** stay visible; expand/collapse is on the header button only.

**Expanded**

- **Left accent**: **`box-shadow: inset 2px 0 0`** orange on **`.plan-document-card--expanded`** (not discarded).
- **Header**: title toggles expand; optional **Saving…** or save error after debounced PUT.
- **Body pane** (**.plan-document-pane**): one region for content. **Eye control** top-right (**`Toggle preview`**, **`aria-pressed`** when preview is on). **Default: Preview** ( **`Markdown`** on body text). Toggle switches to **Markdown** (**`MarkdownLineEditor`**, **`className="md-line-editor--plan"`**, **`minRows={4}`**, spellcheck enabled).
- **No fixed-height clip** on the pane: **preview** and **markdown** both **grow with content**; **no inner scrollbar** on **`.plan-document-pane-inner`**. The chat column scrolls when the card is tall.
- **Footer**: **Discard** (text link) and **Run plan** (orange, ▶ icon). **`min-width: 640px`**: CSS grid places **head** and **actions** on one row, **body** full width below; actions stack in a column on the right.

**Editing**

- The editor shows **markdown body only** (YAML frontmatter stripped via **`planEditorBody`** in **`planContent.ts`**). Autosave **`PUT /coddy/sessions/{id}/plans/{slug}`** with JSON **`{ "body": "…" }`**, about **600ms** debounce.

**Discard**

- **`DELETE`** marks the plan **`discarded`** in session state; the card **stays** in the transcript, muted (**`.plan-document-card--discarded`**), controls disabled. Server excludes discarded slugs from the plan-mode system prompt.

**Read-only transcript**

- **`onRunPlan`** and **`onDiscard`** are optional. **`MessageList`** forwards **`onPlanDocumentRun`** / **`onPlanDocumentDiscard`** only when the shell provides them, and a subagent child transcript provides neither (exactly like **`onEdit`**). With no handler at all the card carries **`.plan-document-card--readonly`**, renders **no footer**, keeps the preview and the eye toggle, opens the **`MarkdownLineEditor`** as **`readOnly`**, and schedules no autosave **`PUT`**. Each footer button is also rendered only when its own handler exists, so no stubbed handler can leave a dead **Run plan** or **Discard** on the screen.

**Run plan**

- Starts implementation via session prompt metadata (see **`docs/reference/acp-protocol.md`**, **Run plan**).

**Tests**

- **`external/ui/src/ui/chat/PlanDocumentSection.test.tsx`**

### Layout grid

The shell has four tiers, chosen by the viewport's width. Every width query in **`styles.css`** and every **`matchMedia`** in the code is an edge of a tier or a component threshold listed below, and the code reads the edges from **`shellBreakpoint.ts`** (**`PHONE_MAX_WIDTH_PX`**, **`SHELL_STACK_MAX_WIDTH_PX`**, **`WIDE_RAIL_MIN_WIDTH_PX`**) rather than spelling them out. **`layoutGridCss.test.ts`** reads the component table below as its allow-list: it fails on a width the stylesheet asks about that is neither a tier edge nor a row of that table in the direction the row gives, on range syntax or a unit other than px, on a row no rule uses any more, and on a **`matchMedia`** with a literal width outside **`shellBreakpoint.ts`**. A new width is a new row here first. The phone rules used to be split between **480px** and **520px** while the shell broke at **1199px**, and a phone from 521 to 599px got the tablet layout.

| | Phone | Tablet | Desktop | Wide |
| --- | --- | --- | --- | --- |
| Width | up to **`599px`** | **`600px`** to **`1199px`** | **`1200px`** to **`1919px`** | from **`1920px`** |
| Query | **`max-width: 599px`**, on top of the tablet query | **`max-width: 1199px`** (the stacked shell) | **`min-width: 1200px`** | **`min-width: 1920px`** |
| Devices | 360 to 430 in portrait: Galaxy S21, iPhone 13 mini, Honor X9d | iPad mini and Air in portrait (744 to 834), iPads in landscape (1024 to 1180), a narrow desktop window | laptops, desktop windows | large monitors |
| Navigation | top bar: **Coddy** alone, **40px** icons, what does not fit folds behind **More** | top bar with the brand and every icon | vertical rail, icons only | the rail may widen to icons plus labels (**`coddy_nav_rail`**) |
| Scroll | the document | the document | one inner scrollport, **`#messages`** | as desktop |
| Composer | docked to the viewport's bottom edge; the selector chips are one strip that scrolls sideways; the plate's picks are 36px tall, git's count slim with a 40px hit area | docked; the same plate on a touch screen | docked inside the chat column | as desktop |
| Menus and pickers | bottom sheets over a scrim | bottom sheets over a scrim | anchored dropdowns | as desktop |
| Transcript | one column, nothing wider than it | as phone | a centred **920px** stripe | as desktop |
| Settings | the drawer, **2**-wide section tiles whose names wrap to two lines, one section at a time behind **Back** | the drawer, section tiles, one section at a time | the section rail beside the form | as desktop |
| Drawers and panels | History, Scheduler, Tasks, Docs and Swarm take the screen between the insets | as phone | docked beside the rail | as desktop |
| Text fields | at least **16px** | at least **16px** on a touch-only device | as designed | as designed |

A phone is the stacked shell narrowed, not a fourth layout: everything the tablet column says holds on a phone unless the phone column says otherwise, and the rules live in one **`@media (max-width: 599px)`** block (see **Phone layout**). **599px** is the compact window class of the mobile platforms and the edge issue #340 asked for: every phone in portrait is under it, and the smallest tablet in portrait is over it.

**Navigation stays at the top on a phone.** The docked composer owns the bottom edge and rises with the on-screen keyboard (**`interactive-widget=resizes-content`**); a bottom navigation bar would sit between the transcript and the composer, or under the keyboard. The top bar keeps **History** one tap away and folds the rest behind **More**, and the menus a phone opens are bottom sheets already.

Component thresholds decide a component's own layout from the width it gets, not the shell's tier. Each one is a row here:

| Threshold | Component | Why it is its own |
| --- | --- | --- |
| **`min-width: 640px`** | plan document card head | the title and the actions sit side by side only once the card is wide enough for both |
| **`max-width: 700px`** | swarm screen padding | the map needs the width more than the frame around it |
| **`min-width: 761px`** | desktop rail pill | the rail's track (**`--rail-pill-track`**) and its **44px** hit icons; it predates the grid and also reaches the tablet top bar from 761px |
| **`max-width: 1279px`** | documentation outline | the reader's third column (**On this page**) needs **1280px** |
| **`min-width: 900px`** | documentation outline on a tablet | on the stacked shell **On this page** stands beside the page once the page keeps at least about 600px next to it; below, it folds into a button above the page |

Verification widths: **360**, **375**, **393** and **430** for the phone, **599** and **600** for its edge, **834** for the tablet, **1199** and **1280** for the desktop's edge. **`npm run check:overflow`** (**`external/ui/scripts/phone-overflow-check.mjs`**, a live browser against the **`phone-overflow-check.html`** stand) measures the transcript at each of them.

### Phone layout

A phone gives the shell 360 to 430 CSS px. Everything below lives in one **`@media (max-width: 599px)`** block at the end of **`styles.css`**, after the stacked-shell rules it narrows, and nothing in it applies above 599px (the phone tier of the **Layout grid**). A few components narrow themselves under the same query next to their own rules: the Tasks control of the chat header, the permission card and the live status line.

- **Top rhythm.** On the stacked shell the top bar's pill stands **`--coddy-top-rhythm`** (**10px**) under the window's edge and the chat's title the same step under the pill (**`--coddy-mobile-title-sticky-top`**: the top inset less the bar's 4px of slack, plus the step); an expanded composer keeps the step under the title and around its control (see **Composer field height and expand**). One step, one variable: do not give either gap a number of its own.
- **Top bar.** The brand is **Coddy** alone (**`.rail-brand-sub`** hidden) and it is the element that gives way: **`.rail-brand`** and its wrapper **`.rail-brand-tip-host`**, which is the bar's flex item, may shrink and clip (**`flex: 0 1 auto; min-width: 0; overflow: hidden`**), **`.rail-middle`** grows but never shrinks (**`flex: 1 0 auto`**), so the icons keep to the right edge and are never painted over the brand. Icons are **40px** with a **4px** gap. When they do not all fit next to the brand at its natural width, the bar folds the rest behind a **More** button (three dots, **`nav-more`**, **`nav/navOverflow.ts`**): **History**, **Swarm** and the **environment** never fold; the others come back into the bar as room grows in the order **Settings**, **Scheduler**, **Docs**, **Sign out**; the menu under the button lists what is folded as **Docs**, **Scheduler**, **Settings**, then a separator and **Sign out** at the bottom. The menu hangs from the button's right edge (**`.rail-more-menu`**, opaque, **44px** rows, real **`href`** links), closes on a pick, **Escape** or a press outside, and the button lights up while a folded panel is open. **`NavRail`** measures the pill with a **`ResizeObserver`** only on the stacked shell; the desktop rail never folds. It is not a global app menu: it holds only what the bar has no room for, and nothing when everything fits.
- **Composer selector strip.** The bar scrolls its attach button and the mode, model, reasoning, permission and toolset chips inside **`.composer-tabs`**, one line under a **16px** fade at the right edge with a **12px** trailing spacer so the last chip can scroll clear of the fade, the context ring and **Send** fixed on the right. Chips keep their height (**8px** vertical padding); a long model name ends in an ellipsis (**11em**). Menus are portals and bottom sheets, so the scroller never clips them. The workspace is on the plate over the card, one line whose picks and count a finger can hit on a phone (**Workspace picks on the plate**).
- **Start screen.** **`.hero`** is one grid track that cannot grow past its container (**`minmax(0, 1fr)`**, at every width), so the page never scrolls sideways and the title stays centred.
- **Transcript.** On the stacked shell the transcript's column is the page, so a row wider than the column scrolls the whole page sideways; one tool row named after an MCP tool did it by 400px at 360px. Nothing in the transcript may be wider than it, at any tier: a tool row's label wraps inside the row when it is longer than the row (**Tool card UI**), and prose, links and inline code break where they have to (**Markdown**). Tables and code blocks keep their width and scroll in their own box. Tool rows stay folded until tapped.
- **Settings.** The section tiles keep two columns, and a section's name wraps to two lines instead of ending in an ellipsis (**`.settings-tile-title`**): a phone has no hover, so the full name in the tile's **`title`** tooltip is out of reach. An MCP server row puts its name and command line on the first line and wraps its controls to the row's end below them (**`.mcp-list-item-head`** **`flex-wrap: wrap; justify-content: flex-end`**, the text block **`flex-basis: calc(100% - var(--mcp-row-inset))`**), so a server name is read whole instead of a few letters between the status dot and four 40px buttons. A map row of the settings (the default headers of **HTTP requests**) puts the value under the name, the trash beside the pair (**`.settings-map-entry`** one column), and its rows stand 16px apart (**`.settings-map`**), twice the gap inside a pair, so a header name is read whole.
- **Text fields** are at least **16px** on a phone and on any touch-only device (**`(any-hover: none) and (any-pointer: coarse)`**), because iOS Safari zooms into a smaller field on focus and stays zoomed; the composer textarea and its highlight mirror change together.
- **Keyboard.** What Enter does follows the input device, not this width: see **Composer primary action**.

### Sessions drawer placement (implementation contract)

- **Horizontal alignment**: The **left edge** of the drawer is **`rail-column` right edge + gutter** (~**`--nav-floating-gutter`**). Do **not** hardcode **`left`** in **`px`** for "wide navbar" guesses (wide **`fit-content`** width varies).
- **Measured track width**: SPA sets **`--rail-shell-track-width`** on **`.shell`** to **`rail-column.offsetWidth`** (ResizeObserver in **`NavRail`**) before computing drawer **`left`** and **`width`** so narrow and labeled-wide rails stay flush with **`--nav-floating-gutter`** after the nav column.
- **CSS fallback**: When the variable is not yet set inline, **`--rail-shell-track-width`** defaults on **`.shell`** to **`calc(var(--rail-pill-track) + var(--rail-column-pad-end))`**.

### History grouping, tags and the archive

- **One control, not a row of them.** A sliders button (**`.sessions-filter-trigger`**) sits at the right
  end of the **search row**, and everything that decides what the list shows lives in the menu it opens
  (**`.sessions-filter-menu`**): Status, Environment, Group by, Sort by - status first, because "am I
  looking at the archive" is the question asked most often. It belongs with the search because both
  narrow the list below, and not in the head, where its neighbour would be a close button that does
  something else entirely. It takes the **height and corner radius of the search field** beside it
  (36px, 12px) so the row reads as one strip of controls. The menu hangs 6px under the trigger, its
  right edge on the trigger's (see **Portaled, not nested**).
- **Four rows deep, not four lists long.** Each row (**`.sessions-filter-row`**) carries its question on
  the left, the **answer in force** in the accent colour (**`.sessions-filter-value`**) and a chevron;
  the choices open beside it as **`.sessions-filter-submenu`**, on a mouse hover or on a click or tap,
  one at a time. Only a mouse opens a row by hovering it: a touch opens it by its tap, because iOS
  drops the click of a tap whose emulated hover shows new content. The click that follows the mouse
  onto a row it has just opened keeps the row open rather than folding it straight back; a later
  click, or Enter or Space on the focused row, folds it.
  A hairline above **`.starts-group`** separates the two rows that decide *what is listed* from the two
  that decide *how it is arranged* - a rule rather than headings, because all four are questions of the
  same kind. A section with only one row to choose from is not rendered at all.
- **Portaled, not nested.** The drawer is **`overflow: hidden`**, so a submenu inside it would be cut at
  its edge: the menu is rendered into the document (**`createPortal`**) and placed **`position: fixed`**
  from the trigger's rectangle, the same way the composer's menus are. The menu is never wider than the
  room left of its trigger (**`max-width`** from the trigger's right edge less 8px).
- **A list opens where its whole width fits.** A list's width depends on the locale and on the names of
  the remotes, so it is measured in a layout pass before it is painted (**`placeFilterSubmenu`**) and
  opened beside the menu on the right (the drawer sits at the left of the window), on the left near the
  window's right edge (**`.opens-left`**), each 8px clear of the window's edge. Where neither side has
  the room - a phone, where the drawer is the width of the screen and the trigger sits at its right
  edge - the choices **fold out under their row**, inside the menu (**`.opens-inline`**): indented under
  the question, no panel of their own, the row's chevron turned down as a disclosure's is. The menu then
  scrolls as a whole instead of running past the bottom of the window (**`.has-inline-submenu`**), and
  until it closes a click, not a hover, switches sections, since a list folding away under the pointer
  would move the rows beneath it. In a 393px window a list used to start at -100px, leaving only the
  check marks on the screen (issue #460). Pinned by **`SessionsFilterMenu.test.tsx`** and
  **`features/web_ui_menus.feature`**.
- **Only a value moved off its default is coloured.** The answer on a row is the accent colour when it
  differs from the default (**Active**, the first environment, **Folder**, **Last activity**) and a
  muted 42% text otherwise (**`.sessions-filter-value.is-default`**): a menu where every row is accented
  says nothing about what has been narrowed. Each of the four is remembered in its own cookie
  (**`sessionPrefs.ts`**), and every read validates what it finds, so a cookie from an older build
  cannot put the drawer into a state the code no longer has.
- **The menu is opaque.** It uses the **tooltip** surface (**`--coddy-tip-bg`** / **`--coddy-tip-shadow`**),
  not the glass panel: it sits directly over the list it filters, and a translucent panel there is read
  through. Both panels use it, so the row and its choices read as one surface.
- **Escape undoes one step**: an open submenu folds first, the menu closes second.
- **Group heading** (**`.session-group-head`**) is the bucket's **own name with the shared chevron after
  it** (**`<Chevron open />`**, see **Chevron**: pointing right while collapsed, turned down when open),
  not a band across the list: it is **`flex: 0 1 auto`** with
  **`margin-right: auto`**, so only the name is the fold target and the empty space after it belongs to
  nothing. **12.5px**, weight **600**, muted until hover, and in the **case the name already has** -
  upper-casing a folder path is a small lie. The label is translated for a fixed bucket (dates,
  *No workspace*, *No tags*) and is the operator's own text for a folder name or a tag.
- **The end of the bar is the `+`** (**`.session-group-new`**), in a 26px box whose right padding
  matches a session row's, so it lands in the **same column as the rows' own ⋮**. It is **visible at
  rest**, not revealed on hover: a way to start work in this folder is worth a glance, not a hunt with
  the pointer. There is no row count - the rows are right there to be looked at.
- **Hover brightens text, never a plate.** Both the heading and the **`+`** transition their **colour**
  only (140ms): a band appearing under the pointer makes a heading read like a row that can be opened.
- **A folder name more than one heading carries** gets the full path under it
  (**`.session-group-path`**, 10.5px, 40% text): the name alone cannot tell two checkouts apart, and
  that is the only case the line appears in. It is clipped plainly at the end - reversing the direction
  to keep the tail moves the leading slash to the other side, and a path that reads as ending in
  **`/`** is a worse lie than a truncated one. **`SessionGroup.subLabel`** carries it, set by
  **`groupSessions`** only for the names that repeat.
- **Buckets** that hold nothing are not drawn. Collapse state lives in the drawer, keyed by group, so a
  heading that comes and goes with a search keeps its state while it is on screen.
- **A folder heading carries a `+`** (**`.session-group-new`**, 22px, revealed on hover of
  **`.session-group-bar`**) that starts a new chat in that workspace. Only a heading that *stands for a
  folder* has one - a date bucket is not a place to put a session - which is why **`SessionGroup`**
  carries **`workspacePath`** (the full path) next to the name it shows.
- **The pinned group** (**`.session-group.is-pinned`**) leads every mode, headed *Pinned* and closed by a
  hairline: the pins are one list kept by hand, and the groups below are the ones the machine made.
  **`groupSessions`** lifts the pinned rows out first, so a pin is held **once** - a conversation at the
  top *and* inside its folder would leave dragging one of the two meaning nothing.
- **A pinned row is dragged by the row itself** (**`.is-reorderable`**), with no grip: a handle beside
  the title pushed a pin's title off the edge every other row shares. A mouse or a pen starts the drag
  once the press has travelled **4px**, and the click that release produces is swallowed so the moved
  row does not open; a finger has to **hold the row for 400ms** first, so a swipe still scrolls the
  list, and once held the page's **`touchmove`** is cancelled so the finger moves the pin, not the
  list. The row takes no text selection, native link drag or touch callout. The drag runs on
  **pointer events** - HTML5 drag-and-drop never starts from touch - the dragged row fades
  (**`.is-dragging`**) and the **landing place** is drawn on the list itself - an inset accent rule
  along the top of the row it would take (**`.is-drop-target`**), which the 14px corner radius carries
  around the corners so it reads as that row being singled out - rather than a row following the
  pointer, which would survive no scroll and cost a compositing layer. The arithmetic is
  **`reorderPins`** / **`pinDropIndex`**, kept pure.
- **Archiving moves the row at once** and sends the **`PATCH`** behind it, and the list is **not read
  again**: a re-read from the first page dropped the rows scrolling had loaded, and with them the place
  in the list where the next conversation to archive sat. A refused **`PATCH`** puts the row back
  where it stood - before the row that followed it, or at its old index when that row has gone; if
  the operator switched the **Status** filter meanwhile, the row stays out and the list error says it
  instead - and says so **on the row** (**`.session-row-error`**, **10.5px** in the failure colour of a
  tool row's marker, **`role="alert"`**): the list error at the top of History is out of view once the
  list has been scrolled. The note is a sibling of the row's link, not part of it, so the link keeps
  the conversation's name; the row wraps to give it a line of its own (**`.has-row-error`**). It goes
  when the operator tries again or closes the drawer. Only the flag is put back: a title or tags that
  changed while the request was out stay. A listing issued before the **`PATCH`** settled still carries the row as it was, so every
  listing is shown with this tab's own moves applied until one issued after the settle comes back; and
  since the listing pages by an **offset**, each archive that took a loaded row out of the working list
  moves the next page's cursor back by one, or the conversation that moved up into the gap would fall
  between two pages. A page asked for while such an archive is still in flight starts one row earlier
  per archive, because the server may apply the **`PATCH`** before it answers the page; the price is at
  most a row fetched twice, which the merge drops by id. A listing that comes back after the list was
  read again from the top is dropped: it belongs to the list that read replaced (another filter,
  another order), and a page appended from it mixed rows of another filter into the new one. A page
  whose request fails lowers the loading flag, so History asks for it again (**`sessions/archiveMoves.ts`**,
  **`App.archiveSession.test.tsx`**).
- **An archived row is dimmed** (**`.session-item.is-archived`**): its title drops to 45% text and its
  tags to 60% opacity. Put aside and still in play differ by exactly that. The archive **mark**
  (**`.session-archived-mark`**) leads the row beside its state dot, where states
  belong - it is not a chip among the tags, which are labels the operator chose.
- **A running turn is a pulsing dot** (**`.session-activity-dot`**, **`sessionRowShowsActivity`**), not a
  spinner: the **unread dot** a finished background turn leaves behind (8px, violet) made **a third
  darker** (**`color-mix(in srgb, <unread colour> 67%, #000)`**) and pulsing between full and 30%
  opacity every 1.2s, held still under **`prefers-reduced-motion`**. The two marks are one family -
  *working* and *done, not read yet* - where a spinner beside them read as a third kind of state.
  **The open conversation carries it too**: it is the turn the reader is most likely waiting on, and a
  row without the mark reads as finished. Its row follows this tab's own view of the turn
  (**`generating`** in **`App.tsx`**) rather than the last listing, which is only refreshed on a poll.
  A row always reserves the same **24px** state-mark track: its 16px marker slot starts at the left edge,
  leaving the remaining space between marker and text. An idle session's transparent neutral
  **`.session-idle-dot`** ring keeps a finished row distinct from a group heading while the larger permission
  and question marks occupy the same track without moving its title or tags. A real failed
  turn carries **`lastErrorSeq`** in the session activity data: while its generation is newer than the
  read cursor it is a filled red **`.session-error-dot`**; opening that session advances the cursor and
  turns the mark into a red outline (**`.is-seen`**) until a later successful turn clears it. An unseen
  error wins over the generic violet unread-completion dot. A permission or question waiting on the
  reader owns the slot instead, so it renders no idle or error ring behind its own mark. A question marker stays still: the turn is waiting for a person rather than making progress.
- **The composer's slot on an archived conversation** is **`.archived-session-notice`**, cut from the
  same glass panel as the subagent notice beside it (**`--coddy-glass-panel-bg`** plus the backdrop
  filter) - it stands over the transcript, and a wash of the text colour is transparent on a dark
  canvas. A line of text, and one button that takes the conversation back out.
- **Row tags** (**`.session-row-tags`**) go **under** the title, not beside it: the title is what the row
  is for and must not be pushed out of view by labels. Chips are **10px**, pill-shaped, on a 6% text
  wash. **The first chip starts where the title's text starts**, never under the state marks: the link
  (**`.session-row-link`**) is a two-column grid, **`auto minmax(0, 1fr)`**, the marks
  (**`.session-row-marks`**, rendered for every row) hold the first column and keep their own 6px to
  the title, and the title line (**`.session-row-leading`**) and the tags share the second. Neither
  carries a nudge of its own; the idle ring makes that alignment stable in every grouping mode, covered
  by **`sessionRowTagsAlignCss.test.ts`**. The **archived badge** (**`.session-archived-badge`**) is the same size and wash but uppercase,
  and sits **inline after the title**, because it qualifies the title rather than the row.
- **A chat being named shimmers instead of showing its first message** (issue #435). From the first send
  of a new chat until its **`POST /coddy/describe`** settles (a name, an empty answer, a failure, or
  **`NAMING_PLACEHOLDER_TIMEOUT_MS`**, 30 seconds, in **`sessionTitleSuggest.ts`**, after which a late
  name is still applied but no longer promised), **`App.tsx`** holds the session
  in **`namingSessionIds`**: the chat header draws a bar where the title will be
  (**`.chat-title-skeleton`**, **`data-testid="chat-title-pending"`**, the button **`aria-busy`** and
  named *Naming the chat…* for screen readers) and the row draws one for the title
  (**`.session-title--pending`** / **`.session-title-skeleton`**) and two short ones where the tags will be
  (**`.session-row-tags--pending`** / **`.session-tag-skeleton`**). All of them are
  **`.naming-skeleton-bar`**: the settings skeleton's shimmer tinted from **`--text`**, so it reads on
  every theme, sized in **em** so it follows the type it stands in for, and still under
  **`prefers-reduced-motion`**. A first send of attachments alone has no text to name the chat by and
  puts up no placeholder. Never draw the first message there while the name is on its way: for a
  chat started with a command it is a **`/rpa-init`** token, and for a long prompt a sentence that is
  replaced a second later. The name and the tags of the same answer replace the bars in one frame.
- **One control per row** (**`.session-row-menu-trigger`**, a 26px **⋮**, 0.38 opacity until the row is
  hovered) opens **`.session-row-menu`**: **pin**, **rename** and **tags** - the three that change where
  the row sits or what it says about itself - then a hairline (**`.starts-group`**), then
  **archive** and **delete** together - both take the conversation out of the list, while pinning only
  moves it - with delete in the destructive colour. An icon per action cost the title a button's width each and made a
  mis-click a delete; inside the menu the actions have room for their words. The menu is portaled and
  placed from the trigger, and flips above the row near the foot of the window.
- **A pinned row carries no mark of its own**: it stands under the *Pinned* heading, which already says
  it, and a pushpin beside the title only took the title's room.
- **Rename edits the row in place** (**`.session-title-input`**, the box the chat header already uses):
  the name is **selected** when it opens, so typing replaces it and the box shows the beginning rather
  than the tail a caret at the end would scroll to. **Enter** and blur save, **Escape** leaves the title
  alone, and a name that did not move costs no request. The rename ends **once**: the key that ended
  it and the blur of the disappearing box both go through the same one-shot commit, so Escape cannot
  be followed by a blur that saves the discarded draft.
- **The tag editor** (**`.session-tag-editor`**, **`SessionTagEditor.tsx`**) is **one component for both
  places that show tags** - the row menu here and the **+** of the session table - because they are the
  same three gestures: drop one, type one, take one the history already uses. It is cut from the
  **tooltip** surface, like the row menu it opens from and for the same reason: it stands over the list.
  A chip carries its own cross (**`.session-tag-chip`** + **`.session-tag-chip-remove`**); the box
  (**`.session-tag-input`**) offers the labels this history already files under
  (**`.session-tag-suggest`**, most used first, the row's own excluded, prefix matches leading);
  **Enter** files what was typed, the arrow keys take over the list and **Enter** then files the
  highlighted one, **Backspace** on an empty box drops the last chip, **Escape** closes.
  **There is no Save**: every change is a **`PATCH`** at once. The list takes the new set **before**
  the request answers - the editor builds each gesture on the row it is shown, and a second gesture
  made while the first is in flight would otherwise start from the set before both - and the folded
  set the server answers with then replaces it, so a chip never changes spelling one refresh later.
  A refused write puts back what the row carried. The folded form of what is being
  typed is shown under the box (**`.session-tag-hint`**) **only when it differs** from what was typed -
  the arithmetic and the fold are **`tagEditing.ts`**, kept pure and a twin of `NormalizeTag` in Go.

### Session table: sorting, the archive and tags

- **The tags of a row end in a `+`** (**`.sessions-manager-tag-add`**, dashed, **visible at rest**): the
  chips themselves stay filters, as they were, and the **+** opens the same editor the row menu in
  History opens. A row you can file is worth a glance, and a finger has no hover to reveal it with.

- **Sortable column head** is a **`button`** inside the **`th`** (**`.sessions-manager-sort`**),
  carrying the caret (**▲** / **▼**, 0.6rem, 75% opacity) only while it is the sorted column; the
  **`th`** carries **`aria-sort`**. The header keeps the column's own typography - a sortable column
  must not look like a control bolted onto the table.
- **Toolbar order** is search, archive **`select`**, then the two icon actions: empty the **archive**
  (archive box), then delete the **ticked** rows (trash with a check). The destructive pair sits
  together at the trailing edge at every width.
- **Archived badge** (**`.sessions-manager-badge-archived`**) is the **neutral twin** of the accent
  **open** badge: a text-wash pill, because it says where a row sits, not that anything is wrong.
- **Tag chip** (**`.sessions-manager-tag`**) is a **button**: pressing one filters the table. An active
  tag filter is a muted line under the toolbar with an underlined accent link that clears it.
- **No per-row delete.** The row's only destructive surface is its tick; the scope of a delete is
  always what the operator can see ticked, or the archive scope named on its own button.

### Narrow-rail hover tooltips

- Shown **only** when the rail is **narrow** (no wide labels column). Labels visible in wide rail substitute for tooltips; do not show floating tip rows there.
- **Copy**: brand area **New Chat**, **History** nav control **History**, **Scheduler** nav control **Scheduler**, external links match their labels. External accent reference (from another product, colours only) in **`docs/assets/ref-navbar-narrow-tooltips-accent.png`**.
- **While a control owns an open overlay** (example **History** with the list visible and **`.is-active`** on the trigger), **hide that row's tooltip** even if the mouse still hovers (**nav stacking can sit above backdrop**). Same for **Scheduler** when its drawer is open.
- Tooltip **horizontal offset** must use the **same gutter math** as the History drawer (**column padding + nav floating gutter (+ border shim where needed)**), not a shorter offset from icon-only **`rail-tip-host`** width alone.

### Nav rail panel and wide layout (design contract)

- **Panel shape (desktop)**. The nav is a **tall rectangular column** along the **left viewport edge** with **rounding only on the right** (straight left edge flush with the browser). Avoid a centered **full-height capsule** that does not meet the edge.
- **Wide rail width**. Pill width is **content-driven** (**`fit-content`**) with a sensible **max-width** cap, not a legacy fixed pixel width guess.
- **Wide header brand**. **Coddy agent** is **one horizontal line** (**Coddy** + muted **agent**). Keep **breathing room to the right** of the label (**extra padding-right** on the brand control) so copy does not sit against the inner right edge of the panel.
- **Labeled rows (History, Scheduler)**. Rows **share the same width** within the column (stretch to the **widest** row). Each row is a **single interactive surface** (icon + label), not a small icon hit target plus detached text.
- **Icon column alignment**. The first grid track for row icons matches the **collapse** toggle footprint (**44px** wide control). **Horizontal padding** on row hits stays **balanced** (avoid oversized **padding-left** and cramped **padding-right** at the label end).
- **Collapse vs global menu**. The **stacked-lines** control **only** narrows the rail. It is **not** a global app navigation drawer (see Nav rail item 2 above).

### Active counts on the rail

- **Over the icon, in every shape of the rail.** The count of **History** and **Scheduler** (**`.rail-active-count`**, a **14px** accent pill) hangs off the top right corner of the item's icon (**`top: -11px; right: -11px`**), and the icon and its count are one wrapper (**`.rail-nav-icon`**, **`position: relative`**, centred in the wide rail's 44px icon track). That holds icon-only, labelled (wide) and in the rows of the phone's **More** menu. The count is **never** placed from the row: the wide rail's row is as wide as its label, and a count placed from it ran to the far end of the label; the More menu's stood after the text. Do not give a count a place of its own beside a label again. Held by **`nav/railActiveCountCss.test.ts`** and **`NavRail.test.tsx`**.
- **What each counts.** **History** counts the active turns of chats and of their subagents (**`active_count`** of **`GET /coddy/sessions`**); a **scheduler run** is the scheduler's work and is counted on **Scheduler** alone (**`runs_active`** of **`GET /coddy/scheduler/jobs`**): the listing does not descend into a scheduler job session, where the runs live as child sessions. The Scheduler's count is read when the page opens and again at every **`turn_started`** / **`turn_ended`**, whether or not the Scheduler is open, and only the latest read is kept, so a run that ended does not stay on the rail until a reload. Held by **`App.schedulerRailCount.test.tsx`** and **`TestCoddySessionsListLeavesSchedulerRunsOutOfActiveCount`**.

### Nav rail icons (implementation contract)

- **Collapse (hamburger glyph)**. Use **three equal-length** horizontal lines (**no** shorter third line). Prefer a **compact symmetric** **`viewBox`** (for example **20×20**), **round** line caps, and stroke weight that reads at **18px** output size.
- **Expand (narrow rail at XL)**. Keep a **chevron / chevron-pair** style control that reads as **widen rail**, not a second burger menu.
- **Rendering**. Small rail SVGs should opt into **`shape-rendering` tuned for crisp curves** (for example **`geometricPrecision`**) and **`flex-shrink: 0`** so flex layout does not squash glyphs.
- **Regression art**. Use hover captures under **`docs/assets/`** when checking outline and press states.

### Escape and the screens of the rail

Every item of the rail but **Sign out** and the environment (which opens a menu, **Environment item and menu**) opens a **screen** over the chat: **History**, **Scheduler**, **Swarm**, **Docs**, **Settings**. **Escape** closes the screen on top the way its **×** does - the same function, so the address goes back to what was under the screen and a reload does not bring it back. The swarm screen over a chat has no ×: Escape closes it as its backdrop does. On a relay the swarm map is the home screen, with nothing under it, and Escape leaves it.

- **One rule for every screen, a future one included.** The rule lives in **`nav/railEscape.ts`**; **`App.tsx`** calls **`useRailScreenEscape`** once, with a table of every screen: whether it is open and what closes it. The table is a **`Record<RailScreenId, RailScreen>`**, **`RailScreenId`** being every **`NavItemId`** of the rail but **`signOut`**, so a screen added to the rail does not pass the type check (**`tsc`**, run by **`make lint`**, the pre-commit hook and CI; **`vite build`** does not check types) until it has its entry, its place in the stacking order (**`RAIL_SCREEN_DEPTH`**, which only settles a tie: the rail keeps one screen open) and its row in the page map of **`App.railEscape.test.tsx`**, whose test then opens it and closes it with Escape. Do **not** give a screen an Escape handler of its own: while every screen had to remember one, History and the scheduler had theirs and Docs and Settings had none.
- **Escape undoes one step.** What is nearer the key answers first and **claims** it (**`preventDefault`**, or stops the event): an open menu (the ⋮ of a History row, the History filter menu, **More** on a phone, the composer's **Mode** / **Model** / **Reasoning** / **Permission** menus, the environment, folder and branch menus), a picker or the context popover of the composer, the folder browser (its new-folder row first), the chat title being edited, the **(i)** tip of a settings field, the open list of a settings combobox, the marketplace results under the Skills install search, the documentation search with text in it (Escape clears it; in an empty box the key goes on and the reader closes), the settings search with results showing or with a query in its field (Escape clears the query; the next one is the drawer's), a confirmation dialog, the image viewer. A screen takes only an Escape nothing claimed. A menu, a popover or a dialog answers Escape through **`useEscapeCloses(open, close)`** (**`components/useEscapeCloses.ts`**), whose **`close`** may take a step of the layer's own first; a control that answers only while it has the focus claims the key in its own **`onKeyDown`**. A screen with a step of its own takes that step before it closes - the step the arrow of its head takes: the scheduler's open job or its runs go back to the job list, an open row of Settings goes back to its list, a Settings section on the stacked shell goes back to the tiles. Settings has one step before those, and the head's arrow does not take it: with the **results of the settings search** showing, Escape clears the query wherever the focus is (the head then reads **Settings** with no arrow, and Telegram's Back, which dispatches an Escape at the focused element, takes the same step); it is the first step of the screen's chain, registered with **`useRailEscapeStep`** like the others. A step of state **`App`** owns is the table entry's **`close`** while it applies (the scheduler); a step of state the screen keeps is registered from the screen with **`useRailEscapeStep(<screen>, step)`**, **`null`** while there is none (Settings).
- **Where it listens, and why there.** One **`keydown`** listener on **`document`**, bubble phase. That is after the controls of the page (React answers at its root, below **`document`**) and after the tips, menus and dialogs that listen in the capture phase, and before the listeners on **`window`**, which belong to the chat under the screen: the question card's Escape skips its questions. A step taken is claimed and stopped, so an Escape that closed a screen never reaches the chat under it, and the question card also ignores an Escape something else claimed (a picker of the composer, which stays usable while the card waits). A tip, menu or popover that listens on **`document`** or **`window`** for Escape does it in the **capture** phase and calls **`preventDefault`** - which is what **`useEscapeCloses`** does, and what the History row and filter menus and **`ConfirmDialog`** do on **`window`**: in the bubble phase on **`document`** it would come after the screen, which has listened since before it opened, and both would close.
- **A hidden pane is deaf.** While the results of the settings search show on the wide drawer, the tab under them is hidden, not unmounted, and **`PaneHidden`** (**`components/PanePortal.tsx`**, a context the drawer sets to **`true`** around that tab) is how everything inside it knows. **`useEscapeCloses`** does not listen while it is **`true`**, so a tip, a tag editor or the Skills install results in the hidden tab claims no Escape and the key goes to the results step above instead; **`PanePortal`** draws nothing into **`<body>`** while it is **`true`**, so a portal of the hidden tab does not sit over the results. Build an Escape layer under Settings on **`useEscapeCloses`** and draw its portal through **`PanePortal`**: those two are the chokepoints, and a layer that listens or portals on its own is not covered (**Settings drawer**, *The hidden tab*).
- **What never closes a screen**: a held key (**`repeat`**: one press, one step), Escape with a modifier, and the Escape that ends an input method's composition (**`isComposing`**, or keyCode **229**).

Tests: **`nav/railEscape.test.tsx`** (the stacking order, the claim, a held, modified or composing key, the steps and whose they are, the chat under a screen), **`App.railEscape.test.tsx`** (every screen of the table through the real app, the scheduler's and Settings' steps, the search, a row menu of History, the backdrop and the relay's home), **`components/useEscapeCloses.test.tsx`** (the claim, a screen under the layer, a layer in a hidden pane, **`PanePortal`**), **`Settings.test.tsx`** (**`describe("settings search")`**: Escape's steps, Telegram's Back, each layer of a hidden tab), and in their own tests the claims of **`Combobox`**, **`FieldHint`**, **`NavRail`**, **`ContextBreakdownPopover`**, **`ChatHeader`**, **`QuestionPromptSection`**, **`SkillsSection`**, the composer's menus (**`ComposerModelMenu.test.tsx`**), **`EnvironmentSwitcher`**, **`WorkspaceChips`** and the folder browser, each against a screen that listens from before it opened where a screen can be under it (**`nav/railEscape.fakes.tsx`**). Scenarios: **`features/web_ui_rail_escape.feature`**.

Sessions search uses **`GET /coddy/sessions?q=...`** (**title or first persisted user message substring only**); list uses infinite scroll toward older pages.

Desktop navigation wider than Full HD optionally shows labels on the expanded rail (**cookie** remembers preference).

Sessions list interactions

- Session list supports open on row click.
- Session list shows a small trash icon on hover.
- Renaming is done only in the chat header.

## Components

### Repo links

Repo links appear as plain text links (**`GitHub | API docs | v1.2.36`**) in a fixed footer at the bottom of the **start screen** (hero / empty state only — not shown once a chat is active). GitHub (**`https://github.com/coddy-project/coddy-agent`**) and **API docs** (**`/docs/`**) both open in a **new tab** (`target="_blank" rel="noopener"`). The version of the server the page is talking to closes the line on the right, after the same **`|`** separator: **`GET /coddy/info`** through the environment, so on a remote or a node it is that server's; a release (**`X.Y.Z`**) reads **`vX.Y.Z`** and links to its release notes in a new tab, a build between releases is plain text, and without an answer the line keeps its two links. Implemented via **`chat/HeroFooter.tsx`** (**`.hero-footer`**, inside the `isEmpty` branch of `ChatScreen`) with `position: fixed; bottom: 16px` centered across the viewport; pinned by **`HeroFooter.test.tsx`**.

### Tool timeline

Captured via SSE (**`tool_call`**, **`tool_call_update`**). Rendered like **thinking** and **memory**: a **`thinking-row`** foldout with **chevron**, **tool name**, and **duration** (**`thinking-dur`** in the summary row alongside the label).

The expanded body uses the same tool-specific visual preview as the permission gate, followed by a separate **Result** panel when the tool returned output. It never repeats the approval actions. Most results stay raw monospace text. Known document and structured-result tools render their content in their own cards: `load_skill`, web search and fetch, the scheduler, and the structured cards below (every other built-in tool that answers in a known shape, and every tool of an MCP server). Terminal structured cards that fully express their result suppress redundant boilerplate: completed todo updates keep their saved plan snapshot, and a completed **`plan_exit`** card presents a theme-aware **Plan mode → Agent mode** transition with a semantic-success checkmark and the localized completion message instead of the raw `switched session to agent mode` string.

**A picture the call showed the model is seen, not folded.** A **`read`** of an image file hands the model the picture ([Images](docs/features/images.md)), and the row previews it under the summary, outside the **`details`**, so it shows with the row closed: **`.tool-images`** at the inset of the label (14px), 6px under the summary and with no margin of its own below, so the space after the pictures is the transcript's gap between rows (10px) as after any other row, one **`button.tool-image-card`** per picture with the size and the manner of a sent attachment card (128x96, radius 12px, the chip's border and fill, **`cursor: zoom-in`**), the thumbnail filling it with **`object-fit: cover`**, and a click opening the original in **`ImageLightbox`**. A picture without a thumbnail (a WebP, or one over 16 megapixels, which the server does not decode for a preview) fills its card with the original. The images load through **`useApiImageSrc`**, like an attachment's, so a remote environment or a relay shows them. Only a completed call has them; a failed **`read`** keeps its error and shows no card.

**The row names an action, not a function.** A tool id is translated through the **`tool.name.*`** dictionary entries (**`toolDisplayName`** in **`messages/toolDisplayName.ts`**): the English row reads **`running a command`** and the Russian one **`выполняю команду`**, both lowercase, both in the voice of the agent doing the work. An id with no entry - an MCP server's own tools - keeps its raw spelling rather than being flattened into something generic. **`read`** serves files and directories from one tool, so the directory wording (**`browsing a directory`** / **`просматриваю директорию`**) is used only when the arguments prove it: **`recursive`**, **`show_hidden`**, or a path ending in a separator. Beside the label, **`.tool-summary-target`** names the one thing the call acts on, through **`toolCallTargetText`**, and this row is the one place that names it - the live status line under the transcript never does. A **path** is spelt against the directory the work is happening in (**`relativeToolTarget`** in **`chat/toolTargetPath.ts`**, gated on **`toolCallTargetIsPath`**) whenever that is shorter. The roots are the session's own directory followed by the **worktrees** of its workspace (**`pathRoots`**, built in **`App.tsx`** from **`currentSessionCwd`** and **`workspaceCtx.worktrees`**), and the **deepest** root holding the file wins: work inside a worktree reads against **that worktree**, because the path to it is what every row of that work repeats. A command, a pattern, a url or a name is never rewritten, and the **`title`** attribute and the expanded card keep the path the call was given. **A read of part of a file names the lines** after the path, spelt the way a mention writes a range (**`toolCallTargetRange`**): **`path:120-180`** for **`offset`** 120 and **`limit`** 61 (the tool's offset is the first line, 1-based, and its limit a count of lines), **`path:1-40`** for a limit alone, **`path:240-`** for an offset that reads to the end of the file, and the path alone, as before, for the whole file and for a call whose offset or limit is not a whole number (the tool refuses it); one line is **`path:120-120`**, which a mention still reads as a range. Only **`read`** of a file takes it - a read the label already calls a directory listing (**`readsADirectory`**) has no lines, and the offset of **`coddy_docs_read`** or **`keep_result`** is not a line of a file. The target is then **`.tool-summary-target--ranged`**, an inline flex of **`.tool-summary-target-path`**, which takes the ellipsis, and **`.tool-summary-target-range`** (**`flex: none`**), so a long path never clips the lines away; the **`title`** is the full path with the range. **`toolCallTargetText`** stays the path alone, since the live status line reads it too. Label, target and duration share one line (**`.thinking-head`**), and the target is what gives way, clipped with an ellipsis and carrying the full value in its **`title`**. What trails the label - the target, the **(failed)** marker, the duration - is one group, **`.thinking-trail`**, and the head wraps (**`flex-wrap: wrap`**): the group stays on the label's line while that line has the room it needs and moves under the label as a whole when it does not. That room is the group's flex basis - **7em**, the duration and a readable start of the target, **11em** on a failed call (**`.thinking-trail--failed`**), which also carries the marker - capped at the group's own width (**`max-width: max-content`**), because the head is as wide as its content and a basis wider than a short group would wrap it under a label it fits beside; its minimum is zero, because the automatic one is the target's whole unwrapped text and would push the group under every label with a long command. The label never shrinks for the group, is at most the row's width and wraps inside it (**`max-width: 100%; overflow-wrap: anywhere`**) - an MCP tool names its server and its tool in a sentence, and the tool's name is one long identifier. The row used to be one unit that could not wrap, and that label made a phone's page scroll sideways; a first fix that let the target alone drop under the label left a one-pixel target on the label's line and the duration alone on the next (**`transcriptWrapCss.test.ts`**, live check **`npm run check:overflow`**, which fails on a target narrower than its first 24px and on a group split across lines). A backgrounded **`run_command`** says so in the label itself (**`running a command in the background`** / **`выполняю команду в фоне`**, key **`tool.name.run_command_background`**, chosen from **`background: true`** in complete arguments - a payload still streaming in must not rename the row halfway) and the duration slot carries the task's clock instead of the call's meaningless **`0ms`** (see **Background task on a transcript row**).

**Nothing to preview is not an empty object.** A call that takes no arguments renders the shared preview bar alone - the action label, the status mark the todo rows use, and **Done** / **Running…** / **Failed** / **Cancelled** as its meta - instead of a code panel containing **`{}`**. It is the same chrome as the plan cards minus their list, so the plan tools and the no-argument calls read as one family.

**A shell command is input, not body text.** **`run_command`** / **`ssh_run_command`** use the **`shell`** preview kind: the command sits in an inset rounded block inside the card behind an accent **`$`** prompt, and the control that copies it sits on that same line, in the block's last column. The card header above it never carries a second copy control. For a local call that header is the interpreter the server actually runs (**`/usr/bin/bash`**, **`cmd.exe`**), published as **`shell`** on **`GET /coddy/workspace/context`** and held in the process-wide **`chat/hostShell.ts`** store the way the locale is held; a server that reports none, and a remote **`ssh_run_command`** whose interpreter lives on somebody else's host, keep the generic label.

**Output carries no label, and a failure is said once.** The returned body attaches directly under the call, with no **Result** header and no divider - the preview viewport drops its closing edge when a result follows, so the two panels are one surface: it is the only thing there, and naming it was chrome. One surface means **one** gap at the seam: a command block already carries its own **10px** inset inside the card, so the output under it drops its top padding (**`.coddy-tool-call-body--connected-result > .permission-preview:has(.permission-preview-shell) + .tool-call-result-card .tool-call-result-content`**); previews whose body runs edge to edge, a diff or a code panel, keep theirs. A call that **failed** says so on its summary row instead - **`.tool-failed-marker`** renders **(failed)** / **(ошибка)** in the theme's deletion red, between the target and the duration - so the failure is readable while the row is still collapsed.

**`load_skill`** returns Markdown. The summary row already names the skill, so the call renders no argument card; the body a **completed** call returns is a skill's markdown and renders through the Markdown component (**`.tool-call-result-content--markdown`**) in the prose font rather than the panel's monospace stack, with list indents tightened for the narrower card. A **failed** call returned an error rather than a skill and keeps the raw monospace panel.

**A web search is read as its hits, all of them.** **`websearch`** renders its answer as a Markdown list of links (**`webSearchResultMarkdown`** in **`chat/webToolResults.ts`**), each snippet on its own line under its link (a hard break, not a run-on after it), closed by the tool's **`hint`**. The row's preview is the first 19 lines of the output and the engine report that leads the answer fills them - the cut usually lands on **`"results": [`** with not one hit whole, which is how the row came to print raw JSON behind a **More…** control. So **opening the row is the request for the results**: the first **`toggle`** to open fetches the saved answer once (**`GET /coddy/sessions/{id}/tool-calls/{toolCallId}`**), a muted **Loading…** line (**`.tool-result-loading`**) stands in meanwhile, and the whole list renders at its natural height with **no More / Less** and no capped viewport. A closed row costs no request; only a failed fetch brings back the ordinary **More…** to try again. The **header carries every parameter of the search** - the query, **`page N`** and **`max N`** with the tool's defaults filled in (page 1, 15 rows, capped at 25) and **`site …`** when one was named - and the first line of the body (**`.web-search-engines`**, **`webSearchReport`**) says how each engine answered: **`bing: 10`** for one that answered, **`brave: blocked (http 429)`** in the failure colour for one that did not. The report is read out of the preview as well as the full answer, since it is whole even where no hit is.

**A scheduler call is read as what happened to which job.** The **`coddy_scheduler_*`** tools take a **`job_id`** and answer with a line of JSON, and the row printed both as they were - **`{"job_id": "ai-news-digest"}`** over **`{"job_id":"ai-news-digest","paused":false}`**. They render **`SchedulerToolCard`** (**`messages/SchedulerToolCard.tsx`**, model in **`chat/schedulerToolDisplay.ts`**) instead of the argument preview and the result panel: the preview bar with the plan rows' status mark, the job id (**`old → new`** for a rename) and, as its meta, **what happened** - **`resumed`** / **`снято с паузы`**, **`run started`** / **`запуск принят`**, **`was not running`** for a Stop of an idle job, **`created`**, **`updated`**, **`deleted`**, **`paused`**, **`replaced`**. A create, replace or patch lists under the bar the fields it set; **`job_get`** lists the job - description, the cron with its reading (**`0 8 * * * · At 08:00, every day`**), state, next run (UTC), mode, model, folder - and its instruction as Markdown in an inset panel; **`jobs_list`** is one row per job (id, description, schedule, state) with the count as meta; **`job_runs`** one row per run (local start, status, duration). The reading tools fetch the whole answer when the row opens, as a search does. The row names the job beside the label (**`toolCallTargetText`** reads **`job_id`** for every scheduler tool). A failed call keeps its error as raw text. **Every scheduler tool has a **`tool.name.*`** entry in every dictionary** - **`coddy_scheduler_job_resume`** shipped without one and the row read the raw id; **`TestEverySchedulerToolIsNamedInTheWebUI`** (**`external/scheduler/tools/names_ui_test.go`**) now holds the registry to the dictionaries.

**A structured card reads the tool's own answer.** **`switch_model`**, **`http_request`**, the **`background_*`** tools, **`preview_server`**, **`coddy_docs_search`** / **`coddy_docs_read`**, **`plan_list`** / **`plan_read`** / **`plan_write`**, **`session_describe`**, the **`config_*`** tools, **`keep_result`**, **`compact_context`**, the **`coddy_memory_*`** tools and every tool an MCP server serves (**`server__tool`**) render **`StructuredToolCard`** (**`messages/StructuredToolCard.tsx`**) instead of the argument preview and the raw result panel. The Go tools write their answers for the model; **`chat/structuredToolDisplay.ts`** parses that text into small views, and its tests hold each parser to the exact text the Go formatter produces - a card tested on a format no tool writes passes and then prints raw text in every real session. Anything a parser does not recognise is shown as the text it is, inside the same card, so a change of format on either side loses nothing. The card is the scheduler card's frame: the bar (**`.permission-preview-bar`**) names what the call is about, the body (**`.scheduler-tool-body`**) holds labelled fields (**`.scheduler-tool-fields`**) and rows (**`.scheduler-tool-rows`**). Text the tool returned as it was - a log, a response body, a nested JSON value - is **`.structured-tool-output`**, the monospace voice of the raw panel, never the card's prose font; Markdown it returned (a plan, a documentation section, a memory note) is **`.structured-tool-document`**, an inset in the prose font. What was asked and what came back are set apart by a rule (**`.structured-tool-answer`**) because both can be the same list of fields. A body with nothing in it is not drawn, so a call still waiting for its answer is the bar alone. A **failed** or **cancelled** call, and one whose arguments do not parse, keep the generic panels, where the error reads as written. A truncated answer caps the card's **body** (**`bodyClassName`** / **`bodyRef`** carry the **`tool-result-viewport`** classes), so the bar stays in view while the body scrolls.

- **`http_request`** - the bar is the method and the address the request really fetches: the **`query`** object appended the way the tool appends it (names sorted), the password of a userinfo hidden. Under it: the rationale, the request headers with credential-bearing ones (**`authorization`**, **`cookie`**, **`token`**, **`secret`**, **`password`**, **`api-key`**) shown as **`•••`** and an emptied one as removed, the kind of body with its file, its form fields (a credential-named value hidden) or its byte count, then the JSON (indented) or text payload itself in monospace, the proxy with its credentials hidden, **not verified** in the failure colour (**`.structured-tool-warning`**) when **`verify_tls`** is false, followed redirects, the timeout and **`output_file`**. Everything that changes where the request goes or what it trusts is on the card: the generic preview showed all of it as JSON, and a card that drops a skipped certificate check is a card that hides it. The response - status, headers masked the same way, the body in monospace, the tool's bracketed notes (**`followed redirects: …`**, **`body: saved …`**) as muted lines - sits under the rule.
- **`switch_model`** - the model, the reasoning level and how long it applies, read from the answer (what took effect), not from the arguments (what was asked); the sentence written for the model is not repeated.
- **Background tasks** - one row per task: id, the status through the Tasks panel's own labels (**`taskStatusLabel`**, so it is translated), label, address, the elapsed / estimate / exit detail and the pool's note, muted. **`background_output`** / **`background_wait`** add the output in monospace and say when it is still running or its start was dropped; **`background_reap`** lists what it killed by pid. **`preview_server`** is the address as a link (http and https only), the task and when the server stops; the lines telling the model to invite the user are not shown.
- **Documentation** - a search is one row per hit, the title a link into the reader (**`docsHrefFromCoddyLink`**, never Markdown built from the title: a title is text); a read is the section as a document, a link to it in the reader and its line range, with **Continues at line N** when the tool paged it.
- **Plans, session filing, configuration, memory** - a plan read shows its name and overview above the body (a preview cut inside a long frontmatter stays monospace text until **More…**), a plan write the plan it wrote (the plan card in the chat has the latest version, the transcript keeps each one), a plan list one row per plan with its update time; **`session_describe`** the title, the tags as chips and what changed; a **`config_*`** answer its JSON as fields, with the values the server redacted, in place of the arguments it repeats; a memory note as a document when it is a **`.md`** file or has Markdown's structure and as monospace text otherwise, a memory search one row per hit.
- **MCP** - the bar is the server, muted, and the tool as its server spells it (**`.structured-tool-mcp-server`**, **`.structured-tool-mcp-tool`**). The arguments are fields: text as text, numbers and booleans in monospace, a short list of scalars as chips, anything nested as indented JSON. The answer is read by its shape (**`resultNode`**): a JSON object as the same fields, other JSON indented, text with Markdown's own structure (a heading, a fence, a list - **`looksLikeMarkdown`**) as a document, anything else as monospace text. MCP servers answer the model in Markdown or JSON far more often than in plain text, and neither reads as its source. **Every value is the text the server wrote.** **`JSON.parse`** only validates: **`chat/jsonSource.ts`** reads the document into nodes that carry their own slice of it (**`parseJsonSource`**), a number, a boolean and nested JSON are shown as that slice and nested or top-level JSON is re-indented as text (**`indentJson`**, the layout of **`JSON.stringify(value, null, 2)`**, a document past 1 MiB shown as it came). Printed back from a parsed value, an id past 2^53 - a Discord or Twitter snowflake - came out rounded (**`12345678901234567891`** as **`12345678901234567000`**), an escape came out decoded and of two equal keys only the last survived; now a repeated key is a row per value and the JSON panels keep **`\u00e9`** as written. A text row shows the string its literal encodes. The arguments of every card that lists them as fields, and the JSON body of **`http_request`**, are read the same way.

**`question` is the record of an offer.** The row names the act (**`asking`** / **`спрашиваю`**, key **`messages.toolQuestionLabel`**) and carries no duration - nothing was running while the reader read it. Its body is the offer as the model made it: every question, each option behind the letter it was offered under (**`.question-tool-offer-row`**, the letter in the same **`.question-prompt-bubble`** the live card uses), and after them the free-answer slot unless the question set **`custom`** to **`false`** - carrying the words the reader typed into it, so an answer is read in the slot it was given in rather than named twice. The flag is on by default (issue #257), so arguments that do not name it get the slot, the record of an older question among them, empty. The letters the reader took are marked in the accent, each answer claiming one option. A line closes the block only when the letters cannot say it: nothing answered yet, or an answer with no slot of its own. This is the **only** place the offer survives - the answered card below the row keeps just the question and the answer - so an option the reader did not take is readable here and nowhere else.

**Copy controls are icons.** Every control that used to spell out **Copy** - the markdown code-block button, the tool preview and permission preview controls - is the clipboard glyph on the shared **`.md-copy`** chrome, with the word it replaced kept as the native **`title`** tooltip and the accessible name.

- Tool arguments arrive via `tool_call_update` status `in_progress` where `content[0].content.text` is raw JSON args.
- Tool result arrives via `tool_call_update` status `completed` or `failed` where `content[0].content.text` matches the HTTP user preview rules (**raw** text, **no Markdown**): the first **19** content lines, then a twentieth row that is only **`...`**, when the output is longer; **`_meta.coddy.toolResultPreview`** marks truncation. Outputs that are not truncated skip the fixed-height viewport and overflow toggle (natural-height grey mono panel). When truncated, the fixed-height panel shows the clipped preview with **no vertical scrollbar**. The shared **More…** button performs **GET `/coddy/sessions/{id}/tool-calls/{toolCallId}`** once, fills the same panel with the full saved body at the same max height, enables **overflow-y** scrolling, and turns into **Less**. **Less** restores the clipped preview without another request while the full text stays in memory for this session.

#### Tool card UI (bundled SPA, current)

Implementation lives in **`external/ui/src/ui/messages/ToolCallMessage.tsx`**.

- **Layout** - Outer **`thinking-row coddy-tool-call-row`**; **`details`** uses **`thinking-details coddy-tool-details`**. **`summary.thinking-summary`** with **`thinking-left`** ( **`aria-label="Tool summary"`** ): **`thinking-chevron`**, then the **`thinking-head`** (it wraps when the label leaves the group too little of its line) holding **`thinking-label`** (the catalogued action name, **`...`** suffix while **`pending`** / **`in_progress`** ), the optional **`tool-summary-target`**, and the **`tool-failed-marker`** a failed call adds, and **`thinking-dur`** - the last three inside **`thinking-trail`** (**finished** durations from **`meta.json`**, **live elapsed** while in flight when **`startedAtMs`** is set, placeholder **`-`** when unknown). Transcript stacking uses **`messages-inner` `gap`** like other **`thinking-row`** blocks (avoid tool-only asymmetric margins). Expanded **`thinking-body coddy-tool-call-body`** is transparent and contains the shared **`PermissionToolPreview`** without nested copy or approval actions, or the **`StructuredToolCard`** of a tool that has one (see **A structured card reads the tool's own answer**). A generic preview drops its header bar when the arguments name nothing for it. Generic returned output follows in **`tool-call-result-card`** as a bare **`pre.tool-result-pre`** body, joined to the preview above it without a header or a divider.
- **Duration format** - Every step duration on a transcript row - a tool call, a reasoning block, a memory pass, live or finished - goes through one formatter, **`formatStepDuration`** (**`messages/formatStepDuration.ts`**): **milliseconds only below three seconds** (**`846ms`**, **`2999ms`**), whole **seconds** up to a minute (**`32s`**), **minutes and two-digit seconds** up to an hour (**`1m 42s`**), **hours and two-digit minutes** beyond (**`1h 05m`**). A count like **`32749ms`** asks the reader to divide by a thousand and **`1.7m`** to multiply a fraction by sixty; neither is a reading. The live status line and the background task clock count whole seconds of their own and keep their formatters.
- **Viewport** - Truncated results ( **`resultWasTruncated`** from list / SSE) add **`tool-result-viewport tool-result-viewport--tall`**, clipped with **`tool-result-viewport--clip`** (capped at ~20 lines, **overflow-y** hidden) or scrollable with **`tool-result-viewport--scroll`** after **More…**. Large **`write`** / **`write_file`** code previews and **`apply_patch`** / **`edit`** diffs use the shared measured **`permission-preview-viewport`** cap (**190px**, **170px** on phones): the toggle appears only when the rendered body overflows, expansion scrolls inside the tool card, and **Less** returns the viewport to the top. Every other preview - a command block, a short argument card - is **`permission-preview-viewport--static`**: it has no toggle, so it is never capped, on a phone either. The phone rule is written **`.permission-preview-viewport:not(.permission-preview-viewport--static)`** because it comes after the modifier at the same specificity; written plainly it capped a long wrapped command at 170px, and with the overflow visible the command spilled over the output under it (**`permissionPreviewViewportCss.test.ts`**). Short results and previews follow content naturally without a fake tall box or toggle row.
- **Controls** - Result **More…** (**`data-testid="tool-result-more"`**) / **Less** (**`data-testid="tool-result-less"`**) and preview overflow controls reuse the shared left-aligned **`tool-overflow-toggle`** tab button, attached flush to the panel's bottom border. Phone layouts increase the button's minimum height to **36px** for a more comfortable touch target.
- **Full body** - The SPA obtains saved full strings only via **GET `/coddy/sessions/{sessionId}/tool-calls/{toolCallId}`**. **`App.tsx`** wires **`onFetchToolCallFull`** to that endpoint and merges JSON **`result`** into **`fullResultText`** plus full **`args`** into **`argsText`**. Restored **`apply_patch`**, **`write`** / **`write_file`**, and **`edit`** cards auto-fetch in any status when the 200-character list **`argsPreview`** is incomplete JSON, so their structured previews match live SSE cards after reload instead of rendering an empty **`+0 −0`** header. Reconciles via **`loadMessages`** keep already-complete args (**`pickRicherToolArgs`** in **`toolCallArgs.ts`**) instead of re-truncating them, and complete args re-arm the one-shot fetch so a later truncation recovers again. The preview overflow toggle re-measures on the enclosing **`details`** **`toggle`** event (collapsed foldouts measure zero while hidden).

- **Spawn agent arguments** - `spawn_agent` uses `SpawnAgentCard` inside the existing disclosure body instead of JSON when `agent` and `prompt` are available. The header pairs a small outlined robot icon in an accent-tinted tile with the agent name and optional description. A clock badge on the right labels the supplied positive `timeout_seconds` as `Timeout Ns`; it is separate from elapsed execution time in the disclosure summary and wraps below the identity on narrow widths. The prompt occupies an inset rounded panel, preserving line breaks and wrapping long words as plain text. Both themes use semantic foreground and surface tokens. Results and their More / Less controls retain the standard tool behavior, with the result visually attached below the agent card. Truncated history arguments are fetched once per incomplete preview, including running calls; a later truncated reconciliation can fetch again. Unavailable or malformed arguments keep the readable argument fallback. Labels follow the active English/Russian UI locale.

Tool call history is persisted per session under `tool_calls/` so it can be restored after restart.

### Tool permission gate

**PermissionPromptSection** renders only for tool calls that actually require approval. Read-only calls do not get a placeholder card, status checkmark, or “runs without a permission prompt” message.

- The header contains one short action question and one technical tool-id badge. Do not repeat the raw tool name in the question or preview header.

### Question card

A waiting **`question_prompt`** (**`QuestionPromptSection.tsx`**) lists every question's options behind their letters and, after them, a row for an answer of the reader's own (**`.question-prompt-row--other`**, an input with the placeholder **Other…** / **Другое…**, testids **`question-other-<n>`** and **`question-other-multi-<n>`**), on single and multiple choice alike. The row follows the question's **`custom`** flag, which is on by default (issue #257): the options a model puts up often miss what the reader means, so only a question that sets **`custom`** to **`false`** - a confirmation whose answer must be one of its options - lists the options alone (**`offersOwnAnswer`** in **`chat/questionTypes.ts`**). A payload without the key offers the row, so a node of an older version, which leaves the key out for a question that never mentioned it, gets it too; the server reads a missing key as true and always writes it (**`acp.QuestionPrompt`**). In a multiple-choice question the typed text joins the options ticked beside it. The card's row (**`.message-row-question`**) is flush with the transcript column on both sides, like **`.message-row-permission`**: on a phone the card's edges meet the composer's under it, and the row chevron above stays in its own band. A padding on one side only set the card in from the left edge and not the right (**`questionPromptRowCss.test.ts`**). Tests: **`QuestionPromptSection.test.tsx`**, **`features/web_ui_question_own_answer.feature`**.

### Answered question card

An answered **`question_prompt`** (**`QuestionPromptSection.tsx`**, resolved branch) is a record of one exchange and states it once: the **Questions** head, the question, and under it what the reader said (**`.question-prompt-answered`**, reusing **`.question-prompt-resolved-q`** / **`-a`**). It is **not** a foldout - it carried a summary line on its head and the same two lines again inside a **`<details>`**, which put the same exchange on screen twice behind a control that revealed nothing new, three times counting the tool row above it. The options are not repeated here either; the tool row keeps the offer.
- Buttons keep the backend option labels and order: **Allow**, **Allow always**, optional **Always allow `<program>`**, **Reject**.
- The **Always allow `<program>`** option appears **only** for **run_command**, and only when the command is a single plain invocation (**`internal/permission.ProgramGrant`** refuses anything carrying shell metacharacters or a leading **`VAR=`** assignment). Its label names the exact allowlist entry that will be stored — **`curl`** for a bare program, **`git status`** for a multiplexer — so the operator approves the string that is actually saved. **Allow always** keeps its narrow exact-command meaning; the wider grant is never implied.
- Four buttons must still wrap cleanly at phone width: the row wraps rather than shrinking any button below its touch target.
- The preview uses the matching transcript **tool_call.argsText** when available; this preserves structured arguments when the ACP permission rationale contains only prose.
- **apply_patch** shows a color diff with old/new gutters, additions, deletions, hunk context, and the affected path. **edit** derives the same diff treatment from **oldString** and **newString**, retaining up to two unchanged lines on either side when present.
- **run_command**, **write**, **mkdir**, **touch**, **mv**, **rm**, and **rmdir** use compact operation-specific previews instead of raw **Arguments:** JSON. Unknown permission tools fall back to a monospace body, and a tool with no arguments to a bar naming the action and its state.
- The copy control is the clipboard icon (**`.md-copy`**, tooltip **Copy**). For a shell command it lives inside the command block next to the **`$`** prompt (**`data-testid="permission-prompt-copy"`**), not in the card header.
- Preview bodies have a collapsed height cap. **More…** appears only when DOM measurement confirms overflow; it keeps the same bounded viewport, enables internal vertical scrolling, and changes to **Less**. The shared tab button is left-aligned under the viewport and gets a taller touch target on phones. Short content has no toggle.

- **A background subagent's prompt** (**`SubagentPermissionCards`** in **`chat/SubagentPermissionCard.tsx`**) uses this same card once the turn that spawned the subagent has ended and no stream is left to carry the prompt. **`ChatScreen`** renders one per waiting task of the session at the **end of the transcript column**, after the last message, oldest question first: a running agent row with a usable **`pending_permission`** (**`isAwaitingPermission`**: the child **`sessionId`** and the **`toolCall.toolCallId`** present). The head names the subagent (*The subagent "writer" asks for permission*) in place of the action question; the preview is built by **`buildPermissionToolPreview`** after **`stripSubagentTitlePrefix`** drops the relay's **`[subagent <name>]`** prefix; the buttons are the offered options (never an "always" one). The answer goes to the **child** session through **`submitPermissionChoice`** (**`chat/permissionSubmit.ts`**, shared with **`PermissionPromptSection`**) and the task rows are re-read, as they are on every **`subagent_permission`** server event. Adjacent prompts use the transcript's **10px** gap. A newly waiting call follows the reader only while the chat is pinned to the bottom; polling an unchanged prompt and removing a settled prompt do not force a scroll. Reference capture: **`docs/assets/subagents/chat-subagent-permission-dark-1280.png`**.

Implementation: **external/ui/src/ui/chat/PermissionPromptSection.tsx**, **PermissionPromptPreview.tsx**, and **permissionToolPreview.ts**.

### Shared file cards

A file the agent shared with **`share_file`** is a card (**`ArtifactCard`** in **`messages/ToolArtifactCards.tsx`**): under its tool row (**`.tool-artifacts`**) and inline in the answer that names it (**`.inline-artifacts`**), one card per file in a wrapping row with an **8px** gap.

- **Size.** **134x140px** with **12px** padding; on a phone (**`max-width: 599px`**) **`min(118px, calc(50vw - 28px))`** wide and **132px** tall, so two cards share a row. The file name is clamped to two lines at the foot of the card, the size (or **Download unavailable**) under it.
- **Badge and trigger, one set of numbers.** The extension badge (**`.inline-artifact-extension`**) sits in the top left corner, the actions trigger (**`.inline-artifact-menu-trigger`**) in the top right. Both are placed from the card's custom properties **`--artifact-badge-inset`** (10px), **`--artifact-badge-size`** (28px) and **`--artifact-trigger-size`** (28px, **32px** on a phone for the finger): the trigger's **`top`** and **`right`** are the badge's inset plus half the difference of the two sizes, so the trigger is level with the badge and mirrors it across the card at any size. A trigger with a **`top`** and a **`right`** of its own sat 6px above the badge and 7px nearer its edge.
- **The dots are an SVG.** Three circles centred in a 24 viewBox, in a grid box that centres them; never the text glyph **`⋮`**, whose ink sat wherever the platform's font put it (the chevron's history, *Chevron*).
- **Visibility.** The trigger is transparent until the card is under the pointer or holds the focus, while its menu is open, and always on a phone or any screen with **`hover: none`**, where nothing would ever show it.
- **The menu is portalled.** The card is **`overflow: hidden`** - it clips a thumbnail to its corners - and a menu inside it was cut off, so a click on the trigger drew nothing. The menu (**`.inline-artifact-menu`**, **`role="menu"`**) is rendered into **`document.body`**, **`position: fixed`**, and placed from the trigger's rectangle before the first paint by **`placeArtifactMenu`**: 6px under the trigger with their right edges together, above it when the window's foot leaves no room below, and never closer than 8px to an edge of the window - a card at a phone's left edge does not push it off the screen, and a window too short for either side holds it as low as it can. It is never taller than the window less 16px and scrolls its items past that (**`max-height: calc(100dvh - 16px)`**), so no item is ever out of reach. It follows the trigger while the page scrolls and closes once the trigger cannot be seen: out of the window, or under another layer at its centre (**`triggerShows`**, a hit test) - the transcript scrolls under the chat's sticky title and the docked composer without leaving the window, and a menu that only watched the window's edges stayed open over the composer (checked on every scroll and resize; when it closes this way with the focus inside, the focus goes back to the trigger); a second press of the trigger, a press anywhere else, **Escape** (through **`useEscapeCloses`**, the focus handed back to the trigger) and picking an item close it. It takes the focus on its first item that can be picked; the arrow keys, **Home** and **End** move between those, and **Tab** closes it and hands the focus to the trigger without stopping the key, so the focus moves on from the trigger's place in the page (**Shift+Tab** back from it), as the WAI-ARIA menu button does. A disabled item is dimmed. A right click on the card opens the same menu.
- **Failures.** A download that fails marks the card **Download unavailable** and disables Download: the copy is gone. A reveal that fails says **Reveal failed** in the same line and leaves Download as it was, since the session's copy is still there; the next reveal clears it.
- **Spacing.** The inline row stands **12px** under the paragraph before it and **16px** over the paragraph after it. A row that ends the answer (**`:last-child`**, or followed by **`.msg-assistant-foot`**) has no bottom margin: the foot's own **8px** is the gap, as under a paragraph; the two margins together set the foot twice as far from the cards.

Covered by **`artifactCubeCss.test.ts`** (the numbers, the shared properties, the fixed menu and its height, the spacing) and **`ToolArtifacts.test.tsx`** (portal, focus, keys, toggle, scroll, a covered trigger, placement); **`external/ui/scripts/artifact-card-check.mjs`** (**`npm run check:artifacts`**) measures the result in a real engine on desktops and emulated phones (**`docs/surfaces/web-ui.md`**, *Checking the shared file cards*).

### Transcript message types (technical)

Assistant messages keep the footer row **inside** the same padded box as the prose so the copy control shares the transcript inset. Only the answer that **closes a turn** has that row at all, so the answers a turn leaves behind between tool calls are prose alone rather than a column of identical copy buttons and repeated minutes. Every finished turn keeps its own row - an older answer stays copyable - and only the turn still running has none, because its last answer is not yet the answer. **User** copy and time sit **below** the grey bubble (outside the bubble contour), still **bottom-right** under that bubble. That row (**`.msg-user-foot`**) is inset from the bubble's right edge by the message card's horizontal padding (**`14px`** of **`padding-right`**, **`border-box`**), the mirror of the inset under assistant and system rows, so the three action rows keep one margin from their bubble edge; covered by **`userFootCss.test.ts`**. **Copy message** is a **bare icon** (no filled tile on hover); hover uses **link-violet** tint like markdown anchors; the browser **`title`** tooltip shows **Copy message** after a short hover like other native hints. Raw persisted text is copied on click. Timestamps use **`created_at`** (RFC3339 UTC): visible label is **local hour and minutes** only; hovering shows full calendar date, seconds, and timezone offset in the native **`title`** tooltip. Assistant prose uses the full transcript column width.

The transcript UI is a flat list of message blocks (no nested threads). The runtime list lives in `external/ui/src/ui/chat/types.ts`.

Current block types:

- `user_message`
  - Raw user input text (**plain**, **`pre-wrap`**; no Markdown or transcript skill chips).
- `background_wake`
  - The first row of a turn a finished background task started. It renders as nothing (**Woken turn** above) and never as a user bubble, but opens the turn.
- `goal_turn`
  - The first row of a turn the session supervisor started for the goal: a kickoff, a continuation, a recovery, a resume or the wrap-up. It renders as a compact goal row (**Session goal** below), never as a user bubble, and opens the turn.
- `thinking`
  - Streaming model reasoning deltas (`delta.reasoning_content`, whichever field the provider sent the reasoning in) rendered as a disclosure row.
  - `thinking...` while in progress, `thinking` when completed.
  - **The body is rendered only while the row is open** (**`ThinkingMessage.tsx`**, **`onToggle`**). Every reasoning delta changes the content, and a closed row parsed its whole Markdown again for each one: on a long block that is steady work for the page with nothing on screen to show for it, on a slower machine enough to hold the transcript back until the block ends and then land it whole.
  - **A length nobody measured is not shown.** A completed block without **`durationMs`** - a model on **`stream: false`** that delivers reasoning and answer in one flush, or history persisted without **`reasoning_duration_ms`** - renders no **`.thinking-dur`** at all. It used to print a dash, which read as a failure, and then **`0ms`**, which read as a clock that had stopped.
  - **Summary row layout** - elapsed time stays immediately beside the **thinking** word, not pushed to the far right of the chat column. In **`ThinkingMessage.tsx`**, **`.thinking-dur`** nests inside **`.thinking-left`**; **`external/ui/src/styles.css`** sets **`.thinking-left { gap: 0 5px; }`** between the label and the timer. Do not use **`justify-content: space-between`** on **`summary.thinking-summary`** for that spacing. Avoid a wide summary flex that sends the timer to the opposite edge of the transcript.
  - **What the reasoning cost follows the duration** (**`.thinking-tokens`**, inside **`.thinking-left`**, the type of **`.thinking-dur`**, apart from it by the row's gap alone, no separator: `4s 312 tokens`): the short count the live status line uses (**`status.turnTokens`**, **`formatTurnTokens`**), the exact number in its **`title`**. Live it comes from **`reasoningTokens`** of the **`token_usage`** event that ends the model call, put on the reasoning row that call wrote and on no other (**`consumeComposerSse.ts`**); after a reload from **`reasoning_tokens`** of the assistant message. A provider that reports none (Anthropic, Devin) leaves the row without a count, never a zero.
  - **Reasoning the provider kept to itself** (a message with **`reasoning_tokens`** and no **`reasoning`**) is a row with its count and no text, built from the history (**`transcriptFromMessages.ts`**), so it appears whenever the transcript is read from the server: when the turn ends, or on a reload in the middle of it, where **`mergeTranscriptPreferLocalSuffix`** leaves such a server-only row out of the comparison and keeps the live tail after it; opened, its body says the provider did not return the text (**`.thinking-body--hidden`**). Such a row parts a continued answer the way a reasoning row with text does, and two of them in a row are never collapsed into one (**`dedupeAdjacentDuplicateThinkingCompleted`**).
  - Multiple `thinking` blocks can appear in one user turn. If the model resumes reasoning after tool calls, the UI starts a new `thinking` block and preserves ordering.
- `tool_call`
  - Tool execution timeline block (SSE `tool_call` and `tool_call_update`, enriched from `/coddy/sessions/{id}/tool-calls`).
  - Summary row matches **thinking** (**chevron**, **tool name**, **duration** beside the label). Expanded details reuse the permission card's structured tool preview without approval controls: diffs keep colored old/new gutters; filesystem and shell calls show paths, operation metadata, or commands instead of raw args JSON; **read**, **grep**, **glob**, and **print_tree** use the same compact language. Unknown tools retain a styled monospace fallback. Results are **raw plain text** in the muted **Result** section (**no Markdown**); when both preview and result exist, their touching borders and shared outer corners form one continuous execution card. When **`resultWasTruncated`** is false (output fits the preview cap), the result block grows with content only (no fixed tall viewport or overflow toggle). When truncated, the capped viewport and shared **More…** / **Less** button match the tool timeline above (REST fetch only on the first **More…**).
  - Duration label is computed from persisted `tool_calls/<id>/meta.json` `startedAt` and `finishedAt` when available, with live **`startedAtMs`** updates while **`in_progress`**.
- `assistant_message`
  - Assistant prose for a turn, split into **one or more** bubbles (a new bubble opens whenever text resumes after a `tool_call` / `thinking` — see **Ordering rules** below). Each is reconciled from **`GET /coddy/sessions/{id}/messages`** when streaming ends or after a refetch. After **Stop** mid-stream, that **`GET`** can lag the partial row already on screen; **`mergeTranscriptPreferLocalSuffix`** (see **Multi-session streaming and Stop** above) preserves visible text until the server catches up.

- `system_notice`
  - A UI-only row from the session's `uiLog` (never sent to the model), rendered by **`SystemNoticeMessage`** with the uppercase **SYSTEM** label, a monospace `pre-wrap` body, the copy control and the timestamp. The action row (**`.msg-system-foot`**) sits below the bordered card and is inset by the card's horizontal padding (**`14px`**), so its copy control starts at the same x as the one under an assistant row; covered by **`systemNoticeFootCss.test.ts`**. Two levels: **`error`** (a failed request or turn; red family, `role="alert"`, and a **refresh** control on the last row that re-runs the turn) and **`notice`** (information the operator should see once, such as a project hooks file held until approved, or a settings change the agent made itself with **`switch_model`** or a skill's frontmatter - a change the user made is on the composer's selectors and leaves no row; blue family via **`.msg-system-notice`** / **`.msg-system-stack-notice`** in both dark and light themes, `role="status"`, **no refresh control** even on the last row). Rows of any other level are dropped by the client rather than mis-rendered. A row sits at the end of the turn it is stamped with: the server stamps the number of user-role messages the history held, compaction summaries and background wakes included, and the client counts the same rows (**`chat/uiLogNotices.ts`**), so an error of a compacted session does not disappear when the saved transcript replaces the live turn; a row stamped past the end of the history is shown after the last message. Inside its turn a row stands at its own time: it goes before the first assistant message of that turn stored later than it (**`beforeMessageAt`**), so a provider failure the turn recovered from sits between the rows around the break instead of under the final answer, where it read as the answer having failed.

Ordering rules:

- The transcript renders in **arrival order** (pure array position), during streaming and after completion alike — the list is never re-sorted and tools are never grouped above the reply.
- `thinking` blocks appear wherever reasoning arrives in the stream.
- `tool_call` blocks appear where tool events arrive.
- The **live line stands under the transcript for the whole turn and always says what is happening**, in general words at least. It is rendered whenever the turn is running (**`generating`**), with no exception for streaming text and no silent state: under a reasoning row it reads **`Thinking…`** / **`Размышляю…`**, while the answer streams **`Writing the answer`** / **`Пишу ответ`**. It used to drop its text under a reasoning row and to vanish altogether once any segment of the turn carried **`streaming: true`** - which every segment written earlier in the turn keeps until the turn is reconciled - so a turn that went on to run tools or think again showed nothing moving at the foot of the column and read as stopped.
- A **tool row appears as soon as the model names the call**, not when the call is complete. Both streaming providers announce the name on its first delta (**`internal/llm/openai_stream.go`** on the tool-call delta that carries it, **`internal/llm/codex.go`** on **`response.output_item.added`**) through **`StreamChunk.ToolCallNamed`** - a channel of its own, because a call with no arguments yet must never be executed, forwarded to an API client or persisted - and the agent turns it into a **`pending`** row (**`internal/agent/react.go`**). Arguments can take seconds to stream, and without the early row the transcript stands still with nothing but a Stop button, which reads as a hang. The same id is announced again with the complete call, so the row updates rather than doubling.
- **Tool rows land in stream order, frames or not.** Tool updates are queued and flushed on an animation frame so a burst costs one render, while reasoning and text are applied at once. So the queue is flushed **before** a new reasoning row as well as before text - a reasoning block that followed queued tool rows used to land above them - and a timer (**`toolFlushFallbackMs`**, 250ms) lands the queue when no frame comes: a hidden tab, or a window the browser treats as occluded, gets no animation frames at all and held the rows back indefinitely.
- **Whitespace never opens a row.** A model calling several tools in one answer streams whitespace between the calls (the hermes tool parser behind vLLM passes **`"\n\n"`** through as content). Opened as a segment of its own, each run was an empty **`assistant_message`** - zero pixels tall, still taking the column's **10px** gap - so the live tool rows stood apart until a reload rebuilt the turn from **`messages.json`**, where the same whitespace is one tail of one message. **`consumeComposerSse.ts`** holds whitespace that would be the first thing in a segment until text follows and prepends it then; **`loadMessages`** skips a message whose content is only whitespace; and **`MessageList`** renders no row for one either way. Covered by **`consumeComposerSse.order.test.ts`** and **`MessageList.test.tsx`**.
- **Reasoning summary parts keep their boundary.** The Responses API sends one part per headed block, each opening with its own bold title; concatenated raw they run into a single paragraph where the markdown of one title swallows the next. **`codex.go`** inserts a blank line on **`response.reasoning_summary_part.added`**, in the stream as well as in the persisted text, so the live body and the reloaded one agree.
- Assistant text is **segmented**: text that resumes after a `tool_call` or `thinking` row opens a **new** `assistant_message` bubble, so prose interleaves with tools/thinking in the order the model emitted them (preamble → tool → follow-up → tool → …). During streaming this is enforced by **`consumeComposerSse.ts`**: new `tool_call` / `thinking` rows are appended at the **end** (not spliced above the reply) and mark the segment dirty; the next text delta drains the pending tool queue, then opens a fresh assistant segment (**`currentAssistantId`**), with **`lastAssistantId`** returned for the finalize/`syncAssistantFromServer` fill. This matches the chronological ordering **`loadMessages`** reconstructs from **`GET /coddy/sessions/{id}/messages`** on completion (which mints one assistant bubble per server message via **`stableAssistantItemId`**, the parts of a continued answer joined into one - next rule). The **live** stream and the **reloaded** transcript therefore show the **same** interleaving — there are not two different behaviors.
- **A continued answer is one bubble.** When the model's output limit cut an answer off and the model went on, the server stores two assistant messages and marks the first **`truncated`** (**`GET /coddy/sessions/{id}/messages`**). The live stream appends the continuation to the bubble it is drawing, and **`transcriptItemsFromMessages`** (**`chat/transcriptFromMessages.ts`**) joins a **`truncated`** row with the assistant row right behind it into that one bubble on a reload, with nothing between: the whitespace at the join is part of the text, so a word or a code fence cut in two reads whole; a step that came back with no text and no tool call between them does not part them (**`session.FollowsTruncated`** on the server). They stay two bubbles where the stream drew two: the continuation opened with thinking of its own, or a notice (a provider failure the turn recovered from) stands between them. An answer of its own a provider failure cut off is not **`truncated`** and keeps its bubble; a part of a continued answer a provider failure cut off is, since the step run again goes on from it. A page of a paged read never ends between the two parts, and the shared file cards of such an answer stand above its text. Covered by **`transcriptFromMessages.test.ts`**.

### Message editing (history rewind)

Editing a user message rewrites the conversation in place: the history is rewound to that message and the edited text is sent in the same session — no sibling session and no navigator.

- The pencil is the user bubble's own **`.msg-user-edit`** control (**Edit message**), a **`.msg-copy-icon-btn`** glyph in **`.msg-user-foot`** directly left of the copy control — always visible, in flow, like every other foot action.
- Sending the edit posts **`POST /coddy/sessions/{id}/rewind`** with the 0-based user-message index; the server truncates **`messages.json`** at that point, moves the **`tool_calls/`** entries and **`ui_log`** rows of the removed turns into the undo snapshot (**`rewind_undo/`**), and clears a stale pending permission.
- The draft and the editing state stay until the rewind lands: a failed request is a **`system_notice`** error row and nothing is lost.
- After a **200** the client drops the shadow transcript and the persisted permission prompts of the removed tail, reloads the kept prefix, and sends the edited text as the next turn.
- **`session_rewound`** on **`GET /coddy/events`** tells every other tab or surface holding the session to reload the same way.
- **An open edit is visible and can be left.** The pencil used to load the text into the composer and nothing else, so an operator who forgot about it sent a follow-up that silently rewound the conversation. Now the composer carries **`.composer-edit-banner`** above the card (the frosted panel of the queue rows, an accent border, a pencil glyph, **Editing message**, a one-line snippet of the message and the hint that sending rewinds to it, the cross where the queue's remove control sits); the send button is named **Send edit**; the edited bubble gets **`.msg-user-stack--editing`** (accent border and ring) and every row after it fades to 45% through **`.msg-user-stack--editing ~ *`**, which works because every row root is a direct child of **`.messages-inner`**. **Escape** in the field leaves the edit once no picker or popover claimed it, and leaving puts back the draft and attachments the pencil replaced (**`App.tsx`**, **`editingPrevDraftRef`**); so does sending the edit, once the rewind has landed.
- **A sent edit can be taken back.** While the messages response carries **`rewindUndo`**, the edited prompt's foot carries **Undo edit** (**`.msg-user-undo-edit`**, a counter-clockwise arrow, left of the pencil) and the banner slot shows **`.composer-edit-banner--done`**: one line, **Message edited.** and the note that file changes are not reverted side by side (the body is a wrapping row, baseline-aligned), then an **Undo** button and a cross that hides the banner for that edit only, both centred on that line (**`.composer-edit-banner--done > *`** overrides the top alignment the cross brings from the queue rows; a two-line body left Undo hanging between the lines, **`composerEditBannerCss.test.ts`**). On a phone the note wraps under the title and the controls stay centred on the block. An open edit wins the slot. Undo stops the edited turn first, then **`POST /coddy/sessions/{id}/rewind/undo`** restores the cut tail and the transcript is read again.

Functional contract and endpoints: **`docs/surfaces/web-ui.md`** (**Message editing and history rewind**).

### Composer pill

Muted **Auto** pill tracks future modality toggles; UI copy stays English everywhere.

### Composer permission chip, toolset chip and turn overrides

- **Permission chip** (**`composer-tab mode-btn mode-permission`**, **`data-testid="composer-permission"`**) is the last selector chip, after **Reasoning** (the bar reads attach, Mode, Model, Reasoning, Permission), and names the session's permission mode: **Ask** / **Accept edits** / **Bypass** (**Спрашивать** / **Правки без вопросов** / **Без вопросов**). Its **`title`** names the mode new sessions start in (*new sessions start in: bypass*), the one chosen last on any surface; the permission mode is not configuration, so no Settings tab has a field for it. Ask is the quiet default (the plain tab recipe); **`perm-accept_edits`** takes an amber hairline (**`rgba(234,179,8,.6)`**); **`perm-bypass`** takes the danger tone - red text (**`#b91c1c`** light, **`#f87171`** on every dark canvas), a **`rgba(239,68,68,.7)`** border and a **3px** **14%** red glow, the family of the plan/ask mode outlines - so a session that approves everything never looks like one that asks. The menu is the **`mode-menu`** family (anchored portal on desktop, bottom sheet on narrow shells); the bypass row carries the same red text. Hidden in a subagent's read-only transcript.
- **Toolset chip** (**`composer-tab mode-btn mode-toolset`**, **`data-testid="composer-toolset"`**) follows the permission chip while the configuration has a **`toolsets[]`** preset, and is not rendered otherwise (no placeholder, no disabled chip): it names the session's toolset, **All tools** (**Все инструменты**) for none, in the plain tab recipe, and its **`mode-menu`** lists the presets and **All tools** last, the current one **`is-selected`**. A preset added on a configuration reload brings it without a page reload. Hidden in a subagent's read-only transcript.
- **Turn overrides** (**`.composer-overrides`**, **`data-testid="composer-overrides"`**) follow the selectors when **`--once`** / **`--count=N`**, a skill or the model's **`switch_model`** changed a setting for some turns: **11px**, **62%** muted, one line (**`max-width: 240px`**, ellipsis) naming the first override ("gpt-oss-120b this turn and 2 more", "off, 1 turn left") with **`+N`** for the rest; the **`title`** lists them all. Nothing renders without overrides.
- Both mirror the session's settings snapshot (**`event: session_settings`**): a change made anywhere - a command, the permission dialog, a console - shows up in every tab.

### Session goal

A session can carry a goal (`/goal <objective>`, **`docs/features/session-supervisor.md`**): the supervisor checks every turn against it and starts the next turn itself until a check finds it met. The composer shows where the goal stands and is where the operator acts on it; the transcript shows the turns nobody typed as goal rows. The model and the parsers live in **`chat/goal.ts`**, the popover in **`chat/GoalPopover.tsx`**, the row in **`messages/GoalTurnMessage.tsx`**, the target glyph in **`components/TargetIcon.tsx`**.

- **One snapshot, four deliveries.** The goal arrives whole with a version, in one envelope (**`{sessionId, goal, version, notice}`**, `goal: null` once cleared), from **`GET /coddy/sessions/{id}/messages`** (`goal`), from the turn stream (**`event: session_goal`**), from **`GET /coddy/events`** (**`event: session_goal`**, parsed in **`chat/serverEvents.ts`**) and from the answers of **`PATCH`** / **`DELETE /coddy/sessions/{id}/goal`**. **`App.tsx`** keeps it for the viewed session the way it keeps the settings snapshot (**`applySessionGoal`**, **`isNewerGoal`**): the highest version wins, a snapshot of another session is dropped, and a new session on screen starts from nothing until its read lands.
- **Mark** (**`.composer-goal`**, **`data-testid="composer-goal"`**, on the plate over the composer card, left of git's **`+A −D`** and 8px from it, or at the plate's right edge when git reports nothing): shown only while the session has a goal, the one **`/goal`** set. A **24px** button with **7px** corners, the improve-prompt wand's size so the two read as a pair (**`goalCheckerCss.test.ts`** holds them equal), holding the target glyph (**15px**) in the status's tone and nothing else; **`Goal: <status>`** is its name; on a phone or a touch screen an invisible **40px** hit area lies over it, as over git's count. The plate reaches the mark as a function: **`ChatScreen`** hands **`cardTop`** as **`(goalMark) => <WorkspaceBar goal={goalMark} … />`**, so the mark and its popover stay the composer's, and a session with a goal gets a plate even outside a repository (the folder alone, then the mark). Its tip is the context ring's (**`rail-tip`**, above the mark): the status line in the tone, then the objective (cut at 200 characters), its right edge the mark's so it stays over the card, shown on hover where there is one (**`@media (hover: hover)`**) and on keyboard focus, and never while the popover is open. A touch opens the popover straight away. The mark's host carries the tone class too, so the tip, a sibling of the button, reads the same **`--goal-tone`**.
- **Tones** come from the theme's own tokens through **`--goal-tone`** (**`.goal-tone-*`**), never a colour of their own: **active** the accent (**`--accent`**), **paused** and **limited** the muted text (**`--muted`**), **blocked** the danger tone (**`--coddy-danger`**: there is no per-theme warning token, and blocked is the state that needs the operator), **complete** the done green of the todo list (**`--coddy-todo-completed`**). The mark's glyph and its tip's status line are the tone. Status words: active, paused, needs you, complete, out of budget (в работе, на паузе, ждёт вас, выполнена, бюджет исчерпан).
- **Popover** (**`GoalPopover`**, **`role="dialog"`**, **`data-testid="goal-popover"`**), opened by the mark and by a bare **`/goal`**: the context popover's shell (**`slash-menu-surface`** glass, **`sessions-head`** head with the **×**). On a desktop it is a portal anchored to the mark (to the composer card when there is no goal), **400px** wide at most, its right edge the composer card's (**`alignRef`**) and pulled inside the window, above the anchor when there is room and below otherwise, never taller than that room. On the stacked shell it is a bottom sheet over the scrim (**`.goal-popover--sheet`**, **`max-width: min(560px, 100vw)`**, at most **80vh**, on **`--coddy-keyboard-inset`** so the on-screen keyboard does not cover it; a Mini App lifts it with the other sheets). The head and the actions stay put and the body scrolls between them, which keeps the actions in reach at **320px** and in a **568x320** landscape phone. **Escape** leaves an edit first, then closes; a press outside closes on a desktop, the scrim on the sheet; while the Clear confirmation asks, both are the dialog's. Opening focuses the set form's field where a keyboard is at hand (**`composerAutoFocusAllowed`**), the panel otherwise; closing gives the focus back to the mark.
- **Contents**: the objective whole, in its own box that scrolls past nine lines; the status as a badge in its tone and the reason the supervisor gave (paused, blocked, limited); the **last check** - its verdict, a **verified** badge when the verifier confirmed a met verdict in the workspace, its reason and the items it left open; the **checklist**, one row per requirement with a drawn status mark (a ticked circle in the done green, an open circle, a dashed circle for unverified) and its evidence folding out under the row on a tap (a disclosure with the app's chevron, never a hover); the **numbers** - continuations *N of M*, checks, the time worked (**`formatElapsedSeconds`**, the turn clock's format) and the tokens, against the budget when one is set (**`formatTurnTokens`**) - and closing the same grid, **Checked by** and **Reasoning**: six cells, each one column wide, three to a row on a desktop and a tablet (**`repeat(3, minmax(0, 1fr))`**) and two on a phone (**`repeat(2, minmax(0, 1fr))`** under **`max-width: 599px`**), so the rows come out full and every label stands on the grid's column lines; a model id wraps inside its cell, after the provider first (**`ModelId`** puts a **`<wbr>`** after the slash). **Checked by** (**`goal-checker`**) is the model the next check runs on, the server's **`checkModel`**; **Reasoning** (**`goal-reasoning`**) is the level alone, the one **`/goal -r`** chose, else the model's default level as it is, **default** when the model configures none, and absent for a model without levels (**`checkerLevel`**).
- **Actions** (**`.goal-popover-actions`**): icons, rounded squares of **36px** with **10px** corners (**`.goal-icon-btn`**, glyphs in **`chat/goalActionIcons.tsx`**), each named in its **`title`** tip and its **`aria-label`** - Resume a filled play triangle on the accent ground (**`--primary`**), Pause two bars, Edit a pencil, Clear a bin in the danger tone (**`--danger`**). The edit and set forms keep worded buttons. **Pause** while active (**`PATCH .../goal {"status":"paused"}`**); **Resume** while paused, blocked or limited, which sends **`/goal resume`** as a prompt of the chat, since resuming starts a turn; **Edit**, an inline field filled with the objective whose **Save and start** sends **`/goal <objective>`** (it replaces the goal and starts working on it at once) and **Cancel** goes back; **Clear**, in the danger tone, asks through **`useConfirm`** and then **`DELETE .../goal`**. Without a goal the popover is the set form: a line on what a goal does, the field and **Set goal**. The field counts the objective in runes against the server's **4000** and refuses more. A prompt waits while a turn runs - Resume and the form's button are disabled and a note says why - and goes the way a typed message goes (**`sendGoalPrompt`** in **`App.tsx`**, like the plan card's Run plan), without touching the draft or an edit in progress. A refused Pause or Clear says so in the popover.
- **`/goal` in the composer**: a bare **`/goal`** (Send, Enter or the queue key) opens the popover and sends nothing, like **`/docs`** and **`/mcp`** (**`runLocalCommandFromDraft`**); **`/goal`** with anything after it - an objective, **pause**, **resume**, **clear** - is the server's and goes as typed. The **`/`** menu row is the server's (**`GET /coddy/commands`**).
- **Goal rows** (**`GoalTurnMessage`**, **`data-testid="goal-turn-row"`**): the first message of a turn the supervisor started is the instruction the model reads, and nobody typed it, so it is never a user bubble. A compact row stands in its place, on the thinking row's frame: the target and one line mirroring the server's note - *Goal set: <objective>*, *Goal resumed: <objective>*, *Goal continuation N of M: <reason>*, *Goal recovery N of M: <reason>*, *Goal budget used up, wrapping up: <reason>* - cut at two lines. When the check left items open the row says how many and folds them out (the chevron at its start, like a thinking row); a line the two lines cut at the row's width (measured, so a phone folds what a desktop shows whole) folds out whole. Live the row comes from the **`goal_turn`** frame (**`applyGoalTurnToItems`**: it takes the place of the **`/goal`** prompt this tab sent for a kickoff or a resume, since the server stores no such message, and a frame for the row already there changes nothing); after a reload from the message's **`goal_turn`** field (**`chat/transcriptFromMessages.ts`**), the instruction text never shown.
- **A goal row opens a turn and counts like one** (**`opensTurn`**): the answer before it closes the turn before and keeps its action row, the live status line counts from it, a failed goal turn offers no retry, and the index of a later prompt counts it the way the server counts user-role messages (**`userMsgIndices`**, the rewind and the edit), as a wake does. The row itself has no edit or copy control.
- Checks: **`chat/goal.test.ts`**, **`chat/ComposerGoal.test.tsx`**, **`App.goal.test.tsx`**, **`chat/consumeComposerSse.goal.test.ts`**, **`chat/transcriptFromMessages.test.ts`**, **`messages/MessageList.test.tsx`**, **`messages/userMsgIndices.test.ts`**, **`chat/serverEvents.test.ts`**.

### Workspace picks on the plate (folder / branch / worktree)

The composer card has no chip row: before the chat starts, the folder, the branch and the worktree are **picks** on the plate over the card (**Bar over the composer**, below), built by **`Composer`** in pick mode (**`WorkspaceBar`**, **`.workspace-bar--pick`**). **`WorkspaceChips.tsx`** (helpers in **`chat/workspaceContext.ts`**) renders them inside **`.workspace-bar-picks`**; data from **`GET /coddy/workspace/context`** (session header, or **`?path=`** preview before a session exists).

- **Applied after the message is on screen** (issue #357) — the first send draws the chat with its message and the live line (**`Preparing the session…`**) first, and applies the picks after (**`applyPendingWorkspace`**, which answers why a pick did not take). A pick that does not take sends nothing into another workspace: the route leaves the new chat and the start screen comes back as it was sent from - the picks still made, the text and the files in the composer (**`startScreenRestoreRef`**, taken by the effect that sees the route go home, which then does not reset the new chat's settings) - with the reason under the composer card (**`.hero-start-notice`**, **`role="alert"`**, the danger colour), cleared by the next send. The text goes back to the start screen wherever the operator is by then: into its composer at once, or on its next opening after they went on to another chat. Whatever the send gave the session it never created goes back with it: the naming in flight is cancelled (**`TitleSuggestHandle.cancel`**: no name shown, no **`PATCH`**), its History row is dropped, and the start screen's **`@`** recents move back to **`WORKSPACE_AT_RECENTS_NO_SESSION_KEY`**. Do not keep a failed first send on its chat route: a retry from there is no first send, applies no pick, and runs in the server's default folder.
- **One line** — **`.workspace-bar-picks`** is **`display: contents`**, so the picks are the plate's own flex items: the folder and the branch shrink with an ellipsis (the branch is shown whole while the plate has room for it and cut in the middle only when it has not, **`FitMiddleText`**), the worktree checkbox keeps its size. Each pick is a button (the checkbox a label) of the plate's look with **1px 6px** padding and a **7px** radius; a lighter ground (**9%** of the text colour) marks it on hover and while its menu is open, never the accent; the plate's left padding drops to **10px** so the folder still starts on the placeholder's left edge (**16px** in from the border, the field's own padding, and the running chat's plate keeps **16px** for the same reason). No git count is shown before a session. On a phone and on any touch screen (**`(max-width: 599px), (any-hover: none) and (any-pointer: coarse)`**) the picks and the checkbox are **36px** tall with **10px** side padding and **13px** text, the checkbox **16px**: they have no ground at rest, so only the plate grows. Git's count keeps its slim framed look there - a tall framed box read as a stray square - and an invisible hit area **40px** tall (**`::after`**, **6px** wider on each side) is laid over it, so a finger lands on it.
- **Folder pick** (**`composer-workspace-chip`**) — the plate's folder outline plus workspace basename (full path in **`title`**). Click opens the **Recent** menu (**`workspace-folder-menu`**, **`mode-menu`** family): a **`Recent`** header, MRU folder rows from **`localStorage`** **`coddy_workspace_recents_v1`** (**`chat/workspaceRecents.ts`**, cap 8) with the current workspace marked by a **✓** (**`is-selected`**), a separator, and **`Open folder…`** at the bottom.
- **Open folder…** opens the project-styled **folder browser modal** (**`WorkspaceFolderModal.tsx`**, **`workspace-modal`**, centered over a dim backdrop, **`min(560px, 100vw - 48px)`** wide from the tablet tier up; a **phone** (**`max-width: 599px`**) opens it where History opens, with the box and the glass of **`.sessions.drawer`** on the stacked shell: **`position: fixed`**, **`top: calc(var(--coddy-mobile-top-inset) + 6px)`**, **10px** from the sides (**`max(10px, env(safe-area-inset-*))`**), **14px** from the bottom, the **`--coddy-glass-panel-*`** background, border, blur, radius and shadow. The inset variables are declared on **`:root`** in the stacked-shell block, not on **`.shell`**, because this dialog is portalled into **`<body>`**): editable path field, **`..`** row, subfolder rows from **`GET /coddy/workspace/folders`** (row click **navigates into** the folder; a directory **symlink** wears the folder glyph with a bent arrow cut out of its body, **`workspace-modal-symlink-icon`**, drawn with **`fill-rule: evenodd`** so no mask or **`id`** repeats per row, and names its target after the name, **`→ <target>`**), footer **New folder** and **Show hidden** on the left, **Cancel** / **Open** (picks the currently browsed folder) on the right. The head is the drawers' head (**`sessions-head`**: 12px bold capitals, as History and the Scheduler open with) and **`workspace-modal-head`** only keeps it from shrinking - it must not set a font of its own. Every control of the dialog (path field, rows, the new-folder field, buttons) carries **`font: inherit`**: inputs and buttons do not inherit the page font, and without it they were drawn in the browser's control font. The browser starts at the **parent** of the current workspace. **Escape** folds an open new-folder row first and then cancels the dialog, as **Cancel** does.
  - **Path field** (**`workspace-modal-path`**, an **`input`** on the **`.mode-menu-filter`** token recipe — never **`direction: rtl`**, which breaks the caret): **Enter** browses to the typed path. While it holds a path that has not been visited, the primary button reads **Go** and navigates; otherwise it reads **Open** and picks.
  - **New folder** (**`workspace-modal-btn--add`**, first in the footer: a **32px** square with the scheduler's plus glyph on the scheduler add button's colours - the colour rules are shared with **`.scheduler-btn-primary`**, light theme included, so the two cannot drift apart) and **Show hidden** right after it; the switch's **`margin-right: auto`** pushes the **Cancel** / **Open** pair to the right edge. New folder opens an inline name row **between the path field and the list** (**`workspace-modal-row--new`**: folder glyph, **`workspace-modal-new-name`** field on the path-field token recipe, **Create folder** and a **×**). It is a **`flex: none`** sibling of the list, deliberately **not** a row inside it: a child of the scrollport scrolls away under the operator, and on the shortest viewport the checker drives (**1024x300**, where the list has already shrunk to zero) it would be taller than the port that holds it. For the same reason it is tighter than a folder row (**`padding: 2px 8px`**) - it is chrome, and chrome is what a short dialog runs out of. The row is a form, so it does not take the row hover. **Enter** / **Create folder** posts **`POST /coddy/workspace/folders`** and the dialog adopts the answer — the listing of the **new folder** — so **Open** picks it without another step. **Escape** / **×** closes the row. Disabled on the **drive level** and while the row is open; **Create folder** is disabled on an empty name. A taken name (**409**) keeps the row and its text and reports the clash in the same inline error slot as a failed listing.
  - **Drive level** (Windows) — a drive root has no parent directory, so the server answers **`"parent": ":drives:"`** and **`?path=:drives:`** lists the volumes (**`drives: true`**, drive glyph rows, `No drives` empty state, **Open** disabled). This is the only way out of a drive in the SPA; hosts without drive letters never see the level.
  - **Height and scroll** — the dialog is a column flex box bounded by **`min-height: min(520px, 80dvh)`** and **`max-height: min(80dvh, 760px)`** (on a phone the History box sets its height instead) (each with a **`vh`** line first, for engines without **`dvh`**; Safari keeps its dynamic chrome inside **`vh`** but outside **`dvh`**). Head, path row and actions are **`flex: none`**; the folder list (**`workspace-modal-list`**) is the **only** scrollport and carries **`min-height: 0`**, so it — and nothing else — gives up height on a short window. A floor on the list instead would push the action row past **`max-height`**, where the dialog's **`overflow: hidden`** clips it beyond reach (issue #159). The list contains its own **`overscroll-behavior`**, so a wheel gesture past the last folder does not scroll the page behind the modal, and styles a thin scrollbar on the settings-list recipe.
- **Branch pick** (**`composer-branch-chip`**) — the plate's branch glyph plus the current branch; rendered **only** when **`is_git_repo`**. Click opens the branch list (**`workspace-branch-menu`**), current branch first and marked **`is-selected`**. Picking a branch calls **`POST /coddy/sessions/{id}/workspace`** with **`{"branch", "worktree": <checkbox>}`**.
  - **Refresh in the background** - each opening calls **`POST /coddy/workspace/fetch`** (**`onRefreshBranches`**, owned by **`App.tsx`**) without hiding the list: the rows the context already holds - the local branches and the remote ones as the last fetch left them - stay on screen and pickable while the fetch runs, so the menu keeps the size it opened with instead of shrinking to a placeholder and growing back. The spinner (**`workspace-branch-refreshing`**, a **12px** ring, **`role="status"`**) sits in the right padding of the filter field and takes no layout space; while there are rows it is the only mark of the refresh, and *Fetching branches from the remotes…* (**`workspace-branch-refreshing-empty`**) fills the scroll area only while there is nothing to list yet. Fresh remote rows join the list when the fetch answers; an answer from an earlier opening, or one that lands after the list closed, is dropped, and **`App.tsx`** does not apply a fetched context over a folder or a branch picked while the fetch ran.
  - **One list** — the branches only on a remote (**`remote_branches`**, named as a local branch would be) are rows of the same list as the local ones: the current branch first, then both kinds in one alphabetical order (**`branchRows`**), every row **`workspace-branch-row-<name>`** (**`.workspace-branch-item`**, a flex row with an **8px** gap, the name in **`.workspace-branch-name`** cut with an ellipsis). A remote-only row opens with a **14px** cloud (**`RemoteBranchIcon`**, **`workspace-branch-remote-icon`**, the muted colour, **`aria-label`** *On the remote only*) and its tooltip says that picking it creates the local branch tracking the remote one. While the list has any remote-only branch every row keeps the icon's **14px** slot (**`.workspace-branch-mark`**, empty on a local row), so the names stay in one column as the filter narrows it; with none, the rows are as they always were. The filter and the empty notes (*No branches*, *No branches match*) treat both kinds alike.
  - **Failed refresh** — **`workspace-branch-refresh-warning`** (**`.workspace-branch-warning`**, **`role="status"`**) between the filter and the list: an amber note in the tone of **`.context-usage-note--warn`** (**`#b45309`** on light, **`#fbbf24`** on every dark canvas, a **10%** wash of the same colour behind it, **8px** radius, **12px** text), the sentence on the first line and git's first line of the reason under it in the muted text colour, one line with an ellipsis, the whole reason in the tooltip. The rows under it are the cached ones and stay pickable.
  - **Scroll** — the folder and branch menus keep the **`mode-menu-scroll`** cap (**175px**) and, inside **`.workspace-menu`**, take the folder browser list's thin scrollbar (**`scrollbar-width: thin`**, a **6px** WebKit thumb at **20%** of the text colour, **34%** on hover, no track) and **`overscroll-behavior: contain`**: with the remote group the branch list scrolls in most repositories, and the platform's arrows read as a foreign control.
- **Worktree checkbox** (**`composer-worktree-chip`** label + real **`input[type=checkbox]`** **`composer-worktree-checkbox`**, the browser's own checkbox in the theme's accent) — the **open-branch-switches-in-a-worktree** preference, only when **`is_git_repo`**. It is one choice for every folder in this browser (cookie **`coddy_worktree`**): ticked once, it stays ticked in any folder with git, across chats and reloads. When the session already runs inside a worktree it is **checked and disabled** (state, not a choice). Branches already checked out in another worktree jump there regardless of the checkbox.
- **Chosen once** — folder, branch, and worktree are fixed at session start. As soon as the conversation has messages (**`workspaceLocked`**) the picks give way to the plate of a running chat, which names where the chat works without offering a choice (**Bar over the composer**, below); the server enforces the same rule with **409** on **`POST .../workspace`**.
- **Pre-session (draft/home)** — the start screen opens on the folder last picked in this browser (cookie **`coddy_workspace_dir`**, one per environment, written whenever a folder is picked there or from a History heading; **`chat/workspaceCookies.ts`**), else on the server's default; a folder that is gone is forgotten and the default shown. A chat left for the start screen hands it nothing of its own workspace, a linked worktree included. The branch is the one the folder is on now: the start screen reads its folder again on arrival and when the page gets the focus back, so a **`git switch`** in a terminal shows; a branch picked there is that new chat's choice. Choices are kept client-side (**pending**) and previewed via **`GET /coddy/workspace/context?path=`**; the first send applies them to the fresh session id (**`POST .../workspace`**, whose answer becomes the chips' context) before **`POST /v1/responses`**. Navigating to another session drops pending choices.
- **What a new chat lists follows its folder** — the picked folder is the scope of everything the composer lists while you type: the **`/`** menu, **`@`** (files, rules, **`@agent:`** subagents), the mention highlight and the file a line range previews all carry **`cwd=<folder>`** next to the session header (**`chat/workspaceScope.ts`**). The known skill names that chip a **`/name`** in the composer and the transcript belong to the chat's folder and session: another pick or another session drops them at once and asks again, a late answer is discarded, and the first send of a new chat keeps them because its folder does not change.
- Menus follow the **`Mode`**/**`Model`** conventions: anchored to the pick, **`mode-menu--portal`** (**`opens-down`** on the start screen, **`opens-up`** when docked) on desktop, full-width bottom sheet (**`mode-menu--sheet`**) on narrow shells.

### Bar over the composer

Before the chat starts the plate offers the workspace as a choice (**Workspace picks on the plate**, above). Once a chat runs, where it works is a fact rather than a choice, so the picks give way to a plate over the docked composer (**`WorkspaceBar.tsx`**, **`.workspace-bar`**, **`data-testid="workspace-bar"`**), the way Claude's app names the repository, the branch and the diff above its composer.

- **Placement**: a plate cut like a queued message (**`.composer-queue-item`**: the glass panel's fill, border and blur, a **12px** radius), joined to the top edge of the composer card. **`Composer`** renders it through its **`cardTop`** slot right before the card, which then squares its top corners (**`composer-card--joined`**); the plate has the card's width and no bottom border, so the card's top border is the seam between them, and a message queue stacks above the pair. One line that never wraps: **12px** text at a **66%** text tint, **14px** between items, an item shrinking with an ellipsis before anything is pushed out. The icons sit on the middle of the lowercase letters and a pixel above it (**`top: -1px`**): the glyphs carry their ink low and read as sunk on the middle.
- **Items**: on the left the **repository** (a folder outline, **`folderChipLabel`**: the main checkout's name in a linked worktree; the full path in **`title`**) and the **branch** (the git branch outline, in a linked worktree too: the worktree is named in the tooltip; the name shown whole while the plate has room for it, and only when it has not cut in the middle to the longest form that fits (**`FitMiddleText`** over **`middleTruncate`**, fitted again when the plate or the window changes size), so both the prefix and the last words stay; **`Branch <name>`** or **`Worktree <path> on branch <name>`** in **`title`**); at the **right edge**, while the folder has uncommitted changes, git's count (**`.workspace-bar-edits`**, tabular monospace digits in the diff colours, **`+A −D`** formatted for the page's language). The count's right edge is the improve-prompt wand's under it: the plate's right padding (**12px**) is the wand's offset from the card's edge. The count is framed as a button: a light border (**18%** of the text colour, **7px** radius) and no fill at rest; on hover and on keyboard focus the border brightens (**34%**) and the ground lightens (**9%**). Never the accent. It opens the edits window; its label says how many files changed. A folder in no repository shows its name alone.
- **Only in git, or with a goal**: the plate of a running chat is git's - the branch and the count - so a chat whose folder is in no git repository has no plate, and the card keeps its rounded top, unless the session has a goal: then the plate names the folder and carries the goal mark (Session goal in the composer). A read-only transcript (a subagent's, an archived chat) has no plate either.
- Checks: **`chat/WorkspaceBar.test.tsx`**, **`chat/ChatScreen.test.tsx`**, **`npm run check:files`** (through a relay, on a desktop and a phone: the plate joined to the card edge to edge, the count framed at its right edge and brightening on hover, the icons a pixel above the middle of the words), **`chat/viewsLookCss.test.ts`**.

### Environment item and menu

The page says which Coddy it drives with an item of the nav rail (**`nav/EnvironmentSwitcher.tsx`**, **`.rail-env-btn`**, **`data-testid="nav-environment"`**), at the foot of the rail below Settings and above **Sign out** when there is one, the last item otherwise; the composer carries no environment chip. Its icon is the host: a **laptop** for this browser's own server, **two chevrons pointing at each other**, one a little above the other, for another host, the way a remote connection is usually drawn; both in the rail's 18px line style. The active environment's dot (**`.env-status`**: green **up** and **local**, red **down**, amber **checking**, driven by **`useActiveEnvHealth`**) sits on the icon's lower right corner (**`.rail-env-status`**, 7px, ringed in the rail's ground). The tooltip and **`aria-label`** say **`Environment: <name>`**: for this browser's own server the short host name of its machine (**`shortHostName`**, **`env/hostName.ts`**: the first label, an IP address whole; the whole host name in the tooltip), **Local** while it is not known; for another host the remote's name. The wide rail shows the name beside the icon (**`.rail-env-label`**), cut with an ellipsis to the rail's width rather than widening it (issue #357). The icon stands on the column of the other items' icons: the wide grid centres each item's icon in its 44px track, and since the environment's icon is wrapped with its dot, the wrapper (**`.rail-env-icon`**) is what carries **`justify-self: center`** - without it the icon sat at the left edge of the track (**`nav/railEnvItemCss.test.ts`**). On the stacked shell the item is one of the top bar's icons and never folds behind **More** (**`nav/navOverflow.ts`**), like History and Swarm. It is on every screen, a relay's map included, so the way to another environment never depends on the composer being there.

- **The menu** (**`mode-menu--env`**, the **`mode-menu`** family: on the desktop an anchored portal beside the rail, **`opens-right`**, its foot level with the item's and growing upward (**`opens-up`**; an item in the upper half of a short window hangs it from its top, **`opens-down`**), a bottom sheet over a scrim on the stacked shell) reads **Environment** with **Local**, then **Remote** with every entry of the page's own **`httpserver.remotes`** (read past the environment shim, **`env/configuredRemotes.ts`**: the list of places to go does not travel with the place you are), then **Connect to…**. The list is read on first use, again every time the menu opens and on every **`config_reloaded`**, so a remote a save or the agent's **`config_commit`** added is in the next menu without a reload.
- **One remote is a row, a hint and its agents** (**`.mode-env-entry`**, **`data-reach`**). The row: the dot, the name, and on the right the host, or **relay · N agents** once a relay let this browser in. The name is the entry's own, else the one the remote reports (**`reportedName`**: a relay's **`/swarm/info`** name, an agent's **`/coddy/info`** host name), else the address; the host on the right is left out when it is the name already, and choosing the row names the environment the same way. Every remote is probed while the menu is open (**`probeRemote`** in **`env/remoteProbe.ts`**), again whenever the list changes, and an answer from an older opening is dropped. A red remote says why on a line under its name (**`.mode-env-hint`**, 11.5px, a **62%** text tint, indented past the dot to the name's edge): it does not answer, it refuses the token (the key named for an agent or for a relay, or the configuration's own entry when that carries the token), or the browser keeps the answer from the page, naming this page's origin, which is how a CORS list that leaves the origin out looks from here - and, when the page itself is on a loopback address (**`isLoopbackOrigin`** in **`env/loopbackOrigin.ts`**: a laptop's own coddy serve), the **`cors.allow_loopback`** toggle that admits it on any port. A remote that refuses its token and has none in the configuration offers **Enter token** (**`.mode-env-hint-action`**, a 999px pill), which opens the form filled in with that remote's name and address and the focus in the token field.
- **A relay's agents** hang under its row once it accepts the token (**`.mode-env-nodes`**, 16px in, 12.5px rows 7px tall in padding), from **`GET /swarm/topology`** every hop deep: the dot says online, a node behind another relay shows its route, an offline one says so. A click enters that node through the relay (**`connectSwarmNode`**), with the relay's token.
- **The menu fits the window.** An anchored menu is **300px** wide, starts **10px** right of the item and is pulled back inside the window with a **12px** margin; it is as tall as the room between the item's foot and the window's top less **12px** (at least **160px**, **`roomFor`**) and scrolls past it (**`overflow-y: auto`**, **`overscroll-behavior: contain`**); the sheet stops at **80vh**. A relay with many agents on a phone used to push **Connect to…** off the screen with nothing to scroll.
- **Connect to…** (**`env.addRemote`**) opens the form in place: a name, the address and a password field for the token, then **Connect** and **Cancel**; **Enter** in the token field connects. A token typed there is kept for that address in this browser (**`coddy_env_tokens`**); a token the configuration carries wins over it and is never copied into the browser, so a rotated one is used from the next read.
- **Select = connect**: a row switches the environment at once: between two remotes in place (**A switch does not blink**, **Swarm screen**), to or from Local with a reload. The route is remembered per environment (**`coddy_env_routes`**, written by **`switchTo`** in **`env/remoteEnv.ts`**): back on Local the page opens where Local was left, a remote where it was left or at its home, and a relay's map never follows the app to an agent. Choosing the environment the page is on starts it over, which is the way to read everything again after that remote came back; the swarm map, a place rather than a menu, does not (**Swarm screen**).
- **The alert** (**`EnvHealthBanner`**, **`.env-health-banner`**, **`role="alert"`**): while the active remote is red, a red strip across the top of the page says why in the menu's three cases and names the setting that fixes it as code (the agent's or the relay's token key, the CORS key with this origin and, for a page on a loopback address, the **`allow_loopback`** key beside it; a node reached through a relay is answered by the relay's CORS), with **Switch to Local** as a pill on the right. It is fixed at the top of the window on the desktop. On the stacked shell the rail is a bar fixed at the top (**`--coddy-mobile-bar-h`**), and the alert hangs **under** it at **`z-index: 75`**, over the backdrop and the drawers and under the bar; it publishes its height as **`--coddy-env-banner-h`** on the root (a **`ResizeObserver`** follows a wrap), which **`--coddy-mobile-top-inset`** adds, so the page and every dock placed by the inset start below both. Under the bar it used to be covered by it.

Tests: **`nav/EnvironmentSwitcher.test.tsx`** (the icon and its dot, the list read again on open, the hints, **Enter token**, the configuration's token, the relay's agents, the room and the scroll, the placement beside the rail and inside the window), **`nav/navOverflow.test.ts`** and **`nav/NavRail.test.tsx`** (the item's place, never folded), **`remoteProbe.test.ts`**, **`envRoutes.test.ts`**, **`EnvHealthBanner.test.tsx`**, **`loopbackOrigin.test.ts`** (the page's own origin, held to the server's cases in **`internal/config/testdata/loopback_origin_cases.json`**), **`activeHealth.test.ts`**, **`phoneLayoutCss.test.ts`** (the alert under the bar), **`features/web_ui_menus.feature`**.

### Composer attachments (multimodal ingress)

Attachments row (**`.composer-attachments`**) renders **inside** **`.composer-card`**, below workspace chips and above the field. All ingress paths are gated on the selected model's **`multimodal`** flag (paperclip picker, clipboard paste in **`textarea#composer`**, drag & drop onto **`.composer-card`**). Functional contract and regression table: **`docs/surfaces/web-ui.md`** (**Composer file attachments (multimodal)**).

- Chips (**`.composer-attachment-chip`**, 140px, 10px radius) show the file icon plus name. Locked edit-mode chips stay icon-only (metadata has no bytes).
- An `image/*` attachment is a **preview card**, not a chip: **`.composer-attachment-card`**, **128×96**, 12px radius, the picture filling it (**`.composer-attachment-thumb`**, `object-fit: cover`, local object URL revoked on remove/unmount). Hover and **`:focus-within`** lift the border and the surface on the repo's `color-mix(in srgb, var(--text) N%, var(--coddy-blend-base))` recipe; the picture's cursor is **`zoom-in`** and a click opens it in **`ImageLightbox`**. The **remove** control (**`.composer-attachment-chip-remove`**, 20px round) sits at the card's top-right, over the picture: **`opacity: 0`** until hover - never `display: none`, so it keeps its place in the tab order - and **`opacity: 1`** on **`:focus-visible`**. A disabled card (non-multimodal model) greys and desaturates the picture instead of the icon.
- The **sent user bubble** echoes image attachments with the same card (**`.msg-user-file-card`**, **128×96**), filled first by an **optimistic `previewUrl` blob** created at send time (`optimisticUserFiles.ts`). Once the server snapshot includes **`files[].preview_url`**, that durable session thumbnail replaces the blob and the blob URL is revoked; reloads keep the image preview. The card opens **`files[].url`** - the original bytes, **`GET /coddy/sessions/{id}/assets/{name}`** - and falls back to **`preview_url`** for a message sent before that field existed, rather than losing the click.
- **The picture menu** (**`ui/components/ImageMenu.tsx`**, **`ImageMenuHost`** mounted once in **`main.tsx`**): a right click on a picture of the app - a file in the Files window, an attachment or a tool's picture in the chat, a diagram, the documentation, the image viewer - or a long press on a touch screen opens a small menu at the pointer with **Copy image** and **Save image** (the **`mode-menu`** family, portalled, **`z-index: 900`**, over the viewer's **400** and under the settings field tips' **1000**, flipped left or up at an edge). One listener on the document serves every picture: only the app's own (a **`blob:`** or **`data:`** address, or the page's origin) take it, a picture from another site and anything under **`[data-native-image-menu]`** keep the browser's menu, and a right click something else already answered (a shared file's card opens its own menu) is left to it. **Copy** writes a PNG (a PNG as it is, any other picture drawn onto a canvas first), handing **`ClipboardItem`** a promise so the write starts inside the click as Safari requires, and is offered only where the clipboard takes pictures; **Save** downloads the bytes under **`data-image-name`** (the Files window sets the file's name), else the alt text when it reads as a file name, else **image**, with the extension of the type. A press beside it, a scroll, a resize, the page losing focus or **Escape** close it; its Escape is heard on the window in the capture phase, so it closes the menu and not the viewer under it. The outcome shows for a moment at the pointer (**`.image-menu-notice`**, **`role="status"`**).
- **`ImageLightbox`** (**`ui/components/ImageLightbox.tsx`**) is the SPA's one image viewer: the documentation reader, the composer cards and the sent bubble's all open it. Fitted to the window first, then zoom levels 1 / 1.5 / 2 / 3 with the buttons, **+ / - / 0**, or a click; the **wheel** over the picture and a **pinch** of two fingers zoom between the fitted size and 3 as a factor of its own, off the ladder, and the buttons then take the next level above or below it. A zoomed picture is **dragged** with the pointer (mouse, pen or one finger), cursor **`grab`** / **`grabbing`**, and a press that travelled more than **`DRAG_SLOP_PX`** (4px) swallows the click it ends with, so a drag never also toggles the zoom. **Esc**, the close control or a click beside the image closes it; portal into `document.body`. Its **`.docs-lightbox*`** class names are older than that shared role and stay as they are.
- Pasted clipboard images get deterministic names **`pasted-<n>.<ext>`** (browsers hand every clipboard image over as `image.png`).
- **Picture markup** (**`ui/markup/ImageMarkupDialog.tsx`**, model in **`markupModel.ts`**, drawing in **`markupCanvas.ts`**): one picture pasted into the field opens in the markup editor before it is attached, as **ctrl+v** in the console opens the X window of **`external/cli/markup`**; the two editors share the tools, the palette, the line and label sizes and the meaning of the buttons. The editor is a full-window layer (**`.markup-dialog`**, portal into `document.body`, **`z-index: 400`** like the viewer, the viewer's frosted ground) with the picture fitted into **`.markup-stage`** and never enlarged, and the toolbar along the bottom (**`.markup-bar`**): **Cancel** on the left (a text button), the tools and the palette in one rounded group in the middle (**`.markup-tools`**: pencil, ellipse, rectangle, arrow, text, a separator, crop, rotate a quarter turn clockwise, undo, a separator, seven swatches - red, orange, yellow, green, blue, white, black), **Done** on the right (accent ground). The chosen tool is a lighter ground (**`.markup-tool.is-active`**), never the accent; the chosen swatch a ring in the text colour. A label is typed into a field over the picture (**`.markup-label-input`**, dashed accent outline, the label's font and colour); the press that opens it is cancelled so the browser does not hand the focus to the dialog and close the field. Done renders the history onto a canvas and hands back a PNG named after the original; Cancel and **Escape** hand back nothing. A pasted picture is attached only on Done; an attached one opens from the pencil in the card's top-left corner (**`.composer-attachment-card-markup`**, the remove control's twin: **`opacity: 0`** until hover or **`:focus-visible`**), and Done replaces it in place. A browser that cannot draw (no **`CanvasRenderingContext2D`**, asked through **`markupSupport.ts`** without loading the editor) attaches a pasted picture at once and shows no pencil. The editor is a lazy chunk (**`React.lazy`** in **`Composer.tsx`**), fetched before a picture first opens (**`openMarkup`**), so the canvas code stays out of **`app.js`** and its budget, and a chunk that cannot be fetched attaches a pasted picture as it is instead of failing a render. A press on a control of the toolbar keeps the focus on the editor (**`mousedown`** cancelled), so Enter after it is still Done; Enter on a control focused from the keyboard is that control's press, Tab is wrapped inside the editor, and the shortcuts read **`KeyboardEvent.code`**, so they hold in any layout. A shape is drawn onto the current raster; undo, a crop and a turn replay the history onto a fresh canvas and release the old one (**`releaseCanvas`**: zero size), since Safari caps the memory of a page's canvases together. Cancel, Escape or leaving the screen while the PNG is encoded hands nothing back. On a phone the actions stand over the group, which scrolls sideways.
- Paste/drop against a non-multimodal model attaches nothing and flashes the inline notice **`.composer-attach-hint`** (**`role="status"`**, auto-clears) below the attachments row.
- Switching to a non-multimodal model does not discard existing files: chips use **`.composer-attachment-chip--disabled`** (muted, grayscale, dashed border), attachment-only Send is disabled, and text sends omit while retaining them for a later multimodal selection.
- The server repeats this capability check against the effective YAML model and drops any client-supplied **`inline_files`** when **`multimodal`** is false, so disabled files cannot reach the provider or session assets through a stale/custom client.
- While files are dragged over the card it carries **`.composer-card--dragover`** (inset ring accent) as the drop-target affordance.
- **Attachment-only send** is valid: an attachment alone unlocks **Send** (see **Composer primary action**).

### Composer context meter

Ring to the **left** of **Send** in **`Composer.tsx`**. Implemented by **`ContextUsageRing`**: inner stroke always visible; outer progress arc only when usage **> 0**, flat color (no gradient or shadow), fill from **12 o'clock** clockwise. Colors use **`--coddy-context-ring-inner`** and **`--coddy-context-ring-fg`** (both themes in **`styles.css`**). Dark outer arc **`#f5f3ff`** (logo stroke); light outer arc **`var(--accent)`**.

- Do **not** put a percent label **on** the ring. Percentages and counters belong **only** in the tooltip (**`rail-tip`** family), above the ring, centered, wide enough via **`composer-context-tip`** CSS.
- Idle home (**`contextIdle`**): inner ring only (no outer arc); tooltip **`No context usage yet`** plus **`Max context …`** only (no **`Model …`** line).
- Active session: arc fills from stats; live **`usage_update`** SSE replaces both the current total (`used`) and the session's context window (`size`) immediately and refreshes detailed stats, so manual and automatic compaction reduce the displayed window without a reload. The live size overrides the initial model listing, including when a provider listing arrives late, survives stats refreshes, and is scoped to the session, selected model and configuration version. The tooltip may include usage lines but **never** a **`Model …`** line that duplicates **Mode** (the mode dropdown).
- **Click** (or **Enter** / **Space** when focused) on **`.composer-context-tip-host`** opens **`ContextBreakdownPopover`** (**`data-testid="context-breakdown-popover"`**): summary percent, stacked bar, legend (**System prompt**, **Tool definitions**, **Rules**, **Skills**, **MCP**, **Conversation**). **Escape** or **Close** dismisses; hover tooltip returns when closed. Data from **`GET /coddy/sessions/{id}/stats`** field **`contextBreakdown`** (estimated tokens per category).

See the paired UI rule ([Cursor](.cursor/rules/ui-spa.mdc), [Claude Code](.claude/rules/ui-spa.md)) for the full wording.

### Context popover usage section and usage banner

The account quota behind the selected model's provider (today: `neuraldeep`,
`codex` and `devin`; the hub's `GET /v1/limits`, the Codex backend's usage
endpoint and the Devin seat-management status RPC, read by the server, see
`docs/plans/neuraldeep-usage.md`).
The composer carries no extra control for it: account usage lives under the
context window in the context popover, and a banner speaks up only when
something needs the user.

- **Usage section** (`UsageSection.tsx`, **`.context-usage`**,
  **`data-testid="context-usage"`**) is the last block of the context
  popover (`ContextBreakdownPopover.tsx`, floating on a wide shell, a bottom
  sheet on a stacked one), rendered only when the selected model's provider
  reports usage (its row keeps the **Usage limits panel** switch on,
  `providers[].usage_limits_panel`; off, the section and the banner stay
  away and nothing is read). A head row names the provider and plan (`NeuralDeep · Pro`, the hub's tier id with a capital)
  and, when there is one, a **note** on the right (**`.context-usage-note`**):
  `Usage limit reached · resets 20:59` or the cause of a block no clock lifts
  (**error** tone), `Auto-resuming at 20:59` while the agent waits for the
  reset and the key-rejected hint (**warn** tone), the unlimited-option note
  for a model that bypasses the windows, the stale note when the latest read
  failed, `Quota data unavailable` for a source that reported a plan or a
  failed read but no meter (warn when the read failed). Below it one **meter
  per metered window** the source reports (NeuralDeep's session, week and
  day - the day only when it is above zero; Codex's duration-labelled
  windows and feature-scoped entries; Devin's day/week quota or `ACU`), each a label in the reader's language
  (`week` / `неделя`; a duration the hub chose, `3h`, stands as is), the
  reset time in the reader's clock and the percent **used** on the right,
  and a 6 px track (**`.context-usage-track`** / **`.context-usage-fill`**,
  accent fill; amber from 80 %, red when exhausted or blocked, keyed by
  **`data-tone`**). The wallet line (**`.context-usage-foot`**) closes the
  block; a rejected key shows the note alone. Tone text is lifted on every
  dark theme (**`html:not([data-theme="light"])`**, as `color-scheme` is).
- **Banner** (`UsageBanner.tsx`, **`.usage-banner`**,
  **`data-testid="usage-banner"`**) renders **above the composer card** in
  both the hero and the docked layout, only at 80 % of a window (**warn**
  tone, `You've used 85% of your NeuralDeep 3h limit · resets 20:59`), on a
  block (**error** tone: a timed block names its reset, `Usage limit reached
  · Resets 20:59`, the others name their cause), and while the agent waits
  for the reset (**warn** tone, `Usage limit reached · Auto-resuming at
  20:59`). The **×** control (32 px, a 44 px hit area below 1200 px)
  dismisses it for that provider row, window and period (**`localStorage`**
  `coddy_usage_banner_dismissed`); a new period shows it again. The localized
  copy above is the wording contract; do not replace it with a generic quota notice.
- **The banner's plate is opaque.** It stands in **`.chat-bottom`**, over the
  transcript that scrolls under the docked composer, and a translucent tint
  (the **10%** amber or red it used to be) let the chat read through the
  notice. The tone's colour is mixed into the theme's canvas instead,
  **`color-mix(in srgb, #f59e0b 10%, var(--bg))`** for **warn** and
  **`#ef4444`** for **error**; **`--bg`** is a solid colour in all seven
  themes, so the plate is too (**`usageBannerCss.test.ts`** holds both). The
  text is **`var(--text)`** and the **×** hover plate the **6%** text wash:
  the **`--fg`** and **`--border`** the rules used to name exist in no theme.
- **The transcript makes room for it.** The usage read answers after the
  transcript is on screen, so the banner rises into the block over a
  transcript already parked at its newest message. The reserve under the
  transcript is that block's height (**`--chat-composer-reserve`**); when it
  changes, a transcript that was following the newest message is taken to
  its new end (**`ChatScreen`**, the same stick the auto-scroll keeps), and a
  reader who scrolled away keeps their place. Without it the opaque plate
  would cover the last lines of the answer.
- Data flow (`useProviderUsage.ts`): REST `GET /coddy/providers/{name}/usage`
  at session open and model change (a cache read on the server), a refresh
  after every finished turn of the viewed session, one hub read after a
  window's reset, one cache read when the server deferred a refresh
  (`refreshPending`/`refreshInSec`), one follow-up when a passed reset still
  shows; `provider_usage` frames on `GET /coddy/events` replace the snapshot
  between turns, and the same frame on the turn stream carries the
  auto-resume countdown. Nothing polls otherwise. Snapshots order by the
  server's `fetchedAt`: a REST answer issued before a pushed frame, or a
  frame that crossed a later read, never brings older numbers back, and
  only the latest read issued applies. A row that answered "unsupported" is
  left alone for five minutes. The snapshot is account-wide; the model's
  selector suffix is compared with `unlimitedModels` client-side, and with
  `blockedModels` the same way: a model the account may not call right now
  reads as blocked even while the account itself is healthy.

### Transcript scroll-to-bottom button

- **Placement** - `button.chat-scroll-bottom` (`data-testid="chat-scroll-bottom"`) is a child of **`.chat-bottom-inner`**, positioned **`absolute`** with **`right: 0`** and **`bottom: 100%`** plus a **`10px`** margin. It is anchored to the composer's column, **not** to the viewport, so one rule carries it through the absolute desktop dock, the **`position: fixed`** composer below **`1200px`** and the **`padding-right`** the background tasks panel adds. Its right edge is flush with **`.composer-card`** where there is no composer (a read-only transcript); under a composer it stands one slot over the expand control and on the same line (**Composer field height and expand**). Do **not** re-anchor it to `.chat-stack`, `.chat-scroll` or the viewport.
- **The button** is a **34x34px** circle (**`border-radius: 50%`**) on the shared glass tokens (**`--coddy-glass-panel-bg`** / **`-border`** / **`-backdrop`**), so all seven themes carry it without a per-theme rule. Its glyph is a **16px** stroked down arrow in **`currentColor`**. Its shadow is its **own, small** one (**`0 3px 10px`** plus **`0 1px 2px`**, lighter under **`[data-theme="light"]`**) and **not** **`--coddy-glass-panel-shadow`**, whose **`0 14px 44px`** cast is drawn for a composer-sized card and reads as a smudge under a circle this size.
- **When it shows** - only while the transcript has stopped following the newest output, which is the same band that drives stick-to-bottom: **`TRANSCRIPT_BOTTOM_THRESHOLD_PX`** (**80px**) in **`chat/transcriptScrollPosition.ts`**. Keep both readings on that one module; the distance was previously inlined twice in **`ChatScreen`**, once per scroll surface, and drifting copies would let the button contradict the follow behaviour. The empty hero renders no button at all.
- **Both states live on one mounted node.** While a chat is open the button stays in the DOM and **`is-visible`** carries **`opacity`**, **`transform`** and **`pointer-events`** - an unmounted node cannot animate its way out, and the reader should see it leave as well as arrive. It enters over **`0.14s`** ease-out with a **`0.2s`** **`cubic-bezier(0.2, 0.9, 0.3, 1)`** on the transform and leaves over **`0.12s`** ease-in. Hidden, it is **`inert`**, **`aria-hidden`** and **`tabIndex=-1`**, and a click blurs it before it goes, so focus is never left on a hidden control.
- **The jump is animated**, in **`requestAnimationFrame`** rather than **`scrollTo({ behavior: "smooth" })`**, so the curve is this repository's decision and not the engine's: **`transcriptJumpDurationMs`** (**220-460ms**, by distance) and **`easeTranscriptJump`** (ease-out cubic - leaves at speed, settles into the last pixels). Two rules keep it honest. The end is **re-read every frame**, so a turn still streaming does not leave the travel short of the newest message. And **`syncTranscriptPosition`** stands back while a frame is pending, because reading the position mid-flight would put the button back on screen for every frame above the band - the reason the first draft of this control jumped instantly instead.
- **The reader always wins**: a **`wheel`**, **`touchstart`** or **`mousedown`** cancels the travel where it stands and re-reads the position immediately, so the button comes back if they stopped short.
- **It only ever goes down.** A measured end at or above where the page already is means the reader is at the newest message: the jump writes nothing and re-reads the position, and a frame never turns back up past where the travel started. The end used to be **`scrollHeight - innerHeight`** on the stacked shell, and an on-screen keyboard that overlays the page (iOS Safari ignores **`interactive-widget=resizes-content`**) leaves **`innerHeight`** alone while the page scrolls on past that end: the button sent the reader up by the keyboard's height.
- **Measured against what the reader sees.** On the stacked shell the distance and the end come from the **visual viewport** (**`visualViewport.height`** and **`offsetTop`**, **`innerHeight`** without the API), so a keyboard that shrinks it counts. The docked block itself, fixed to the layout viewport's bottom, rose under that keyboard with the button on it: it sits on **`bottom: var(--coddy-keyboard-inset, 0px)`**, which **`ChatScreen`** sets on the root from **`keyboardInset`** (**`innerHeight - visualViewport.height - offsetTop`**, zero during a pinch zoom) on every **`resize`** and **`scroll`** of the visual viewport. Where the keyboard resizes the page (Chrome with **`resizes-content`**) the inset is zero and nothing moves.
- **`prefers-reduced-motion: reduce`** writes the end position in one step and drops both button transitions.
- **Copy** - accessible name and tooltip are both **`chat.scrollToBottom`**.
- **A conversation opens on its newest message.** When the session id changes, **`ChatScreen`** cancels a jump in the air, re-arms stick-to-bottom and hides the button in a layout effect that runs before the follow, so the new rows land on their end. Without it a reader who had scrolled up in one chat opened the next at the old offset, the button showing, on both scroll surfaces (issue #342).

### Quote from the transcript

A text selection inside **`.messages-inner`** offers itself as a quote for the next message (issue #342, **`chat/TranscriptQuoteButton.tsx`**).

- **The button** - **`button.transcript-quote-btn`** (**`data-testid="transcript-quote"`**), portalled to **`<body>`** and **`position: fixed`** by the selection's box: a **32px** pill on the scroll-to-bottom button's glass (**`--coddy-glass-panel-*`**, its small shadow, lighter in the light theme), a **14px** quote mark and **`chat.quoteSelection`** in **13px** type, **`chat.quoteSelectionTitle`** as its title. It appears once the selection rests (**120ms** after the last **`selectionchange`**) and follows it on scroll and resize, and, while it is out, on a change of size of the transcript column or the docked block (**`watch`**, a **`ResizeObserver`**): a stream growing the transcript, a draft cleared or the composer expanded move the selection or the band without a scroll event, and the button used to stay where the selection had been.
- **Placement** - centred on the selection, **10px** away from it: **above** with a pointer, **below** on a **`touchOnly`** device, whose own selection menu takes the space above; the other side when the preferred one does not fit, and the foot of the band when neither does. The band is the chat header's bottom to the docked block's top, so the button never covers either; a selection out of that band offers nothing.
- **What it takes** - only a selection whose common ancestor is inside **`.messages-inner`**: the chat's title, the composer and the rest of the page offer nothing, and so does a chat with no composer (a read-only subagent transcript, an archived chat). A mouse's **`pointerdown`** on the button is **`preventDefault`**-ed so the selection and the caret stay; a finger's is **not**, because WebKit sends no click after a touch whose **`pointerdown`** was cancelled (a tap on Quote did nothing on an iPhone that way), and the text quoted is the one read when the button appeared, since a touch browser may take the selection down under the finger. **`npm run check:long-prompts`** taps it in WebKit.
- **The quote** - **`quoteMarkdown`** (**`chat/quoteDraft.ts`**, shared with the documentation reader's **Ask about the selection**) turns the passage into **`> `** lines, a bare **`>`** for a blank line, runs of blank lines collapsed; **`appendQuoteToDraft`** puts it after the draft with a blank line on both sides and an empty line under it. The draft stays the only record: nothing beside the text holds quotes. **`ChatScreen`** bumps the composer's **`caretToEndEpoch`**, which puts the caret at the end and, through **`composerAutoFocusAllowed`**, the focus in the field.
- **In the bubble** - **`splitQuoteBlocks`** cuts a sent prompt into quote and text blocks for **`UserMessage`**: **`blockquote.msg-user-quote`** (**`data-testid="user-message-quote"`**) has a **2px** bar in a **26%** text tint, **10px** of left padding and **72%** text; **8px** separate adjacent blocks; the blank lines that only separated a quote from its neighbours are dropped. Only a marker followed by a space, or alone on its line, opens a quote. Skill chips and mentions render inside both kinds of block; copy and the pencil still use the raw prompt.

### Transcript window

A long conversation keeps a bounded slice of its rows in the DOM and holds only the end of its history until the reader scrolls up (issue #338; behaviour and the numbers: **`docs/surfaces/web-ui.md`**, **Long sessions**).

- **Rows are found by id.** Every row root carries **`data-row-id`** with its transcript item id (**`rowId`** prop on the row components, set by **`MessageList`**); the window measures and anchors by it. A new kind of row does the same, and it must be a **direct child** of **`.messages-inner`**: no wrapper, or the window cannot see it.
- **The window** lives in **`chat/TranscriptList.tsx`** (**`useTranscriptWindow`**, pure arithmetic in **`chat/transcriptRenderWindow.ts`**): it opens on the last **8** rows, grows by **4** per frame toward the edge within a screen of the view, and past **120** (plus **8** of slack) drops rows more than a screen out of view on the far side. A row the reader is typing in (a focused text field) is never dropped; a focused button is no reason to keep a row. While a permission or question prompt of the turn in flight waits for its answer - in the transcript, or a background subagent's card after it - the bottom is never dropped (a prompt an older turn left unresolved holds nothing), so the prompt keeps its choices and typed text however far up the reader goes. Its state stays in that component, so a frame that adds rows does not re-render the chat screen or the composer. Do not grow in bigger chunks: a chunk is one frame of layout on a slow phone.
- **Nothing moves under the reader.** A change above the visible area is measured and committed in one task (**`flushSync`**), then the first visible row is put back where it stood - or, for a reader following the newest message, the end is. The correction is the window's own, not the engine's scroll anchoring, which WebKit lacks. The transcript still carries no **`content-visibility`** (see **Multi-session streaming and Stop**).
- **The control above the rows** - **`div.transcript-earlier`** (**`data-testid="transcript-earlier"`**, **`data-state`** **`idle`** / **`loading`** / **`error`**) is the first child of **`.messages-inner`** whenever anything is above the window, and the sentinel the window watches. It is a centred line of **12px** text in a **48%** text tint, **32px** tall: **`chat.transcriptEarlier.show`** as a pill button (**`.transcript-earlier-button`**: a **10%** tint border, a **4%** tint fill on the glass panel colour, **999px** radius, **5px 14px** padding, **7%** fill and **78%** text on hover, the accent focus ring); **`chat.transcriptEarlier.loading`** as a **`role="status"`** line; **`chat.transcriptEarlier.failed`** as a **`role="alert"`** line followed by the **`chat.transcriptEarlier.retry`** pill. Scrolling to it does what the button does.
- **The lower edge** of a window cut short of the newest rows is **`div.transcript-window-edge`**: **1px** tall with a negative top margin of the column's gap (**`--coddy-transcript-row-gap`**, which **`.messages-inner`** sets to **10px**, and to the phone's stack gap on the stacked shell), so the rows above end where they would. While the window is cut short, the live line, the tasks line and the subagent permission cards - which belong under the newest row - are not rendered.

### Composer field height and expand

The field follows its text (issue #342): **`chat/useComposerFieldHeight.ts`** sets its height from **`chat/composerHeight.ts`** on every change of the draft, a change of the field's width, a window resize and a visual viewport resize. The floor is read off the field (its inline height dropped for a moment, so **`rows`** and the **76px** CSS minimum decide), the text's height is **`scrollHeight`** at that size, and the ceiling is **eight lines** or **40%** of the visible viewport, whichever is less, never under the floor. The stylesheet carries **no `max-height`** on **`textarea#composer`**: a cap there would cut the expanded field short. A field that is not laid out keeps the height it had. The hook runs before the mirror's effects, so the mirror reads the new height in the same frame (see **Composer mirror and caret sync**).

- **Expand** - **`button.chat-expand-composer`** (**`data-testid="composer-expand"`**, **`chat/ComposerExpandButton.tsx`**) is the scroll-to-bottom circle's twin: **34x34px**, the same glass, border and small shadow, **10px** over the composer's first block (**`.chat-bottom-inner`**, **`bottom: 100%`**, the margin **`calc(10px - var(--coddy-composer-wrap-pad))`**, since the wrap's top padding is not the composer) and centred on the improve-prompt wand's vertical line (**`right: 8px`**: the wand's centre is 25px from the card's outer edge, 12px, half of 24px and the 1px border), rendered by **`ChatScreen`**, which owns the state. It **never moves**; the jump to the newest message stands one slot **above** it, 10px apart and on the same line (**`.chat-bottom-inner:has(> .chat-expand-composer) > .chat-scroll-bottom`**: **`right: 8px`**, **`calc(54px - var(--coddy-composer-wrap-pad))`**), so the arrow down is the upper of the two. A usage banner over the composer is the block's top, and the two keep their 10px over it. The transcript offers no jump up. The glyph tells the two apart: two arrows spreading to the corners to expand, coming back together to fold, never a chevron or an arrow pointing up or down, which read as another scroll control when the first version stood in the field's corner. Pressed it carries **`aria-pressed`**, the text colour and the label **`composer.collapse`** (else **`composer.expand`**); it keeps the caret in the field (**`preventDefault`** on **`mousedown`**), so a phone keeps its keyboard. Do not put it back into the field or onto the card's edge.
- **Expanded height** - the room from the top of the docked block (measured with the field at its floor, so a usage banner and the queue count) up to the line under the chat header, less the strip the control stands in: one step of the top rhythm (**`--coddy-top-rhythm`**: **10px** on the stacked shell, **14px** on a desktop), the control's **34px**, one step again. Expanded, the composer wrap drops its top padding (**`.composer-wrap--expanded`**) and the control stands **`--coddy-top-rhythm`** over the card, so header, control and card stand one step apart, the step from the window's edge to the top bar. The jump to the newest message is hidden while expanded. Never less than the floor; a longer text scrolls inside it. **`ChatScreen`** hands that room to the docked composer as **`expandRoomPx`**; the start screen gets none and draws no control. The hook also watches the docked block's own height and fits the field again on every change of it, so a banner, an attachment or an error rising into the block takes its height from the field instead of pushing the block over the header - whether the field is expanded or grew by itself. A fit caused by the field's own growth lands on the same height (the room is measured with the field at its floor), so the observer settles after one extra fit.
- **On-screen keyboard** - the line under the header is the header's bottom or the top of the visible area (**`visualViewport.offsetTop`**), whichever is lower: where the keyboard overlays the page (iOS Safari) and the page was pushed up under it, the expanded composer keeps to what can be seen, between that line and the keyboard, where the chat screen lifts the docked block. Where the keyboard resizes the page (Android with **`interactive-widget=resizes-content`**) the window is simply shorter. Viewport changes fit the field a frame later, once the block stands where it will stay. With less room than the field's floor (a small phone held sideways, keyboard open) the field stays at its floor.
- **Pickers in an expanded field** - the skills, mention, line-range and command option pickers open **inside** the field, at its foot over the composer's bar, instead of above the card, where they rode over the top bar: the sheet's bottom is the bar's top (**`measureSheetBottom`**), the desktop portal's the field's foot, and their height is the field's (**`--slash-sheet-room`**, **`maxH`**).
- **Folding** - the control, an unclaimed **Escape** in the field (before Escape leaves an edit), a send, a queued message and another session id. Nothing is persisted: every chat opens folded.

### Composer primary action (**Send** **/** **Stop**)

- Control **`#btn-send`** (**`.composer-icon`**) sits **directly right** of the context ring (**`.composer-context-tip-host`**).
- **Circular button** (**not** pill or squircle): fixed equal **width** and **height**, **`border-radius: 50%`**, **`box-sizing: border-box`**. Intended diameter **42px** in production CSS (**may track token scale**, but stays a **circle**).
- **Glyphs** live in **`composer-send-glyph`**. **Play** state is an **SVG triangle**, **17×17px**, its path set right of centre for optical balance (a text **▶** sat wherever the platform's font put it); **stop** uses **`.composer-stop-square`** (**14×14px** filled block, centered in the circle by **`.composer-run-icon--stop .composer-send-glyph`**, which fills the button and zeroes **`font-size`** and **`line-height`** so no text baseline is left to push the square off centre; the composer, the background task cards and the scheduler's job rows share that one rule). **Every stop glyph is that drawn square, never a text character**: the same rule zeroes the font, so a **`■`** written into the wrapper renders nothing - which is how a running scheduler job came to show an empty red circle. Ring + stop stay **right-aligned** in **`composer-bar-actions`** (same row as mode tabs). Keep contrast high (**`composer-send-play`** vs **`composer-send-stop`**).
- **Focus.** The app puts the caret in the composer by itself on the start screen, on switching to a conversation and on closing History, except on a **touch-only** device (**`(any-hover: none) and (any-pointer: coarse)`**: phones and tablets without a pointer), where a focused field opens the on-screen keyboard over half of the screen; there the keyboard waits for a tap on the field (**`chat/composerFocus.ts`**, **`composerAutoFocusAllowed`**). Like Enter below, this follows the input device, not the width: a narrow desktop window keeps the focus.
- **Keyboard.** Enter sends (or queues while a turn runs) wherever a keyboard is attached, a narrow desktop window included; **Shift+Enter** is the browser's newline and **Ctrl+Enter** / **Alt+Enter** put a newline at the caret. On a **touch-only** device (**`(any-hover: none) and (any-pointer: coarse)`**) Return stays a newline and this button sends, since a phone keyboard has no Shift+Enter; **`enterkeyhint`** is **`send`**, or **`enter`** there. Enter that confirms an input-method candidate never sends. Rule: **`chat/composerEnter.ts`**.
- Idle **disabled** when the message field is empty **and no sendable files are attached** (a disabled attachment under a non-multimodal model does not unlock Send); an active session turn shows **Stop** with an empty draft and **Queue** with text or sendable files, whether or not this tab has a stream reader (see **`docs/surfaces/web-ui.md`**, section **Composer primary action**).

### Composer message queue

A turn in flight no longer locks the composer. Enter queues in the saved `agent.queue_mode`, Tab in the other mode; the first use asks for the Enter preference. Each waiting card shows whether it steers the current turn or starts after it, and a click changes the mode. Text and attached images travel together. Behaviour and the wire contract: **`docs/features/message-queue.md`**, functional checklist: **`docs/surfaces/web-ui.md`** (**Composer message queue**).

- **Placement** — the queue is a **`ul.composer-queue`** (**`data-testid="composer-queue"`**) inside **`composer-wrap`**, directly **above** **`.composer-card`** and below the transcript, with **`6px`** between it and the card. It renders only when something is waiting; an empty queue draws nothing at all (no header, no placeholder).
- **A card** — **`li.composer-queue-item`** (**`data-testid="composer-queue-item"`**) is cut from the **frosted glass panel** the composer card is cut from (**`--coddy-glass-panel-bg`**, **`--coddy-glass-panel-backdrop`**, its border and shadow), **`12px`** radius, **`6px 12px`** padding, **12px** type at **1.45** line height in a **78%** text tint. The **12px** on the right is the improve-prompt wand's inset in the field's corner, so the card's **×** stands on the vertical line of the wand of the card below - the same 24px control at the same inset from the same edge (pinned by **`composerQueueCss.test.ts`**). It used to be a 6% wash of the text colour, nearly transparent over a dark transcript, so the words scrolling under the queue read through it. The rows stack in **reading order** with a **`4px`** gap: the top card is the one the agent reads first.
- **The text** clamps to **3 lines** (**`-webkit-line-clamp`**) and wraps on anything (**`overflow-wrap: anywhere`**), so a pasted path cannot widen the composer.
- **The cross** — **`button.sessions-close.composer-queue-remove`** (**`data-testid="composer-queue-remove-<id>"`**, accessible name **`Remove from the queue`**) is **the app's close control** (see **Close control** below) at **24×24px** with an **8px** radius and a **15px** **×**, in the card's **top-right corner** (**`align-self: flex-start`**). The text carries **3px** of top padding, which centres its first line on the 24px control: a one-line message sits level with its **×** at a **38px** card, a longer one hangs from the same line. Besides it the card holds one more control, the mode; the text itself is not clickable.
- **The mode** — **`button.composer-queue-mode`** (**`data-testid="composer-queue-mode-<id>"`**) names the message's mode, **Steer** or **After turn**, in the frame the first-use buttons share (the glass panel's border and fill, **8px** radius, **3px 8px** padding), and its title says what a click does; a click switches the mode through **`PATCH /coddy/sessions/{id}/queue/{message_id}`**.
- **The images** — a message with images shows **`span.composer-queue-files`** (**`data-testid="composer-queue-files-<id>"`**) before the mode: the paperclip (**`PaperclipIcon`**, the glyph of the composer's attach button, **12px**) and the count in the muted tint, labelled **N images attached**. Never a thumbnail and never an emoji: the queue names its images and never carries their bytes.
- **The first-use question** — while **`agent.queue_mode`** is unset, the first message written during a turn opens **`div.composer-queue-choice`** (**`data-testid="composer-queue-choice"`**, a labelled group) between the queue and the card, with **Steer now** and **After this turn**; the answer is saved as the Enter mode and sends the message, in the other mode when it was sent with Tab.
- **Taking a message back returns it to the field.** A queued message is still the operator's until the agent reads it, and taking it back is how it gets edited: when **`DELETE /coddy/sessions/{id}/queue/{message_id}`** succeeds, its text goes back into the composer - ahead of anything typed since, separated by a blank line - and its images go back into the attachments, from the **`inline_files`** of that answer (**`chat/dataUrlFile.ts`** decodes them, it never fetches a URL). A **404** means the agent read it first; it is already in the conversation and nothing returns.
- **The primary control** (**`#btn-send`**) keeps its circle and swaps only its meaning. While generating with a **non-empty** draft it carries the **play** glyph on an accent fill (**`.composer-run-icon--queue`**, **`data-queue="true"`**, accessible name **`Queue this message`**); with an **empty** draft it is the **stop** square exactly as before. Never render a third glyph for this, and never remove Stop: emptying the field is how a turn is cancelled.
- **The field** keeps focus and its content rules while generating; only the placeholder changes (**`composer.placeholderQueue`**).
- **State recovery** - session open and reconnect read **`GET /coddy/sessions/{id}/queue`**, without waiting for another queue mutation or a History row. HTTP responses and **`message_queue`** events carry a list and version from one server snapshot; **`QueueDeliveryOrder`** (**`chat/messageQueueState.ts`**) keeps the highest version per session for ordinary deliveries. A fresh queue GET captures the delivery revision and recovery epoch before the request. The SPA rejects a snapshot crossed by any queue delivery; otherwise it may establish a lower version (including **`0`**) after a server restart. A lower-version snapshot advances the local recovery epoch, so mutation responses and own/relay stream queue frames captured in earlier epochs are ignored. Replayed **`turn_started`** events preserve waiting messages and their version, and closing a reader does not clear the queue. These controls use the existing same-server routes and events; transcript relay and permission/question ownership stay unchanged.

### Chevron

The app has **one chevron**, **`<Chevron />`** (**`ui/components/Chevron.tsx`**), at **70%** opacity, that a transcript row folds with. It points **right while closed** and turns **down when open** (**`.coddy-chevron.is-open`**, a 90° rotation over 140ms, none of it under **`prefers-reduced-motion: reduce`**); a back control is the same mark pointing **left** (**`.coddy-chevron--left`**, the settings list forms' way back); a dropdown indicator is the same mark pointing **down while closed** and **up when open** (**`pointing="down"`**). The transcript row's **`.thinking-chevron`** and **`.coddy-chevron`** are **one component**, so they cannot drift apart: **`.thinking-chevron`** is a class on **`<Chevron />`**, not a pseudo-element with a glyph of its own. History group headings, the **Finished N** toggle of the Tasks panel, the History filter rows and the settings combobox all use it - the headings used to draw a small solid triangle, the Tasks panel a border triangle and the combobox a third glyph, and each read as a different kind of control. **Any new disclosure, submenu or dropdown uses `<Chevron />`**; drawing **`▸`**, **`▾`** or a border triangle is a defect, and **`chevronContract.test.tsx`** fails on one. Pager arrows (**`‹`** / **`›`** buttons of the settings tabs) and the **▶** of a Run control are not chevrons.

It is an **inline SVG**, never a text glyph and never a triangle. A **stroked polyline** on a **14×14** viewBox - the points **`5.5,4 8.5,7 5.5,10`** at **1.6** stroke width, **round** cap and join, **`currentColor`** - mirrors about the centre of that viewBox on both axes, so its ink is centred in the box by construction and the box is the only thing the CSS has to place. The reason is the whole history of this mark: as the text **`›`** its ink sat wherever the platform's font put it inside the line box, so the same CSS placed the chevron differently on every machine and every fix that nudged **`top`** was undone by the next font. **`chevronContract.test.tsx`** fails if the geometry stops being symmetric, if a stylesheet puts a chevron back into **`content`**, or if a second implementation appears; **`external/ui/scripts/chevron-align-check.mjs`** measures the result in a real browser, which is the only place layout exists.

Its vertical placement is **centred on the first line of the label beside it**, never on the row's box: **`.thinking-chevron`** is placed at **`top: 10px`** - half the **20px** line box of **`.thinking-label`** - with **`translateY(-50%)`**. The row's box is not one line tall on every row: a tool row aligns its 14px label, 12px monospace target and 12px duration on one baseline (**`.thinking-head`**), which lowers the smaller boxes by a pixel or two and makes the box taller than the label's line, and a phone wraps a background chip onto a second line. Centred on that box (**`top: 50%`**) the chevron sank below the label on every tool row while the thinking row kept it level. The two numbers move together; **`toolRowBaselineCss.test.ts`** holds the chevron's **`top`** to half the label's **`line-height`**, and the browser check above measures the chevron's ink centre against the ink centre of the label's text - within **1px** on a transcript row and on the Tasks panel's finished counter.

### Close control

Every **×** that closes or removes something is one control, **`.sessions-close`**: a framed square - **34×30px**, **12px** radius, a **10%** text-tint border on a dark **`rgba(20, 20, 22, 0.35)`** plate, the **18px** glyph in an **85%** text tint - whose plate **lightens under the pointer and on keyboard focus** (**12%** text mixed into the plate, an **18%** border, the full text colour, 150ms). The History, Scheduler, Tasks, Settings and folder-picker heads use it at full size; a queued message uses it at **24×24px** (**`.sessions-close.composer-queue-remove`**). Do not draw a bare glyph, a circle or a transparent hit area for a close action anywhere else. Covered by **`composerQueueCss.test.ts`**.

### Prompt improvement control

The prompt improvement control (**`data-testid="composer-enhance-btn"`**) is a compact **24×24px** icon button in the **top right corner of the field** (**`position: absolute`**, **8px** under the seam and **12px** from the card's right edge: the seam is lighter than the card's edge, so the same 12px above read deeper), so the field and its placeholder start at the top of the card, the first line's middle on the wand's; the textarea and its mirror keep **44px** clear on the right for it, and git's count on the plate above ends on its right edge. It never appears in the lower action bar. Its native tooltip and accessible name are both **`Improve prompt`**. It is disabled for a blank draft and while improving or generating; an active request spins the icon; on hover its ground and border brighten from the text colour, never the accent. Clicking it calls **`POST /coddy/enhance-prompt`**, replaces the draft only after a successful response, and leaves the original intact on failure. **Ctrl+Z** / **⌘Z** restores the immediately preceding draft after a successful improvement.

Composer mode selector

- **`GET /v1/models`** merges Coddy profiles and YAML backends in one list. Split by **`owned_by`**: **`coddy`** means session profiles **`agent`**, **`plan`**, and **`ask`** only. Any other **`owned_by`** marks a configured **`models[].model`** row (YAML backend).
- **`Mode`** lists only **`agent`**, **`plan`**, and **`ask`**. Default is **`agent`**. **`plan`** uses the orange outline treatment (**`mode-plan`**); **`ask`** uses the green one (**`mode-ask`**: **`rgba(34, 197, 94, 0.65)`** border with a matching **3px** halo). The pill label is localized (**`composer.modeAgent`** / **`composer.modePlan`** / **`composer.modeAsk`**).
- Selected mode is sent as top-level **`model`** in **`POST /v1/responses`**, and the server applies it to the session and keeps it. So the pill belongs to the session on screen: opening a session shows **`settings.mode`** of its snapshot, and a new chat - the **New chat** control and every address that names no session, the Back button to **`#/`** included - starts in **`agent`** with the default model and level (**`resetNewChatSettings`** in **`App.tsx`**), never in the mode of the chat just left. A mode picked on the start screen creates the chat in it. While another session is loading (**`sessionLoading`**) the selectors still show the session left: **Send** is **`disabled`** and **Enter** waits too (the **`sessionLoading`** prop of **`Composer`**; **`streamResponses`** refuses any other sender and gives the text and files back to the composer, and the queue's fallback sends through the latest render, **`sendLatest`**), so that session's mode, **`metadata.model`** and **`metadata.reasoning`** are not rewritten with the previous one's; a message for a running turn still joins the queue. The wait ends with the session's own snapshot (**`mirrorSettingsRead`**, shared by the transcript read and the settings-only read, or one the events stream brings). A read that fails, or that a later read took over, is tried again after 1, 3 and 9 seconds and then every 30 seconds, for as long as the session still waits and stays on screen (**`readOpening`**, **`chat/openingRead.ts`**), and only the server's **404** makes the address a new chat (**`settleUnknownSession`**), never a row missing from History, which holds one page: it starts on a new chat's settings when the selectors held another session's and keeps the start screen's picks when it is the chat this tab is creating. A visit that keeps its rows from the shadow of a turn this tab runs reads no transcript, so it reads the settings alone before the wait ends. Covered by **`App.settingsPerSession.test.tsx`** and **`Composer.sessionLoading.test.tsx`**.

Composer YAML **`models[].model`** selector

- **`Model`** sits immediately next to **`Mode`** in **`Composer.tsx`**. It lists only YAML backend rows (**`owned_by`** is not **`coddy`**). Opens **down** on the empty start screen (**`isEmpty`**) and **up** when docked over an active chat (same **`opens-down`** / **`opens-up`** convention as **`Mode`**).
- Default for **new chat** follows cookie **`coddy_llm_model`** when valid, else the alphabetically first YAML row (**`Path=/`**, long **`Max-Age`** on the cookie). The **`default: true`** row of **`GET /v1/models`** (the model a session with no selection runs on) is for API clients and the remote console and does not drive the UI's pick.
- Opening an existing session restores **Model** from the settings snapshot of **`GET /coddy/sessions/{id}/messages`** (**`settings.model`**, the session's own model; the top-level **`model`** names what a running turn holds), never from the cookie: the cookie and the start page's pick are the default of a new chat only, and shown for an existing session they would ride into it with the next message. Changing **Model** updates the cookie (default for the next **New chat**) and **`PATCH`** **`selectedModelId`** on the active session.
- For ReAct (**`agent`** / **`plan`** / **`ask`**), the UI sends **`metadata.model`** with the selected YAML **`id`**; the context-meter **`max_context_tokens`** for the ring follows that YAML row.
- **Long model lists** — backend ids are **`vendor/model`**. When more than one vendor is present the menu groups rows under an uppercase **vendor header** (**`mode-menu-group-label`**) and rows show only the model name (the full id stays in the row **`title`**). The vendors keep the order the configuration names them in, and **inside a vendor the models are sorted by name**, case aside and with numbers compared as numbers (**`orderLlmModels`**: **`gpt-5.6-luna`**, **`gpt-5.10`**, **`gpt-6-astra`**), so a model appended to **`models[]`** later lands where its name puts it instead of at the bottom of its group; the rows, the groups and the row **Enter** picks all come from that one order. On desktop the list is capped to roughly **5 rows** and scrolls (**`mode-menu-scroll`**, **`max-height: min(175px, 50vh)`**). When there are **more than 5** backends a **filter input** (**`mode-menu-filter`**, auto-focused) appears pinned above the scroll; it matches the query against the vendor, the model name, or the full id (case-insensitive). **Enter** selects the first match, **Escape** closes the menu, and an empty result shows a “No models match …” notice. The desktop menu width is constrained (**`mode-menu--llm`**). Helper logic lives in **`chat/llmModelMenu.ts`**.
- **Stays in step with the configuration** — the list behind this menu is read once, at boot, so a model added while the page is open would otherwise be invisible until a reload. **`GET /coddy/events`** carries **`event: config_reloaded`** after every swap of the live configuration (a settings save here or elsewhere, the agent's **`config_commit`** / **`config_rollback`**, a skill install); **`App.tsx`** bumps **`configEpoch`** on it and re-reads **`GET /v1/models`** together with the slash-command list. The menu changes under the operator without any chrome of its own: no toast, no badge, no reopen. The re-read never moves the current selection of an open session - only a chat with no session yet re-picks its default from the new list.
- **Mobile sheet** — on narrow/mobile shells (**`isMobileShell`**, the **`max-width: 1199px`** shell-stack breakpoint) the **`Mode`** / **`Model`** / **`Reasoning`** menus render as a **full-width bottom sheet** (**`mode-menu--sheet`**), the same family as the slash / **`@`** picker sheet, over a dimmed scrim (**`mode-menu-backdrop--scrim`**) instead of the cramped anchored dropdown. The sheet drops the desktop width cap (full width up to **560px**, centered) and gives the scroll a taller **`46vh`** cap. Desktop keeps the anchored **`mode-menu--portal`** dropdown.
- **Telegram Mini App** — inside Telegram (`html[data-telegram-mini-app="true"]`, set by **`ui/telegram/miniApp.ts`** from the launch parameters) the menus and pickers stay bottom sheets. What the stacked shell anchors to the bottom rises by the part of the WebView Telegram does not show (**`--coddy-telegram-hidden-bottom`**, a half-open Mini App) or by an overlaying keyboard (**`--coddy-keyboard-inset`**), whichever is more (**`--coddy-telegram-lift`**): the composer's menus, the slash, **`@`** and context pickers on the start screen, the confirmation dialog and the image viewer, each capped to the visible height under the top bar. The docked composer keeps its **`bottom`** on the keyboard inset and grows a block at the end of **`.chat-bottom-inner`** by the rest, so the transcript's reserve, measured on that block's content box, and the follow to the newest message take it in. Drawers keep their bottom, their head in sight. The top bar's inset (**`--coddy-mobile-bar-h`**) adds Telegram's safe area and content safe area (their sum). Telegram's header, background and bottom bar take **`--coddy-canvas-gradient-top`** / **`--coddy-canvas-gradient-bottom`**. Nothing sets **`overflow-x`** on **`html`** or **`body`**, which would stop **`body`** from scrolling the viewport and the sticky chat title from sticking. Telegram's Back is an Escape for the layer on top, and a layer hears it only through a listener on the document (**`useEscapeCloses`**): a click on Telegram Web's Back, a button of the parent page, leaves the frame's focus on **`body`**, as a tap on a sheet's title or on a picture does. The rules live in **`ui/telegram/telegram.css`**, every one under **`html[data-telegram-mini-app="true"]`**; contract **`ui/telegram/telegram.test.tsx`**, live check **`npm run check:telegram`**. The ordinary browser sheet is unchanged.
- **`Reasoning`** sits immediately next to **`Model`**, shown **only** when the active model row exposes a non-empty **`reasoning_levels`** in **`GET /v1/models`** (reasoning models). Same dropdown styling and **`opens-down`** / **`opens-up`** convention as **`Mode`** / **`Model`**; the button label is the current level capitalized (e.g. **`High`**), default label **`Reasoning`**.
- Default for **new chat** follows cookie **`coddy_llm_reasoning`** when valid for the model, else the model's **`reasoning_default`**, else **`medium`** (or the first offered level). Opening a session restores it from the snapshot's **`settings.reasoning`**, never from the cookie. A level the snapshot's **`reasoningChoices`** offer beyond the menu (**`off`** where the provider can turn thinking off) is shown and sent as it is (label **`Off`**); an empty level (a model with no **`reasoning_default`** and no pick of the session's) shows as the chooser resolves it, **`medium`** or the first level, and is not sent as **`metadata.reasoning`** until the operator picks one, so the session is not pinned to it. A later snapshot of the session goes through the same chooser, so it never blanks the chip, and a transcript read whose snapshot is older than the one applied moves nothing back. The menu of a session whose **`reasoningChoices`** name **`off`** offers **`Off`** after the model's levels. Switching **Model** clamps the level to one the new model offers. Changing it updates the cookie and **`PATCH`** **`selectedReasoning`**; ReAct turns also send **`metadata.reasoning`**. The rules live in **`pickReasoningLevel`** (**`chat/reasoningSelection.ts`**) and are held by **`App.sessionSettings.test.tsx`**.

Composer does not show tools toggles in this milestone.

### Slash commands picker (skills)

When the caret sits on the current composer line on a **`/`** that is **line-start or preceded by whitespace**, with optional `[a-zA-Z0-9_-]*` typed after it, and outside Markdown fences or blockquotes, the UI loads **`GET /coddy/slash-commands`** with a **100ms** debounce, required **`page=1`** and **`page_size=30`**, and optional **`prefix`** from typed characters after **`/`** (works mid-line, for example `say /foo`). Menu open/close rules match **`slashMenuDraftAtCaret`** in **`external/ui/src/ui/skills/draftSlash.ts`**.

- **Automation** uses **`data-testid="slash-command-menu"`**, per-row **`data-testid={`slash-command-row-${name}`}`**, and **`data-testid="slash-command-more"`** for paging.
- **Row layout**: each row is a single block button (**`.slash-row-btn`**, not a 2-column grid). The **`/name`** (**`.slash-row-name`**, bold) and the **description** (**`.slash-row-desc`**, muted) flow **inline inside one **`.slash-row-line`****: the description begins **right after the name**, wraps onto the next line, and the whole line is **clamped to 2 lines** (**`-webkit-line-clamp: 2`**) so overflow ends in an **ellipsis** — no separate right-hand description column.
- **Desktop** (**`slash-menu--floating`**) attaches above the textarea inside **`composer-card`**. **`Mobile`** (the stacked shell, **`max-width: 1199px`**) renders a backdrop (**`slash-sheet-backdrop`**) plus a bottom sheet (**`slash-menu--sheet`**). The composer's pickers dim nothing: their backdrop is **`slash-sheet-backdrop--clear`**, transparent, there only so a press outside closes the list (issue #342; the reader keeps seeing the chat and the draft while choosing).
- Choosing a row replaces the typed **`/`…** segment with **`/<name> `** (plain **`#composer`** value and wire text to **`POST /v1/responses`**). The UI **never** stores **`[/<name>](coddy-skill:<name>)`** in the composer draft.
- **Commands** group (**`data-testid={`command-row-${name}`}`**) comes from **`GET /coddy/commands`**: the settings commands first, then **`/compact`**, **`/export`**, **`/plugin`**. A command with an argument hint (every settings command, **`/compact`** with **`[-m|--model <id>] [-r|--reasoning <level>] [instructions]`** and **`/goal`** with its options) shows it (**`.slash-row-hint`**, **11px** monospace, **55%** muted) between the name and the description. Picked while it is the **whole draft**, a settings command goes to the control the composer already has for it instead of being typed: **`/model`** opens the Model menu, **`/reasoning`** the level menu, **`/permissions`** the permission menu, **`/agent`**, **`/plan`**, **`/ask`** switch the mode; the draft is cleared. Anything else (**`/think`**, a command in front of a message, **`--once`**) is typed and sent, and the server takes it off the text.
- While the draft is non-empty, **`Composer`** draws a **mirror layer** (see **Caret sync** below) and highlights slash tokens parsed by **`segmentComposerSlashSpans`** (**`external/ui/src/ui/skills/segmentComposerSlashSpans.ts`**) with **`span.composer-skill-chip-inline`** (**`data-testid="composer-skill-chip"`**).
- **`user_message`** bubbles show persisted text as-is (**`UserMessage`**, **`msg-user-body`**, **`white-space: pre-wrap`**). Skill mirror chips apply only in **`#composer`**, not in the transcript.
- **`Escape`** closes the menu; **`Enter`** confirms the first row when results are loaded and the menu is open (same turn as **`/`** autocomplete).
- A key an input method is composing with reaches no composer picker - slash, **`@`**, line-range or command option - nor the Ctrl+Z restore of the draft from before Improve prompt: the candidate is confirmed, no row is taken, the highlight stays and the keydown does not close the list. Composing means **`nativeEvent.isComposing`**, or **`keyCode` 229** within **`COMPOSITION_END_KEY_WINDOW_MS`** (100 ms) of **`compositionend`** (Safari ends the composition first; the next keydown consumes the mark, and one that comes 100 ms or more after it, as after a composition ended by a tapped candidate or a blur, is an ordinary key); any other 229, which Android keyboards send for ordinary keys, works the pickers as an ordinary key, while **`composerEnterAction`** still declines an Enter that carries it. The check opens the textarea's **`onKeyDown`**, so a picker added later inherits it.

### Mention picker (**`@`**)

An **`@`** at line start or after whitespace, an opening bracket or a quote opens the mention picker; it reuses the slash picker's chrome one for one - **`slash-menu-surface`**, **`slash-menu-scroll`**, the **`slash-menu--portal`** node on desktop and the **`slash-menu--sheet`** bottom sheet with **`slash-sheet-backdrop`** on stacked shells. Candidates come from **`GET /coddy/mentions`** (**`limit=50`**; the first query after it opens carries **`refresh=1`**). Draft logic: **`atMenuDraftAtCaret`** in **`external/ui/src/ui/skills/draftAt.ts`**; row helpers: **`external/ui/src/ui/skills/mentionRows.ts`**; functional checklist: **`docs/surfaces/web-ui.md`** (**Composer `@` mentions**).

- **Automation**: container **`data-testid="workspace-files-menu"`** (**`role="listbox"`**, aria label **`composer.workspaceFilesAriaLabel`**), rows **`data-testid={`mention-row-${kind}-${label}`}`** (label with non **`[a-zA-Z0-9_-]`** runs replaced by **`_`**) with **`data-at-idx`**, the cut count **`data-testid="mention-more"`**.
- **Title row** is the shared **`slash-menu-title`**, **`composer.workspaceFilesTitle`** (**Mention** / **Упоминание**).
- **Rows** are **`.slash-row-btn.mention-row`** inside one **`.slash-row-line`** (two-line clamp, as the slash rows): a kind label **`.mention-kind`** first - **10px** uppercase text at **16px** line height, **44px** minimum width, **6px** radius, a **7%** text-tint plate with **62%** text; the meta kinds (**`.mention-kind--session`**, **`--rule`**, **`--agent`**, **`--plan`**, **`--doc`**, **`--scheme`**) take the accent on a **14%** accent plate - then the label in **`.slash-row-name`** (a scheme hint prints **`@session:`**), then **`.slash-row-desc`**: nothing for a path (its label is the whole path), the id and date for a session, the description for a rule or a subagent, a localized sentence for a scheme hint.
- **Highlight**: **ArrowUp** / **ArrowDown** move **`is-active`** (the slash picker's accent tint) and **`aria-selected`**, wrapping, and scroll the row into view; the pointer moves it too (**`onMouseEnter`**). **Enter** and **Tab** take the highlighted row, while a turn runs as well; **Escape** closes the picker.
- **Taking a row** replaces **`@`** plus the query with its **`insert`**. A folder or a scheme hint (**`continue`**) adds no space and the picker stays open on the new query; anything else ends the mention with a space.
- **Cut list**: when the server matched more than it returned, **`.mention-more`** on the right of the title row (**`.mention-title`**) says **50 of 1204, type to narrow** (**`composer.mentionMore`**): the rows scroll, the title is what the reader sees when the picker opens. The **`.at-range-menu-hint`** footer (a **`:`** after a file picks lines) shows only while a file row is listed.
- **Empty query**: recent picks of the session (**`localStorage`**) first, then the server's scheme hints and the top of the workspace; **`Type after @ to search`** only when the list is empty, **`Nothing matches`** for a query with no match.
- **Chips in the draft**: a typed mention takes the **`composer-at-chip-inline`** chip only once **`POST /coddy/mentions/check`** has said sending would attach it, and only over the part that resolves; a token that names nothing (**`@google/genai`** in **`npm install @google/genai`**, a handle) keeps the text colour. The draft at the caret keeps its chip while the picker is open on it, and a picked row is chipped at once.

### Line-range picker (**`@path:N-M`**)

A workspace mention can narrow a file to a **1-based inclusive** line range, **`@Dockerfile:21-31`**. The composer text stays the only input: typing the **`:`** after a file token closes the **`@`** mention picker (**`:`** is not a **`MENU_PATH_CHAR`** outside the **`session:`** / **`rule:`** / **`agent:`** hints) and the range panel takes its place, reusing the picker chrome one for one - **`slash-menu-surface`** plus **`slash-menu-scroll`**, the **`slash-menu--portal`** node under **`document.body`** on desktop and the **`slash-menu--sheet`** bottom sheet with **`slash-sheet-backdrop`** on stacked shells. Draft logic lives in **`external/ui/src/ui/skills/draftAtRange.ts`**; functional checklist in **`docs/surfaces/web-ui.md`** (**Line ranges**).

- **Automation**: root **`data-testid="at-range-picker"`** with **`role="group"`** (the file and slash pickers keep **`listbox`**), rows **`data-testid={`at-range-line-${n}`}`** with **`data-line`**, list **`data-testid="at-range-lines"`**, current range **`data-testid="at-range-current"`**.
- **Title row** is the shared **`slash-menu-title`**: the caption, then the path in **`.at-range-title-path`** (600 weight, no uppercase transform), then the typed range as an accent pill **`.at-range-title-range`** (**`color: var(--accent)`** on a 16% accent tint, tabular numerals). A hint line (**`.at-range-hint`**, **`slash-muted`**) explains the entry: type the range or, on desktop, click and drag the rows; the mobile copy only asks to type it.
- **Rows** (**`.at-range-lines`** > **`.at-range-line`**) are one logical line each in the monospace stack (**`--mono`**, 12px, line-height 1.5): a right-aligned gutter **`.at-range-line-no`** (min 3ch, 50% opacity, tabular, not selectable) and the code in **`.at-range-line-code`** with **`white-space: pre`**. Long lines never wrap; **`.at-range-lines`** scrolls horizontally inside the panel so the page keeps **`scrollWidth === clientWidth`**. Selected rows use **`.at-range-line--sel`** (accent at 18% over **`--coddy-blend-base`**); a start typed without an end highlights that one line.
- **Desktop** rows are **`button`**s (pointer cursor, 6% text-tint hover): **`mousedown`** anchors the range and calls **`preventDefault`** so focus stays in **`#composer`**, dragging over rows extends it, and every step rewrites the **`:N-M`** suffix through **`replaceAtRangeSuffix`**. On **`isMobileShell`** the rows are plain **`div`**s with no hover or pointer affordance - there is no mouse to drag with, so the range is typed.
- The panel counts as **open** only once the file loaded (**`GET /coddy/workspace/file`**, one fetch per settled path), so a colon in prose never flashes an empty panel; a path that does not resolve leaves it closed. **`Escape`** dismisses it for that mention, **`Enter`** is left alone and still sends. The **`@`** file menu gains one footer line **`.at-range-menu-hint`** (top border at 12% text tint) telling the user that **`:`** after a file picks lines.
- Truncation: the route caps the preview (default 2000 lines); a truncated file adds a **`slash-muted`** line naming how many of the total lines are shown.

#### Composer mirror and caret sync (contract)

The textarea uses **transparent** glyphs when the draft is non-empty; the user-visible line is the **mirror** (`.composer-mirror-inner`) that must be **pixel-aligned** with **`#composer`** for the same string. The **caret** position is computed **only** by the textarea engine on the raw characters, so any styling in the mirror that **changes horizontal advance** of the same code points **breaks** perceived caret placement.

Rules for **`.composer-skill-chip-inline`** (composer only):

- **MUST** use the **same effective font metrics** as **`#composer`**: **`font-family`**, **`font-size`**, **`line-height`**, **`font-weight`**, **`letter-spacing`**, **`font-style`** inherited or explicitly matched (current gate: **400** weight on both mirror and textarea in **`styles.css`**).
- **MUST NOT** add **horizontal** **`padding`**, **`margin`**, or a **`border`** that participates in the inline box width. Use **`box-shadow: 0 0 0 1px …`** for a ring and **`padding: 0`**, **`margin: 0`** so chip width tracks the underlying `/name` glyphs.
- **MUST** keep **`scrollbar-gutter: stable`** on **`#composer`** and on **`.composer-mirror`** (with **`overflow: hidden`**), and the textarea's padding on **`.composer-mirror-inner`** as the stylesheet sets it. The browser then takes the same scrollbar width out of both text boxes, to the fraction of a pixel, at every page zoom and screen density. Do not size the mirror's padding from **`offsetWidth - clientWidth`** in script: it rounds to whole pixels and goes stale when the zoom or the density changes, because a classic scrollbar keeps its device pixels (13.6 CSS px at 110%, 8.6 at 175%). That is what put the caret beside the text it stood in, and the mirror's old **`16px`** plus the scrollbar, written before the wand took **44px** on the right, left it 28px wider than the field.
- **MUST** scroll the mirror as a scroll container of its own (**`mirror.scrollTop = textarea.scrollTop`** on the field's **`scroll`**), never by a **`transform`** of its text: the two snap a fractional offset (214 CSS px at 125% is 267.5 device px) to the same device pixel, a translated layer does not. The mirror takes the textarea's own height (**`getComputedStyle(textarea).height`**, 131.5px for five rows), because the stack under the inline-block textarea is a few pixels taller and a mirror filling it showed the next line where the field had clipped it.
- **MUST** line the mirror up again on a window **`resize`** and on a change of **`(resolution: <dpr>dppx)`**: a page zoom or a window dragged to a screen of another density lays the text out again without resizing the field in CSS pixels, which is all its **`ResizeObserver`** reports. **`npm run check:caret`** measures the result in a real engine (**`docs/surfaces/web-ui.md`**, *Checking the composer caret at every zoom*).

Transcript chips (**.md .coddy-skill-chip**) are **not** bound by this contract; they may use monospace, heavier weight, and pill padding because they are not paired with a transparent textarea.

Full browser checks against a running **`coddy serve`** instance (including a **mobile viewport**) use **Playwright MCP** in Cursor, Codex or any other code agent you use. This repository does not ship **`@playwright/test`** as an npm dependency.

**Frosted glass (Playwright MCP smoke)** - after **`npm run dev`** under **`external/ui/`** (or **`coddy serve`** with **`make build TAGS="http ui"`**), use **`browser_tabs` / `browser_navigate`** to the SPA, then **`browser_evaluate`** **`getComputedStyle(...).backdropFilter`** and **`.backgroundColor`** on:

| Target | **`backdrop-filter`** | **`backgroundColor`** (example) |
| --- | --- | --- |
| **`.composer-card`** | **`blur(…) saturate(…)`** from **`--coddy-glass-panel-backdrop`** | tinted rgba from **`--coddy-glass-panel-bg`** |
| **`.sessions.drawer`** (open **History**) | same as composer row | same |
| **`.mode-menu`** (open **Mode**) | same | same |
| **`.slash-menu-surface`** (inside **`data-testid="slash-command-menu"`**) | same | same; scroll **`slash-menu-scroll`** only (**`slash-menu-surface`** carries blur). On **desktop** (viewport **`min-width: 1200px`**) the menu root classes include **`slash-menu--portal`** and the node renders under **`document.body`** so **`backdrop-filter`** sees chat behind the composer. The mobile bottom sheet stays inside **`composer-card`**. **`--coddy-z-slash-command`** keeps slash UI stacking **below** History **`backdrop`** and **`sessions.drawer`**. |
| **`.backdrop`** (History open) | **`none`** | dim from **`--coddy-overlay-scrim-bg`** only |
| **`.slash-sheet-backdrop`** (the context and goal sheets on the stacked shell, **`max-width: 1199px`**) | **`none`** | dim only |
| **`.slash-sheet-backdrop--clear`** (the composer's pickers on the stacked shell) | **`none`** | **transparent**: a press-outside catcher, no dim |

Docked chat (transcript visible) uses the same **`.composer-card`** rule as the hero composer.

**`.messages-inner`** uses **`padding: 0`** so bubbles line up with **`#composer`** horizontal inset (composer card still spans the full **`max-width: 920px`** track).

**Corner radius** for composer, History drawer, **`slash-menu-surface`**, **`mode-menu`**, and the bottom sheet chrome uses **`--coddy-glass-panel-radius`** (**`18px`**) so skills dropdown reads as the same family as composer and History.

#### Slash skills verification use cases

Use these to regress behaviour after CSS or **`Composer`** edits. **Vitest** rows are under **`external/ui/src`**.

| ID | Scenario | Expected | Automated check |
| --- | --- | --- | --- |
| UC1 | Type `asdfasf /find-skills asdfasdf` in **`#composer`** | One mirror chip **`/find-skills`**, **`textarea.value`** exactly that plain string (no markdown) | **`external/ui/src/ui/chat/Composer.test.tsx`** · `composer highlights plain slash token as chip while editing` |
| UC2 | Open slash menu mid-line | Menu draft open; **`prefix`** from chars after **`/`** | **`external/ui/src/ui/skills/draftSlash.test.ts`** · `slashMenuDraftAtCaret works after whitespace mid-line` |
| UC3 | Token **`x/foo`** | Whole token plain text slice (no chip for **`/foo`**) | **`external/ui/src/ui/skills/segmentComposerSlashSpans.test.ts`** · `segmentComposerSlashSpans skips letter before slash` |
| UC4 | Line-leading **`/foo`** | Single **`slash`** segment **`/foo`** | **`segmentComposerSlashSpans.test.ts`** · `segmentComposerSlashSpans line start slash` |
| UC5 | Strip legacy **`a [/demo](coddy-skill:demo) b`** | Output **`a /demo b`** | **`segmentComposerSlashSpans.test.ts`** · `stripCoddySkillMarkdownLinks restores plain slash token` |
| UC6 | User bubble **`hi /demo there`** | Plain text, no **`coddy-skill-span`** | **`external/ui/src/ui/messages/UserMessage.test.tsx`** |
| UC7 | Multiline YAML in user bubble | Line breaks preserved in **`user-message-body`** | **`external/ui/src/ui/messages/UserMessage.test.tsx`** |
| UC7b | Display-only slug transform (composer / legacy helpers) | Plain **`/`** → autolink form in **`slugSlashesForUserBubbleMarkdown`** unit tests only | **`segmentComposerSlashSpans.test.ts`** |
| UC8 | Live **`coddy serve`** after **`make build TAGS="http ui"`**, **`#composer`** with **`/coddy_slash_demo`** | **`textarea.value`** plain; **`fontFamily`** chip **===** **`#composer`**; EOL **`selectionStart === value.length`** | **Playwright MCP** · **`browser_navigate`**, **`browser_fill_form`**, **`browser_evaluate`** |

### Command option picker (**`/compact --model`**, **`/goal --reasoning`**)

Once **`/compact`** or **`/goal`** opens the draft, the composer completes the options the command takes and their values; it is the third face of the picker chrome - **`slash-menu-surface`**, **`slash-menu-scroll`**, the **`slash-menu--portal`** node on desktop and the **`slash-menu--sheet`** bottom sheet with **`slash-sheet-backdrop`** on stacked shells - and it sends no request. Draft logic: **`commandArgDraftAtCaret`** in **`external/ui/src/ui/skills/draftCommandArg.ts`**, which mirrors **`parseCompactCommand`** (**`internal/agent/compact.go`**) and **`ParseGoalCommand`** (**`internal/session/goal_command.go`**) down to the separators allowed after the command word and the short spellings **`-m`** (**`--model`**) and **`-r`** (**`--reasoning`**); functional checklist: **`docs/surfaces/web-ui.md`** (**Composer command options**).

- **Automation**: container **`data-testid="command-arg-menu"`** (**`role="listbox"`**, aria label **`composer.commandArgAriaLabel`**), rows **`data-testid={`command-arg-row-${value}`}`** (non **`[a-zA-Z0-9_-]`** runs replaced by **`_`**) with **`data-arg-idx`**.
- **What opens it**: a token starting with **`--`** in the options offers **`--model`** and **`--reasoning`** (a lone **`-`** opens nothing: the instructions may be a list) (title **`composer.commandArgOptionsTitle`**, each row carries its description as its **`.slash-row-desc`**: **`composer.commandArgModelFlagDesc`** / **`composer.commandArgSummaryReasoningFlagDesc`** for **`/compact`**, **`composer.commandArgGoalModelFlagDesc`** / **`composer.commandArgReasoningFlagDesc`** for **`/goal`**); the value after **`--model `**, **`--model=`** or **`-m `** lists the models of the composer's Model menu (**`props.llmModels`**, title **`composer.commandArgModelsTitle`**, **`composer.commandArgGoalModelsTitle`** for **`/goal`**), filtered by **`filterLlmModels`**, each row its full id in **`.slash-row-name`** and nothing else; the value after **`--reasoning `**, **`--reasoning=`** or **`-r `** lists **`default`** and the levels of the model the draft's **`--model`** names, else of the session's (**`commandReasoningChoices`**, title **`composer.commandArgSummaryReasoningTitle`** for **`/compact`**, **`composer.commandArgReasoningTitle`** for **`/goal`**). A bare **`/compact `**, a complete value, an option typed where the value goes and the instructions open nothing, so **Enter** still sends.
- **Empty filter**: the model list stays open with **`composer.noModelsMatch`** in **`slash-muted`**, and **Enter** is left alone, so the command goes to the server, which answers with the configured ids.
- **Keys** follow the mention picker: **ArrowUp** / **ArrowDown** move **`is-active`** and **`aria-selected`**, wrapping, and scroll the row into view; the pointer moves it too; **Enter** and **Tab** put the row into the draft followed by one space; **Escape** closes the picker and leaves the draft alone. Picking **`--model`** opens the models at once. A key an input method is composing with never reaches it (see the slash picker).

### Markdown

**Assistant** messages may contain Markdown. **User** bubbles do not (plain **`pre-wrap`** text).

- **Nothing in an answer is wider than the transcript.** **`.md`** breaks a long link, path or word where it has to (**`overflow-wrap: anywhere`**). Inline code (**`.md-inline-code`**) is an **18px** chip - a **14px** line box and **3px** + **1px** of padding, the text's middle **10px** under the top edge - at most a line wide, and a long identifier wraps inside the chip, **14px** a line; the chip used to carry a **10px** line box, which overlaps the next line as soon as the text wraps. Tables (**`.md-table-scroll`**) and fenced code keep their width and scroll in their own box (**`transcriptWrapCss.test.ts`**).

- **`coddy-skill:`** chips appear only in the **composer mirror** while editing, not in persisted user bubbles.
- Render fenced code blocks with syntax highlighting.
- The explicit language registry and aliases live in `external/ui/src/ui/markdown/syntaxLanguages.ts`. It extends the common grammars with the languages covered in [the NeuralDeep audit](docs/contributing/syntax-highlighting-audit.md); six additional grammars retain pinned upstream sources and licenses under `ui/markdown/grammars/`. PL/SQL, OpenCL, and CUDA use base SQL/C/C++ highlighting. Unsupported labels remain literal text; never guess a related language silently.
- Treat `vue` as an HTML/XML grammar alias for component tags, attributes, and comments, with standard embedded JavaScript and CSS highlighting. This is not a Vue compiler: interpolation expressions and alternative `lang` preprocessors do not receive dedicated grammars.
- Treat the `postcss` fence label as a CSS grammar alias; retain the original label and source for rendering and copying. Plugin-specific PostCSS extensions have CSS-level highlighting only.
- Use the declared fence language (`js` / `javascript`, `ts`, `css`, `html`, `json`, `python`, `go`, and other bundled highlight.js common languages). Unknown or unlabelled fences remain literal text without language guessing; incomplete streamed fences still render safely.
- Syntax colors use the `--syntax-*` semantic palette in each of the seven appearance themes. Keywords, strings, numbers, titles, attributes/selectors, types, comments, metadata, and deletions follow the active theme immediately, including already-rendered responses. Keep token selectors scoped to the views that colour code, never bare: `.md-code` (the chat's code blocks), `.files-code` (an open file of the Files window) and `.dv-code` (a diff of the edits window), one `:is()` list each, held by `files/filePreviewCss.test.ts` (the two windows carried the classes without the colours until it was).
- Each code block has a copy button in the top right corner that copies only the block contents. The block keeps its own line box (**12px** at **1.5**, whatever the text around it) and the button sits on the middle of its first line (**`top: 3px`** for a **26px** button over **1px** border, **6px** padding and an **18px** line), so a one-line block has it centred (**`codeCopyButtonCss.test.ts`**).

### Diagrams and formulas

A **`mermaid`** or **`svg`** fence and a display formula (**`$$...$$`**, **`\[...\]`**, a **`math`** fence) are a **figure** (**`.md-figure`**), not a code block; an inline formula (**`$...$`**, **`\(...\)`**) is typeset in the line. Behaviour: **`docs/surfaces/web-ui.md`** (**Diagrams and formulas**).

- **Frame.** The code block's frame: **14px** radius, the same hairline border, **`--coddy-surface-inset`** behind it, **12px** above and below. It never grows wider than the transcript.
- **Head** (**`.md-figure-head`**): the kind on the left (**12px**, muted), the actions on the right in one row, all in the **`md-copy`** chrome and set in the row rather than floating over the content: the formats the figure saves as, named by extension (**`.md-figure-format`**: **MMD** for the Mermaid source and **SVG** for the drawn picture; an SVG fence has **SVG** only, its source), then the source switch (**`.md-figure-source-toggle`**), an icon of angle brackets with **`aria-pressed`**: off while the picture is shown (the default), lit with a **16%** accent tint and a **45%** accent border while the source is. A formula has the source switch only. The head wraps on a narrow column; the actions keep together at its right edge.
- **Picture** (**`.md-figure-picture`**): centred with **12px** around it, at most the column wide and **min(70vh, 640px)** tall, proportions kept, **`cursor: zoom-in`**; it is a button that opens the image viewer (**`ImageLightbox`**). The picture is an `<img>` of a `data:` SVG, never SVG in the DOM.
- **Source view and errors** (**`.md-figure-source`**): the source as a code block body (**12px** at **1.5**), scrolling in its own box once it is longer than twenty lines (**`max-height`** of **20 × 18px** plus its padding), with the copy button in the box's top right corner, clear of the scrollbar, as on a code block; copy lives only here. A failed draw puts one **12px** line in **`--coddy-danger`** between the head and the source, and the source switch is pressed and disabled.
- **Formulas.** A display formula scrolls sideways inside **`.md-math-display`**. An inline formula (**`.md-math-inline`**) is KaTeX at **1.1em**, **`cursor: copy`**, an accent tint on hover and for a moment after it was copied, and its source as the tooltip; until KaTeX has loaded it shows its source in the inline-code font.
- **Colours.** A Mermaid diagram is drawn with Mermaid's **`base`** theme and every colour a diagram type draws with is set from the active appearance (**`mermaidThemeVariables`** in **`ui/markdown/pictureRender.ts`**), never left for Mermaid to derive (that gave near-black pie slices and invisible edges on dark themes). Tokens are resolved by the browser and composited onto **`--coddy-surface-inset`**, so a **`color-mix()`** or an alpha token works. Fills are the accent or a categorical colour mixed into the surface (**22%** for nodes, **35-45%** for Gantt bars and mindmap sections) under **`--text`**; lines and edges are **`--text`** at **70%**; marks that carry a label (pie slices, git branches, chart series) take the categorical colours at full strength - the theme's **`--accent`** and **`--syntax-*`** palette, deduplicated and each pushed away from the surface until it contrasts with it by **4.5:1** - and are labelled in the surface colour. **`darkMode`** for every theme but **light**; drawn again on a theme switch. Contract: the contrast tests in **`DiagramsAndMath.test.tsx`**; live check **`npm run check:diagrams`**.

### Memory tree (deferred explorer)

A file-tree over combined **global** (**`memory.dir`** / `$CODDY_HOME/memory`) and **workspace** (`<cwd>/memory`) remains out of scope for this milestone.

### Memory runs

When **`memory.enable`** is true, each user turn starts a memory subagent in the background task pool. The transcript shows nothing for it: the run is an invisible **`memory_run`** item of the turn (from the `memory_run` events of the stream, `chat/memoryRun.ts`) that the live status line reads (**Working with memory** while it is in flight). Its record is the **Tasks panel**: an agent task whose card carries the **`memory`** tag (**`bgtask-tag`**, keyed on **`agent.system`**) where a `spawn_agent` child carries its agent's name, the first line of the message as its title, **Show transcript** to the read-only child session on the card itself, and the child's log in the open card.

### Component boundaries

The UI should be implemented as small React components with folder-enforced hierarchy.

- `ui/layout/Shell`
- `ui/nav/NavRail`
- `ui/sessions/SessionsSidebar`
- `ui/chat/ChatScreen`
- `ui/messages/MessageList`
  - `ui/messages/UserMessage`
  - `ui/messages/AssistantMessage`
  - `ui/messages/ThinkingMessage`
  - `ui/messages/ToolCallMessage`
- `ui/chat/PlanDocumentSection`
- `ui/markdown/MarkdownLineEditor`

### Session overflow menu (`…`)

Opens lightweight rename/delete UX (prompt-first until richer modals arrive).

### Swarm screen (`ui/swarm/SwarmView.tsx`)

Shown at **`#/swarm`** in any environment that answers **`GET /swarm/info`**, and as the **home
screen** when the environment is a relay itself. It is the only screen a relay has, and the map
is the screen: there is no list of nodes under it, because everything the list did the map does.

- **Relay as home.** A relay serves no sessions, no workspace and no model - of an agent's API it
  serves only its own settings (**`/coddy/config*`**, see **The relay's own Settings** under **Settings drawer**). **`App.tsx`**
  tracks this as **`atSwarmRoot`** (the swarm probe answered and the environment is not a node
  reached *through* a relay). While it is true the composer and **`ChatScreen`** are not rendered,
  and the rail hides **History** and **Scheduler** - a drawer of sessions that cannot exist is
  furniture for a room nobody can enter. The map is the home screen: the **Swarm** entry of the
  rail stays lit while the map is on screen, whichever address shows it, and the brand, which
  starts a new chat on an agent, leads to the map (a relay has no chat to start). A relay that
  serves its own page has no documentation either, so there the rail leaves **Docs** out (the
  reader reads the page's own server, see **Documentation screen**).
- **The map is the way in.** Clicking a node in the graph switches the app to it: the app repoints
  at that node through the relay (**`connectSwarmNode`**, landing on **`#/swarm`**) and the map
  stays open over it, the node now ringed; every ordinary screen - chat, History, Scheduler,
  Settings - then works against it, and what to do there is the person's next click. The click
  does nothing more: not even a node that asks a question opens that session by itself. A relay on
  the map opens as a relay (**`connectSwarmRelay`**), never as a node whose screens it does not
  have: one chained under this one opens on its own map through this one's mount, and the relay
  the map is drawn for is enterable only from a map opened over a node, where a click on it
  connects to the relay. Where the app already is - the node it is on, the relay on its own map -
  is not a button: a click there would go nowhere. A node with no route is not clickable either.
  Enter and Space do what a click does, and an enterable node takes a visible focus ring.
- **A switch does not blink.** Between two remotes (a node and another node, a node and its relay)
  the page is not reloaded: **`switchTo`** (**`env/remoteEnv.ts`**) sets the environment and the
  address and runs what the modules that hold something of the old one registered with
  **`onEnvironmentSwitch`** (the Settings copy, the server's version, the host shell, the active
  environment's health), and **`EnvScope`** (**`main.tsx`**) starts **`App`** over, keyed by the
  events stream's scope (**`serverEventsScope`**: the server and a fingerprint of its token) and
  the count of switches in place, so the same remote chosen again and a token rotated under the
  one in use start it over as well. What would otherwise start as "not known yet" and pop in a moment later is
  kept for the life of the page (**`env/pageMemory.ts`**): the page's own server and its host name,
  which remotes are relays, whether the scheduler answered, and the last picture of each relay's
  map, which a remounted map draws at once and then reads again. To or from **Local** the page
  still reloads, since the sign-in of the page's own origin comes and goes with it.
- **The map over a node.** Inside a node the **Swarm** entry opens the relay's map over the node
  without leaving it: the environment stays the node's mount, the map reads the relay directly
  (**`SwarmView`** **`relay`**, **`swarm/api.ts`** **`RelayTarget`**: the relay's address and the
  client token the mount takes), and History, Scheduler and the chat stay where they are, so
  nothing reloads and nothing blinks. History and Scheduler lie under the map in the stack of
  the rail's screens, so opening either from the rail closes the map rather than opening out of
  sight under it.
- **Where we are, and how we got there.** Inside a node the map marks it (**`env.swarmNode`**): it
  carries a ring, and every edge on its route is drawn as the live path, with everything
  off the route receding. The ring is the mark: no line of words says it again. On the relay
  itself (**`rootCurrent`**, the app on the relay's home) the relay's card carries the ring, and
  the wire from this machine, when the map draws it, is the live path. Hovering or focusing another node previews its route the same way,
  weaker.
- **The machine the page runs on.** When the page's own server is an agent (not a relay serving its
  own page) and the app is on a remote environment, the map draws that machine one tier above the
  relay, wired to it - it is where the connection starts: a disc with a screen glyph, named by its
  host name (**`GET /coddy/info`** of the page's server; **Local** when the system reports none),
  on a tier the spine calls *this machine*. The live route to the node the app is on starts there. A click on it
  goes back to Local (**`connectLocal`**). Layout: **`layoutTopology(topology, {client})`**
  (**`swarm/layout.ts`**) lifts every tier of the swarm by one and adds the client and its wire,
  which leaves the disc below its name and address, not through them.
- **What the swarm is doing.** **`GET /swarm/sessions`** carries **`turnActive`** and
  **`permissionPending`** per session; **`nodeActivity`** (**`swarm/routes.ts`**, pure and tested)
  folds them per node path. Under each node's meta line: nothing when it holds no sessions,
  a session count when it is idle, a running count when a turn is in flight, and *needs an answer*
  when anything there waits on a permission prompt - that state wins, because it is the one that
  needs a human. A running node pulses slowly, a waiting node pulses sharply in a different
  rhythm, and the hops on the route to a running node carry a travelling dash. Every one of those
  is driven by that live data and by nothing else, and
  **`@media (prefers-reduced-motion: reduce)`** removes all of it, leaving the states carried by
  the copy and a static ring.
- **Dock.** The map opens in a dock as wide as the documentation reader's and centred the same way:
  both take **`width: var(--coddy-dock-width)`**, defined once on **`.shell`** as
  **`min(1350px, …)`**, and the map fills its dock (**`.swarm-view`** has no width cap of its own),
  so a wide screen shows no bare strips beside it. Pinned by **`SwarmView.test.tsx`** (**opens in a
  dock as wide as the documentation reader's**) and **`docsReaderCss.test.ts`**.
- **Dock on a phone.** Below **1200px** the shell's backdrop rises to **`z-index: 60`** and **`#/swarm`** opens it, so **`.swarm-dock-cluster`** takes **70** there, like the settings drawer and the documentation reader, and starts under the top bar the rail becomes (**`top: calc(var(--coddy-mobile-top-inset) + 6px)`**, the side insets clearing the safe area). Under the backdrop every tap on the map, the search box or a node landed on the backdrop and closed the screen. Pinned by **`SwarmView.test.tsx`** (**takes taps on a phone**) and **`features/swarm_web_ui.feature`**.
- **Header.** Title (relay name) and a subtitle counting relays, agents and offline nodes, then
  **`.swarm-header-actions`** with the close control. The environment is not here: it is the
  rail's item (**Environment item and menu**), on every Swarm screen too, its empty and error
  states included, so a relay that needs a token can still be given one. Pinned by
  **`nav/EnvironmentSwitcher.test.tsx`** and **`features/web_ui_menus.feature`**.
- **Canvas layout and persistence.** The graph opens in **Tree layout**: the rooted, tiered view
  with its hop spine. **Graph layout** is a deterministic rooted graph for reading rings and
  cross-links. It trends down by shortest-route depth without placing every hop on a rigid row, and
  draws its links as smooth curves. Its fixed, UUID-ordered solver makes the same topology land in
  the same places on every poll rather than jittering as the relay enumerates it. The relay is the
  pinned root, or the local computer is the root when that connection-start node is present. The
  choice is browser state only, stored in localStorage as **`coddy_swarm_layout`**; a saved legacy
  `star` value migrates to Graph, and an absent, blocked or invalid value means Tree. It never
  changes server configuration or another browser's choice.
- **Canvas camera.** The graph is fitted into its viewport on first open and whenever the relay or
  layout changes. The **Tree** and **Graph** controls select the layout; **Zoom out**, **Fit graph**
  and **Zoom in** control the camera. Pointer-wheel zoom is centred on the pointer, dragging pans
  across both axes even in the fitted view, and a two-finger pinch zooms around the pinch midpoint;
  a drag or pinch never also enters a node. When the canvas has focus, **`+`** / **`=`** zoom in,
  **`-`** zooms out and **`0`** fits. Zoom never goes below the fitted view or above three times
  that scale, and panning remains clamped so the graph cannot vanish completely. The controls are
  **40px** touch targets on the stacked shell. A polling update fits an untouched graph to its new
  bounds, but preserves a manually panned or zoomed camera (clamping it only if the bounds moved);
  Fit explicitly resets that manual view.
- **Search, not filter.** The box goes to the relay, which fans out, so a query reaches machines
  this browser cannot dial. With a query, matching sessions appear as rows under the map, each
  naming its node and route, and a row opens that session on that node. With no query there are
  no rows at all.
- **Distinct empty and error states.** **`/swarm/info`** is public, so a credentialed relay answers
  the probe and refuses everything else; the view then shows **`.swarm-error`** asking for a token
  rather than reporting an empty swarm. Nodes that did not answer are listed in
  **`.swarm-warnings`** above the map rather than silently dropped.
- **The topology graph** is a hand-rolled SVG (**`TopologyGraph.tsx`**, layout in
  **`swarm/layout.ts`**). A relay is a card carrying an accent-filled tile with the router mark; an
  agent is a circle with its name on a chip below. Hops are orthogonal elbows that leave the
  bottom, turn on a rail shared by the children of one parent, and arrive at the top, with no
  arrowheads: the tiers say which way a hop runs. A same-tier link between peers is a bow that
  leaves and arrives at the sides; a link that skips a row goes round the outside in a lane clear
  of every card. A wire is named (a chip on it) only when the relay reaches the node under a mount
  name other than the node's own, which the chip under the node already shows. Depth is stated in
  the left gutter - a dashed upright with a tick, a hop caption and a node count per tier. Every
  state is said twice, never in colour alone: the route in use is solid, heavier and in the
  accent, a way round a ring is grey and dashed, a link into an offline node is coarsely dashed and
  dimmed, and a node that dials out carries a badge as well as a dotted wire. Every relay on the
  active route is outlined, while the current relay has the stronger current-node ring. The SVG is a
  **`role="group"`** labelled by the map's name - not **`role="img"`**, which would collapse the
  subtree and hide the interactive per-node controls it carries - and a visually hidden paragraph
  added through **`aria-describedby`** names each tier, its nodes, where the app is and what is
  running. The SVG keeps its intrinsic
  size and scrolls inside **`.swarm-graph-viewport`** rather than scaling its labels below
  legibility on a phone, and the legend below it wraps instead of setting a minimum width.
- **Words on the map are for what the drawing cannot say.** Under a node there is a line of words
  only for trouble - *offline*, *no route* - and on a relay's card, how many links it carries
  (**N links**, the wire from this machine not counted). The rest is drawn: the card or the disc
  says relay or agent, the ring where the app is, the badge and the dotted wire a node that dials
  out, the screen glyph and the tier this machine. With no line of words under a node its work
  (a session count, *running*, *needs an answer*) takes that line. A hover still spells it all
  out in the node's tooltip (**`<title>`**: name, what it is, the line, the work). Pinned by
  **`SwarmView.test.tsx`**.

The relay serves this SPA from its own address when built with **`-tags "swarm ui"`**
(**`external/swarm/spa_ui.go`**), so a relay is something you open in a browser rather than a
service you reach through some other node's UI.

### Documentation screen (`ui/docs/DocsView.tsx`)

Coddy's own documentation, read out of the binary (**`GET /coddy/docs*`**), at **`#/docs/<page>#<section>`**. It is a reading surface, so it takes the swarm screen's glass dock (**`.docs-dock-cluster`**, the same fixed inset, radius, border, tint and blur as **`.swarm-dock-cluster`**) and reuses the app's controls rather than drawing its own. It is a **sheet**, not a floor: it grows no wider than its three columns (**1350px**: 240 + 780 + 210, two 32px gaps, 28px either side) and stays centred in the space right of the rail (**`width: var(--coddy-dock-width)`**, **`min(1350px, ...)`** defined once on **`.shell`** and shared with the swarm map's dock, with **`margin-inline: auto`**), so a wide window neither frames the page in empty glass nor pushes the outline away from the text (**`docsReaderCss.test.ts`**).

**The pages come from the page's own server**, whatever environment the app is on: **`docs/api.ts`** reads them with **`localFetch`**, past the **`fetch`** shim, because the documentation a person reads is the one of the Coddy they opened, and a relay serves none (it answers only its own routes). A node reached through a relay still serves its pages, for the agent's **`@coddy:`** mentions there. On a relay's own page (the relay serving the SPA from its root) the rail has no **Docs** entry, since there is nothing to read. The sheet does not scroll: the header stays put and the page scrolls in the body under it (**`.docs-body`**, **`overflow: auto`**, a size container), so the sheet's scrollbar starts below the search box instead of running up beside it. Reference capture: **`docs/assets/built-in-docs/reader-page-dark-1280.png`**.

- **Header** (**`.docs-header`**) is laid on the columns of the page (the same **`grid-template-columns`** and **32px** gap as the layout, areas **title search actions**): the title over the contents, the search over the text and as wide as it (at most **780px**), the actions over the outline. The actions take the outline's own column, not one as wide as themselves: with an **`auto`** column (160px in English, 182px in Russian, against the outline's 210px) the search column grew by the difference and the box ran on into **On this page**. The header's insets are the same on both sides (**`--docs-inline`**: **28px**, **16px** on the stacked shell), as in every other panel head: the close button stands as far from the right edge as the title from the left. Only the body scrolls, so a classic scrollbar (10px on Linux, none with overlay scrollbars) takes its width out of the body alone; **`DocsView`** measures it (**`offsetWidth - clientWidth`** of **`.docs-body`**, on a **`ResizeObserver`** of the body and the layout, since the scrollbar comes and goes with the page's height) into **`--docs-scrollbar`**, and the body gives that much out of its right padding (**`max(0px, calc(var(--docs-inline) - var(--docs-scrollbar, 0px)))`**), so the scrollbar stands in the margin, the text ends as far from the right edge as it starts from the left and the search ends exactly where the text does at every width. The header adding the scrollbar to its own right padding instead kept the columns but put the close button and the search 10px farther from the right edge than the title from the left (**`docsReaderCss.test.ts`**, **`DocsView.test.tsx`**). The body keeps the scrollbar's room from the first frame (**`scrollbar-gutter: stable`**), so nothing moves when a page grows tall enough to scroll after it loads (**`npm run check:files`** reads the close button's position every frame with the scrollbars shown, and its inset against the title's). Between **1200px** and **1279px**, where the outline is gone, the actions go back to an **`auto`** column. The title **Documentation** is spelled like the head of every other panel (**`.sessions-head`** of History, Scheduler and Tasks: **12px**, **700**, uppercase, **0.06em** tracking, a **72%** text tint), with the version alone in **11px** at **52%** under it (where the pages come from is not news on this screen). The actions are **Ask the agent** (**`.docs-ask`**, a **999px** pill **30px** tall - the height of the close control beside it, as every row of controls keeps one height - on a **16%** accent plate with a **45%** accent border, the accent's own action because it starts a conversation; the longer explanation is its **`title`**) and the shared close control **`.sessions-close`** - never a bare glyph. The header sits on an **82%** canvas plate with a **6%** hairline under it, outside what scrolls. On the stacked shell it becomes two rows: the title with the actions, then the search under them, across the whole sheet, as the page below it is (**`docsReaderCss.test.ts`**).
- **Three columns** from **1280px**: the contents (**240px**), the page (a reading measure of at most **780px**), **On this page** (**210px**); below **1280px** the outline goes, below **1200px** (the stacked shell) the contents fold into a **Contents** button with **`<Chevron pointing="down">`** above the page, and the page has the sheet to itself: the **780px** reading measure is the desktop's, and on a tablet it only left an empty strip beside the text and the search, so there the search, the **Contents** button and the page stretch across the sheet (**`.docs-article`** and **`.docs-header-search`** at **`max-width: none`**). **On this page** never leaves: where it has no room beside the page it folds into a second button under **Contents** (**`.docs-outline-toggle`**, the same frame, **`<Chevron pointing="down">`**, the list under it on **`.is-open`**, closed again by a section link and by a page change), the column's areas being **toc** / **outline** / **page**. **From 900px** on the stacked shell (a row of the grid's component table) it stands beside the page instead, sticky as on the desktop: **`minmax(0, 1fr) minmax(190px, 210px)`** with the areas **toc outline** / **page outline**, the page taking the rest of the width and the outline at least **190px**, which the header's actions need in Russian; the contents stay folded above the page. Below **900px** the page would drop under about 600px beside it. Both side columns are sticky at the top of the body (**18px**, as tall as the body less that gap at either end: **`calc(100cqh - 2 * var(--docs-top))`**), scroll on their own with the thin scrollbars of Settings, and pad themselves by **3px** so a focus ring is never clipped.
- **Contents** (**`.docs-toc`**): group titles in **11px** uppercase at **52%** text, set **10px** in; the pages of a group sit **12px** further right than that (**`.docs-toc-group > ul`**, **`padding-left: 12px`**), so the title reads as the heading of its list and not as one more page of it - flush, the two ran together. Each page is a **`.docs-toc-page`** drawn exactly like a Settings tab (**`.settings-nav-item`**: **10px** radius, the **6%** text-tint hover, the page being read on a **22%** accent plate with a **40%** accent border and **`aria-current="page"`**).
- **Search** (**`.docs-search`**) is the swarm screen's field (**`--coddy-surface-field`**, **10px** radius, **14px** text, a **2px** accent focus outline), with a **`/`** key cap at its right end while it is empty. The hits hang under it (**`.docs-search-results`**, **12px** radius, the panel border and shadow, at most **62vh**) while the box is in use, and the contents stay where they are. The panel is **opaque** - the canvas colour with a **5%** text tint layered on it - because it lives inside the blurred header, where a nested blur sees nothing and a glass tint lets the page show through. The list (**`.docs-hits`**, a **`role="listbox"`** the input controls as a **combobox**) reads page **›** section, then a three-line snippet whose matched words are **`<mark>`**s on a **24%** accent plate; the selected hit (**`aria-activedescendant`**, also the one under the pointer) sits on a **14%** accent plate with a **32%** accent border. Opening a hit folds the panel away; focusing the box brings it back; Escape clears it, and in an empty box goes on to close the reader (**Escape and the screens of the rail**).
- **The page** (**`.docs-article .md`**) reads, it does not chat: **15px** at **1.68** line height, **h1 28px**, **h2 20px** with a hairline under it and **36px** above, **h3 16px**; links in the accent mixed **72%** with the text, underlined at **45%**; images fit the column with the panel border. Every section heading carries a **`#`** link (**`.docs-heading-anchor`**) that shows on hover or focus. A breadcrumb (group **›** page) and a quiet **coddy.dev ↗** link sit above the title. While the next page loads, the one on screen stays at **55%** opacity (**`.is-loading`**, **`aria-busy`**) and the ask pill is disabled.
- **Images and videos.** An image of the page shows **`cursor: zoom-in`** and opens in a lightbox (**`.docs-lightbox`**, rendered into the body at **`z-index: 400`** over an **88%** canvas veil with an **8px** blur): a bar with the caption, **−**, the level, **+** and the shared close control, all **30px** tall in the close control's frame, over a stage that scrolls a zoomed image. The level is one **58px** control that puts the image back to fit: a frame icon while the image is fitted, the percentage while it is zoomed, so the bar keeps its width either way (the words are its **`title`**). A zoom keeps the point clicked, the point between two pinching fingers, or the middle of the view for the keys and the buttons, where it was. The stage takes the gestures itself (**`touch-action: none`**, which lives with the stage and so leaves the page behind it alone): one pointer drags the picture within what there is to scroll, two fingers pinch it, and the wheel zooms around the pointer instead of scrolling - a listener of the stage's own, **not passive**, so the page behind the viewer does not scroll either (**`WHEEL_DOUBLING_PX`** 500, and lines and pages are read as pixels first). A video (**`video.md-video`**) fills the column with a **10px** radius and the panel border, on black until its first frame (**`#t=0.1`**) arrives.
- **`/docs`** in the composer opens this screen rather than sending a prompt, and the slash menu's **Commands** group lists it (a row the browser adds to the server's catalog, only where the composer can open the reader). With words it opens the reader with them typed in the search box and the hits open under it.
- **The foot** (**`.docs-pager`**): the page before and the page after as two cards on **`--coddy-surface-subtle`** with the panel border, the direction in **12px** muted text over the title; the accent frames the one under the pointer.
- **On this page** (**`.docs-outline`**): the sections two levels deep on a hairline rule, the one being read (it follows the scroll) marked by a **2px** accent segment of that rule and the full text colour.
- Every transition here is removed under **`prefers-reduced-motion: reduce`**. The screen follows every theme through the tokens only (**`--text`**, **`--bg`**, **`--accent`**, **`--coddy-blend-base`**, **`--coddy-glass-panel-*`**, **`--coddy-surface-*`**): no colour of its own.

### Sign-in screen (`httpserver.login`)

Rendered by **`AuthGate`** (**`ui/auth/AuthGate.tsx`**, wrapping **`<App/>`** in **`main.tsx`**) in place of
the entire application when **`GET /coddy/auth/me`** answers **`login_required`** without
**`authenticated`**. Only on the local origin: a remote environment authenticates with the bearer
token of the environment selector and is never gated here.

- **The whole page, not an overlay.** **`.auth-screen`** is a full-viewport grid centred on
  **`.auth-card`** over the plain **`--bg`** ground. There is no blurred transcript behind it,
  because an anonymous browser has nothing to be shown.
- **Boot.** While the one **`/coddy/auth/me`** round trip is outstanding the gate renders
  **`.auth-boot`** — a bare **`--bg`** ground, no spinner and no application chrome. Rendering the
  app and then replacing it with a form reads as a glitch; a spinner for 20 ms reads as a slow page.
- **The column** (**`.auth-shell`**, **`min(380px, 100%)`**) holds two things with a **22px** gap:
  the wordmark, then the card. The logo stands **above** the card and outside it, because it names
  the product while the card asks for one thing.
- **The card** (**`.auth-card`**, the column's full width) uses the glass panel tokens
  (**`--coddy-glass-panel-bg`** / **`-border`** / **`-backdrop`** / **`-shadow`** / **`-radius`**),
  **28px** padding and a **14px** column gap: **`h1`** title, the two fields, the error line, the
  submit button. Nothing explains the screen in prose - a form with two fields under a title that
  says Sign in needs no caption.
- **The wordmark** (**`.auth-logo`**, **`min(240px, 72%)`** of the column, centred) is the
  rectangular logo with the product name in it, not a text line:
  **`src/assets/coddy-logo-wordmark.svg`** on the six dark themes and **`-light.svg`** on
  **`light`** (**`LIGHT_THEMES`**). **`aspect-ratio: 188 / 56`** holds its height, so nothing below
  it moves while the image decodes. Both files are under Vite's inline limit, so they travel inside
  the bundle and the one screen a browser sees before it has any credential paints without a second
  request. Its **`alt`** is a dictionary key (**`auth.signIn.logoAlt`**), because the name in the
  image is what a screen reader must hear.
- **Fields** are ordinary **`label`**-wrapped inputs (**`.auth-field`** / **`.auth-label`** /
  **`.auth-input`**) with **`autocomplete="username"`** and **`"current-password"`** so a password
  manager fills them. Focus draws the accent ring (**`--accent`** at 55% border, 25% glow), never a
  browser default outline. The user field takes focus on mount.
- **The error line** (**`.auth-error`**, **`role="alert"`**) replaces nothing else: the card grows by
  one line. A refused sign-in clears the password field and leaves the user name alone.
- **Submit** (**`.auth-submit`**) is accent-filled, disabled while either field is empty and while a
  request is in flight, and swaps its label for the working one meanwhile.
- **Colour comes from tokens only**, so all seven themes carry the screen, and every string is a
  dictionary key (**`auth.signIn.*`**), so both languages do.
- **Signing out** is the last entry of the nav rail (**`data-testid="nav-sign-out"`**), below
  Settings, rendered only while a form is configured and this browser has passed it. Narrow rail:
  icon plus a tooltip naming the account (**`Sign out (pasha)`**); wide rail: icon plus label.

### Installable app and notifications

The web UI installs as an app and shows system notifications while it is in the background (issue
#508). Everything of it lives in **`ui/pwa/`** and **`public/`**; the functional checklist is
**`docs/surfaces/web-ui.md`** (*Installing the web UI as an app*, *Notifications*), the scenarios
**`features/web_ui_installable_app.feature`**.

- **What the server serves.** **`public/manifest.webmanifest`** (id, start URL and scope **`/`**,
  **`display: standalone`**, the dark canvas **`#0f0f10`** as theme and background colour), the
  icons **`icon-192.png`** and **`icon-512.png`** (the flat mark on its rounded plate, transparent
  corners, purpose **`any`**) and **`icon-maskable-512.png`** (the full-bleed plate of
  **`coddy-logo-mark-icon.svg`**, the mark inside the safe zone, purpose **`maskable`**), and
  **`public/sw.js`**. Vite copies them into **`dist`**, **`scripts-sync-to-go.mjs`** next to
  **`embed.go`**, the **`Dockerfile`** stages them, and **`ui.Handler()`** serves them
  **`no-cache`**, the manifest as **`application/manifest+json`**. Rename them everywhere or
  nowhere.
- **The service worker caches nothing.** It has no **`fetch`** handler and no cache: every request
  still reaches the server, so a new build is never hidden behind an old copy and an offline page is
  the error a tab shows. Do not add caching to it without a design for invalidating it. It shows the
  notices tabs send it (**`coddy-notify`**) and opens a chat on a click (**`coddy-open`**).
  **`serviceWorker.ts`** registers it from **`main.tsx`** in a secure context only.
- **The title bar follows the theme.** **`themeColor.ts`** keeps **`<meta name="theme-color">`** on
  **`--coddy-canvas-gradient-top`** of the applied theme, so an installed window is not dark over a
  light theme.
- **The switch.** **Settings → Appearance**, under the language picker
  (**`NotificationsSetting`**, **`.appearance-notify-block`**): the section label, then one
  **`SwitchField`** (**`appearance-notifications-switch`**) whose description stays behind the
  **(i)**, then a status line (**`.appearance-notify-status`**, the **`settings-field-desc`** type)
  only when the switch cannot be turned on here - blocked by the browser, an insecure page, a browser
  without notifications - in which case the switch is disabled. Turning it on asks the browser for
  the permission inside that click; the choice is the cookie **`coddy_ui_notify`**, client-side like
  the theme. Not rendered inside the Telegram Mini App.
- **When a notice is shown.** Only while the page does not have the person's attention (hidden, or
  its window not focused) and, through the service worker, only while no window of the app has the
  focus. A tab announces only the chats it follows - the one on screen and the ones it sent a prompt
  to (**`AttentionTracker`**): the end of a turn (from **`turn_ended`**, worded from the turn
  stream's error when it failed), a permission request, a question, a background subagent's prompt
  that arrives live (never one the stream replays on connect).
- **One notice per occurrence.** The tag is **`coddy:<environment>:<session>:<kind>:<key>`**,
  where the key is what every tab hears alike: the server's time of the turn's end, the tool call's
  or the request's id. The service worker shows a tag once within 30 seconds.
- **What a notice says.** Title: the chat's title, else **Coddy**. Body: one line of at most 140
  characters in the page's language (**`notify.*`**), naming what happened; it never quotes the
  answer, since a notification lands on a lock screen. Icon: **`/icon-192.png`**.
- **A click** focuses the tab that asked (else any tab of the app, else a new window on
  **`/#/s/<id>`**) and opens the chat through the same path as a History pick, only when the notice
  belongs to the environment the tab shows.

## States

- Idle composer: bordered textarea.
- Streaming assistant: progressively grows final assistant bubble; token HUD updates concurrently.
- Error streaming: surfaced inside assistant transcript with HTTP status text fallback.
- **Working**: for the whole turn, the three typing dots carry a **live status line** to their right (**`TypingDotsMessage.tsx`**, classes **`typing-dots-status*`**). It names the current step and nothing else, for example **`Reading a file`** or **`Running a command`**, followed by an elapsed counter (**`· 12s`**). **The line carries the phase and never what the step acts on.** No path, no command, no pattern, no url, no tool id: what a step acts on is named once, by the transcript row above the line, and repeating it in a running caption only fills the row with a value nobody can read at that width. **Every phrase must therefore be complete on its own** - **`Running a command`**, not **`Running`** followed by the command; **`Выполняю команду`**, not **`Выполняю`**. A new phrase that reads as a fragment without an object beside it is a phrase that belongs to the transcript row, not to this line, and the dictionary test (**`external/ui/src/ui/i18n/statusPhrases.test.ts`**) fails it in every locale. The one phrase that takes slots is **`status.mcp`**, and it names the server and the tool of a call Coddy does not define - the identity of the step, not its arguments. The **turn's own numbers lead the line** (**`.typing-dots-turn`**): the turn clock, the tokens the model has generated in this turn, the background tasks running right now, each closed by a middle dot, so a long turn reads **`15m 08s · 13.5k tokens · 1 running task · Thinking…`**. The clock and the tokens come from the server's **`turn_progress`** (**`chat/turnProgress.ts`**: the turn stream, plus **`GET /coddy/sessions/{id}/activity`** for a tab that joined late, because the relay does not replay a frame the transcript snapshot covers); the start is counted back from the local clock by the elapsed time the server reported, so a wrong clock on either machine does not move it. A reading names its turn by the server's **`startedAt`** and is dated by the turn's age on the server's clock: the newer one wins whichever way it came, and only a clearly earlier start replaces the known one. Until the server has reported - an older server never does - the clock counts from the creation time of the turn's user message. The tokens segment hides at zero, so a turn that has not heard from the model reads **`57s · Waiting for the model`**. Tokens shorten past a thousand (**`1.2k`**, **`13.5k`**, **`240k`**, **`1.2M`**) and never round up. The tasks segment (**`typing-dots-turn-tasks`**) counts through **`countRunningTasks`** (**`tasks/taskStatus.ts`**) - rows that say **`running`**, system tasks such as the memory run left out - the same count the header control uses; it is a **`button`** in the accent colour with no chrome of its own and opens the Tasks panel. **The same dots hold the tail once the turn has ended and its tasks have not**: **`TypingDotsMessage`** takes **`tasksOnly`**, which drops the turn clock and the tokens - there is no turn to time - and leaves the count alone beside the dots, closed by no middle dot (**`.typing-dots-status--tasks-only`**); it opens the same panel, and when nothing runs nothing stands. The dots hold still under **`prefers-reduced-motion: reduce`**, which is what a line that outlives a turn by minutes needs. With the turn clock in front, the **step counter stays only on a step that runs something other than the model** - a tool call, the memory run (**`stepShowsItsOwnClock`**): a second clock equal to the first reads as a glitch. One ticker drives both clocks, aligned to the turn's second boundary. On a phone (**`max-width: 599px`**) the line takes two rows: the phrase keeps the first one next to the dots (with the step counter), and the turn's numbers move under it as an **11px** caption, the last of them closed by no dot. Derivation of the phrase is pure and lives in **`chat/liveStatus.ts`**: it reads the transcript the SPA already holds, scanning back to the last **`user_message`** so a stale row from a finished turn cannot drive the label. Priority is unresolved **permission** prompt → unresolved **question** prompt → running **tool_call** → in-progress **thinking** (**`Thinking…`**) → a **memory_run** in flight → the turn's newest row is answer text (**`Writing the answer`**, kind **`writing`**, no target) → waiting on the model. A status line is never empty: the dots do not render without a phrase beside them. The two prompt states show **no step counter**: nothing is running, so a climbing number would be a lie (same reasoning as the frozen **`ToolCallMessage`** timer). They keep the turn clock, which is wall time since the prompt. A plain wait escalates its phrase with time: **`Waiting for the model`** → **`The model is taking longer than usual`** (15s, **`typing-dots-status--slow`**) → **`Still no response from the server`** (60s). Before that, a turn that has shown nothing since its message (**`opening`** on the status) reads **`Preparing the session…`** (kind **`preparing`**, **`withPreparingPhase`**) until the server reports that it talks to its model: a turn announces itself with a **`turn_progress`** in the **`preparing`** phase as soon as it is taken, while it brings in its MCP servers and waits for its model's context window, and the loop's own report ends the phase. The preparing phrase does not escalate, because it is not the model that is slow (issue #357). Layout: the **phrase** is the only thing that gives ground when space runs out - the turn's numbers and the counter are **`flex: none`**, and a phrase too long for the row ellipsizes (**`.typing-dots-status-verb`**) rather than wrapping it onto a second line. The status node **must** stay after the three **`.typing-dots-dot`** spans, whose **`:nth-child(2)/(3)`** rules carry the bounce stagger. The interactive console (**`external/cli`**) shows the same phrases next to its spinner, from the Go twin of the table in **`external/cli/status.go`**.

## Non-goals for this milestone

Server-side SSR routes per session, BFF auth, CDN-hosted Swagger, and editing **`agentMemory`** via REST remain out-of-scope (`session.json` slot remains agent-managed).

## Dev workflow

To iterate on UI without rebuilding the Go binary:

Backend:

```bash
make build TAGS=http
./build/coddy serve --config config.yaml --home /tmp/coddy-ui-dev-home --sessions-dir /tmp/coddy-ui-dev-sessions -H 127.0.0.1 -P 12345
```

Frontend:

```bash
npm --prefix external/ui install
npm --prefix external/ui run dev -- --host 127.0.0.1 --port 5173
```