> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wednesdayai.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Web client components

> Developer contracts for WednesdayAI web-client components, chat history reconciliation, session lists, and transcript display.

# Web client components

`apps/web` is the end-user web client (client id `openclaw-user-client`). Its view layer is a small
native React component layer under `apps/web/src/ui/`, styled over the shared
[Brand tokens](/reference/brand-tokens). This page is the contract for changing it. For hosting and
configuration see [Web client hosting](/web/user-client).

## Layout

```text theme={"dark"}
apps/web/src/
  app/         App (boot phases), AppShell (connected shell) and the use-chat-* / use-session-creation hooks it composes, onboarding and config-error views
  ui/          the component layer: Shell, Sidebar, TopBar, Thread, Composer, ... + client.css
  state/       chat.ts (zustand store), types.ts
  providers/   gateway and events seams, WebClientRuntime, theme, attachments, preferences, and the chat runtime modules (leaf tracking, queued turns, chat list)
  runs/        RunsController and the projection behind the Runs panel
  gateway/     connection, device identity, setup-code parsing
```

The components in `ui/` are `Shell`, `Sidebar`, `NavigationMenus`, `TopBar`, `ModelMenu`, `ThinkMenu`,
`DisplayMenu`, `ToolDisplay`, `NewChatPicker`, `AccountMenu`, `Thread`, `AssistantMessage`,
`UserMessage`, `EmptyState`, `Composer`, `RightPanel`, `DisabledControl`, `Icon` and
`markdown/MarkdownRenderer`. `Shell` owns layout mechanics (mobile
breakpoint at 860px, sidebar open and collapsed state) so `AppShell` supplies data, not chrome. The
assistant and chat controls client-chat-v3 P2 made live (model, Think, assistant picker, queue,
reply, rename) are covered under [Chat controls and hooks](#chat-controls-and-hooks).

## Styling conventions

* Every class in `apps/web/src/ui/client.css` is prefixed `cl-` (`cl-sidebar`, `cl-composer`,
  `cl-msg`). There is no shadow DOM and no CSS-in-JS. Boot views (loading, pairing, configuration
  error) live in `app/app.css` with a `boot-` prefix.
* Colours, spacing, radii, motion and fonts come from `brand-tokens` custom properties. Never
  hard-code a colour or font family. Text on an accent fill uses `--accent-foreground`.
* A token only this app needs (`--personalise-subtle`, `--composer`, `--shadow-md`) is declared at
  the top of `client.css`, not in the shared package.
* Icons are inline SVG paths in `ui/Icon.tsx` (Lucide-style, ISC notice in `apps/web/NOTICE`). Add a
  glyph to `IconName` rather than an icon-library dependency.
* Markdown replies render under a `.prose` wrapper. Style prose elements under `.prose`, not by
  element selector on the page.
* Honour `prefers-reduced-motion`: entrance animation is disabled under it.
* Tests and e2e suites select by `data-testid` and `data-role`, never by `cl-` class. Keep an
  existing hook stable when you restyle; rename it in the same change as the suites that use it.

## DisabledControl and the phase contract

A control whose backing feature ships later renders through `DisabledControl`
(`apps/web/src/ui/DisabledControl.tsx`), visible but inert:

```ts theme={"dark"}
type DisabledPhase = "P3" | "P3a" | "P3b" | "P4";

type DisabledControlProps = {
  label: string;
  icon?: IconName;
  className?: string;
  testId?: string;
  dataAttrs?: Record<string, string>;
  children?: ReactNode;
} & (
  | { phase: DisabledPhase; finding?: never } // tooltip: "<label> — coming in phase <phase>"
  | { phase?: never; finding: string } // tooltip: "<label> — coming with the extension panel SDK (<finding>)"
);
```

The contract:

* It renders a `<button type="button" aria-disabled="true">` with a `title` tooltip. It uses
  `aria-disabled`, not the `disabled` attribute, so it stays focusable and the tooltip is reachable
  from the keyboard.
* The click handler only calls `preventDefault()`. A disabled control must never reach a gateway
  RPC, whatever capability flag is set. `features.voice` does not enable Dictate or Voice mode.
* The props are a union: pass **either** `phase` **or** `finding`, never both. Use `phase` for work
  on the client roadmap phases (`dev-docs/workstreams/client-chat-v3/strategy/`; P3 read aloud, P3a
  dictation, P3b voice mode, P4 widgets and Canvas). Use `finding` for controls that wait on a
  tracked finding rather than a phase; Calendar, Memory and Add extension cite `"F1, GitHub #634"`,
  and the P2 retags (message feedback, message regenerate, account-menu settings, account-menu
  keyboard shortcuts, composer search) each cite their own finding
  (`"F-2026-09-29-1x, GitHub #69x"`, ADR 0085 M9). The `finding` tooltip wording is fixed, so it
  reads "coming with the extension panel SDK" whichever finding it cites.
* Give it a `testId`. The brand e2e asserts that each disabled control has `aria-disabled="true"`,
  a tooltip naming its phase or finding, and sends no gateway frame when activated.

```tsx theme={"dark"}
// Do this: visible, inert, and honest about when it lands
<DisabledControl phase="P4" label="Canvas" icon="canvas" testId="top-bar-canvas" />

// Not this: a live-looking button with a handler that reaches for an RPC the client may not call
<button onClick={() => gateway.request("talk.session.create", {})}>Voice mode</button>
```

To enable a control when its phase ships, replace the `DisabledControl` with a real element, update
the allowlist ADR if it needs a new method, and update the disabled-control e2e in the same change.

## Session vocabulary

The UI says **chat**; the code says **session**. The gateway's `sessions.*` rows are what the user
sees as chats.

| User-facing | Code |
| - | - |
| Chat, Chats | `ChatSession`, `sessions`, `activeSessionKey` |
| New chat | `createSession(label)`, `sidebar-new-chat`; labels "New chat", "New chat 2"... |
| Switch chat | `switchSession(key)`, `sidebar-session` rows with `data-session-key` |
| Chat list page | `SESSIONS_LIST_LIMIT` (100 per page); `session-list-runtime.ts` pages with `offset`/`hasMore`, `load-more-chats` |
| Source tag on a row | `deriveSourceTag(key)` in `ui/session-key-shape.ts` |
| Assistant | `AgentSummary` from `agents.list`; `deriveAgentIdFromSessionKey(key)`; `sidebar-agent`, `new-chat-picker-option` |
| Model, Think, Send reasoning | `ModelMenu` (`top-bar-model-menu`), `ThinkMenu` (`composer-think`); `sessions.patch` `model`, `thinkingLevel`, `reasoningLevel` |
| Transcript display | `DisplayMenu` (`display-menu-trigger`); browser-local `showReasoning` and `tools: hide / minimal / full` |
| Rename, Suggest a title | `top-bar-title-input`, `rename-suggest`; `sessions.patch` `label`, `sessions.title.prepare` |
| Reply | `replyChip` in the store (`composer-reply`), `message-reply`, `message-reply-quote`; `chat.send` `replyToId` |
| Queue, Send now | `handleQueue`, `handleSendNow` in `use-chat-queue.ts`, `message-queued-badge`; `sessions.steer` |
| This chat changed elsewhere | `chatChangedNotice` (`chat-changed-notice`), `ActiveLeafChangedError`; gateway `active-leaf-changed` |

Do not reintroduce "Space" or "Spaces" in UI copy or identifiers; that word is reserved for a
possible external Spaces extension, and the brand e2e asserts the rendered page contains neither. Session keys
look like `agent:<id>:<rest>`; `session-key-shape.ts` parses them client-side without importing core
code, and `ui/recency.ts` groups rows into Today, Yesterday and Earlier by local midnight.

## State and provider seams

Data flows in one direction.

```text theme={"dark"}
GatewayConnection --frames--> WebClientRuntime --events bus--> AppShell --> zustand store --> ui/*
                    ^                |
                    +--- requests ---+  (gateway seam: sendMessage, createSession, ...)
```

* `providers/web-client-runtime.ts` (`WebClientRuntime`) owns the connection and implements both
  seams: the `GatewayContextValue` (`providers/gateway.tsx`, request-style calls such as
  `sendMessage`, `switchSession`, `createSession`, `uploadFile`, `abortMessage`) and the
  `EventsContextValue` (`providers/events.tsx`, subscriptions such as `onChatChunk`,
  `onSessionChanged`, `onSessionsList`, `onConnectionStatus`). It also has WednesdayAI-native
  members the vendored seams have no room for, which the `app/` hooks call directly: `listAgents`,
  `listModels`, `patchSession`, `prepareSessionTitle`, `createSessionForAgent`, `sendChatMessage`
  (the one `chat.send` seam), `queueMessage`, `sendNow`, `listActiveRunIds`, the `sessionList`
  runtime, and the `onSidebarSessionsChange`, `onActiveSessionRowChange`, `onQueuedRunStarted` and
  `onActiveLeafConflict` subscriptions.
* `app/AppShell.tsx` subscribes to the events seam once per runtime, writes into the store
  (`state/chat.ts`, `useChatStore`) and composes the hooks described under
  [Chat controls and hooks](#chat-controls-and-hooks). Components read the store or receive props;
  they do not subscribe to the connection themselves.
* The seam interfaces are kept in the shape of their upstream Saturday origin, including members
  the web client no-ops (desktop context, accessibility). Provenance is in `apps/web/NOTICE`.
* Runs live in `runs/` behind `RunsController`. Its `start()` and `stop()` are one boolean, so
  components take a **reference-counted lease** with `useRunsLease(controller)`
  (`ui/use-runs-lease.ts`); the Sidebar holds one for the live badge and the `RightPanel` holds
  another while open.

```ts theme={"dark"}
// Do this: the sidebar badge and the panel each hold a lease
useRunsLease(runsController); // Sidebar: badge stays live with the panel closed
useRunsLease(controller, true); // RightPanel: refresh on join

// Not this: closing the panel would stop the controller and freeze the badge
useEffect(() => {
  controller.start();
  return () => controller.stop();
}, []);
```

* `Thread` does not scroll itself. The scrolling element is the `.cl-scroll` wrapper `AppShell`
  places around it, so scroll code must drive that element. Auto-follow applies only while the
  reader is within 80px of the bottom.

## Chat controls and hooks

client-chat-v3 P2 made the assistant and chat controls live and split `AppShell.tsx` into one hook or
provider module per concern, each testable without a gateway connection.

| Concern | Code | Gateway calls |
| - | - | - |
| Assistant picker, new chat | `ui/NewChatPicker.tsx`, `app/use-session-creation.ts` | `agents.list`, `models.list`, `sessions.create` |
| Model menu | `ui/ModelMenu.tsx`, `app/use-chat-controls.ts` | `models.list`, `sessions.patch {model}` |
| Think menu, Send reasoning | `ui/ThinkMenu.tsx`, `app/use-chat-controls.ts` | `sessions.patch {thinkingLevel}` and `{reasoningLevel}` |
| Transcript display | `ui/DisplayMenu.tsx`, `ui/ToolDisplay.tsx`, `ui/Thread.tsx` | none; browser-local preferences |
| Rename, Suggest a title | `ui/TopBar.tsx`, `app/use-chat-controls.ts` | `sessions.patch {label}`, `sessions.title.prepare` |
| Send, Stop, Reply | `ui/Composer.tsx`, `ui/UserMessage.tsx`, `ui/AssistantMessage.tsx`, `app/use-chat-send.ts` | `chat.send {replyToId, expectedLeafEntryId}`, `chat.abort` |
| Queue, Send now | `app/use-chat-queue.ts`, `providers/chat-queue-runtime.ts`, `providers/pending-queued-turns.ts` | `sessions.steer`, `sessions.runs.list` |
| History, older pages, chat leaf | `app/use-chat-history.ts`, `providers/leaf-tracking.ts` | `chat.history {before}` |
| Chat list filter, search, paging | `providers/session-list-runtime.ts`, `providers/navigation-preferences.ts` | `sessions.list {agentId, search, includeInternal, sortBy, sortOrder, includeAgentActivity, offset, limit}` |
| Assistant colours | `ui/assistant-color.ts`, `providers/gateway-mappers.ts` | `agents.list` `identity.color` |
| Menu dismissal | `ui/use-dismissible-menu.ts` | none |

### Completed history boundary and transcript controls

These contracts keep completed runs, session-list responses, and browser-local presentation state
from overwriting newer or connection-specific state.

```ts theme={"dark"}
type CompletedRunOutcome = "final" | "aborted";

declare function resolveCompletedHistoryBoundary(
  current: ChatMessage[],
  loaded: ChatMessage[],
  currentMatches: ReadonlyMap<number, number>,
  completedRunId: string,
  outcome: CompletedRunOutcome,
): {
  loadedBoundary: number;
  completedAssistantAdopted: boolean;
  hasCompletedMatch: boolean;
};
```

`ChatMessage` is the client-owned message type in `state/types.ts`; the map relates current row
indexes to loaded row indexes. This is an internal client contract, not a plugin SDK export.

`resolveCompletedHistoryBoundary` accepts a terminal `outcome` of `"final"` or `"aborted"`. It
finds the last loaded row matched to the completed run. When there is no completed-run match, it
falls back to the loaded row matched to the user message that preceded that run's assistant turn.
If the completed assistant was not adopted, the outcomes diverge:

* For `"final"`, the resolver advances `loadedBoundary` through subsequent loaded rows until the
  next user turn. This admits the server's completed assistant output into the loaded boundary.
* For `"aborted"`, it stops at the existing `loadedBoundary` and does not scan forward. The merge
  therefore preserves the stopped text observed in local state instead of adopting a durable
  server suffix that was ahead of the stream when the abort landed.

```ts theme={"dark"}
const boundary = resolveCompletedHistoryBoundary(
  currentMessages,
  loadedMessages,
  currentMatches,
  completedRunId,
  "aborted",
);
```

`SessionListRuntime` tags each request with a monotonically increasing `seq`. A response is applied
only while its captured `mySeq` equals the current sequence; otherwise it is silently dropped as
stale after a newer filter, search, sort, or page request. `dispose()` clears the pending 250 ms
search debounce and increments `seq`, preventing an in-flight Gateway response from landing on a
torn-down component. On `sessions.changed`, `refetchLoadedWindow()` starts at offset 0 and uses
`limit = Math.max(sessions.length, pageSize)`, preserving every page the user already loaded.

Navigation and transcript preferences are per connection:

* Navigation uses
  `openclaw.web.navigation:<encodedGatewayUrl>:<clientId>`, where the storage scope contains both
  `gatewayUrl` and `clientId`. Defaults are `assistantSort: "newest"`, `activityWindowHours: 4`,
  `includeInternal: false`, `chatSortBy: "updatedAt"`, `chatSortOrder: "desc"`,
  `selectedAgentId: null`, and `lastChatByAgent: {}`.
* Transcript display uses the `openclaw.web.transcript-display.v1` object, keyed within that object
  by `<gatewayUrl>|<clientId>`. Defaults are `showReasoning: false` and `tools: "minimal"`.

Display is not delivery. `showReasoning` and `tools: "hide" | "minimal" | "full"` are strictly
browser-local presentation state in `DisplayMenu`; they do not alter Gateway execution or the
session's `reasoningLevel`. Tool failures remain visible even in `"hide"` mode:

```tsx theme={"dark"}
<div className="cl-tool-error" role="alert">
  {result.error}
</div>
```

A wholly hidden tool call renders `null`, so a tool-only assistant turn must not leave an empty
assistant bubble.

Web-client-specific footguns and their safe patterns:

* Do not apply a late session-list response after a filter or sort changes, or after unmount. Keep
  the `mySeq === seq` guard and invalidate the sequence in `dispose()`.
* Do not refetch only `pageSize` rows on `sessions.changed`; doing so drops an already loaded second
  or later page. Refetch the full loaded window from offset 0.
* Do not treat aborted and final reconciliation as equivalent. Advancing an aborted boundary can
  replace the exact stopped text the user saw with a durable suffix they never observed.
* Do not persist navigation or display preferences under an unscoped key. The same browser can
  connect to multiple gateways or client identities.
* Do not mutate the active row optimistically for model, thinking, reasoning, or label changes.
  Wait for the patch and refreshed session row; otherwise a refusal can leak one chat's pending
  state into another.
* Do not hide tool errors with ordinary tool details. Error output is an alert even when tool
  display is hidden.

```ts theme={"dark"}
// Do this: preserve the loaded window and invalidate work during teardown.
runtime.refetchLoadedWindow();
return () => runtime.dispose();
```

```ts theme={"dark"}
// Not this: a one-page refresh drops loaded rows, and an empty cleanup admits stale responses.
runtime.reload();
return () => {};
```

Contracts worth keeping when you change them:

* **Settings are not optimistic.** `ModelMenu`, `ThinkMenu` and the rename field patch the open chat
  through `WebClientRuntime.patchSession` and only ever show that chat's own row (`sessions.describe`,
  refreshed on every `sessions.changed` for it). A refused patch therefore needs no rollback:
  nothing on screen changed, and the control shows the gateway's message (`chat-controls-error`).
  "Assistant default" in the model menu sends `model: null`; Think sends `off`, `low`, `medium` or
  `high`; Send reasoning sends `reasoningLevel` `on` or `off`. `ThinkMenu` is inert (`aria-disabled`,
  a tooltip naming the model, no frame sent) when `models.list` reports `reasoning: false` for the
  chat's model.
* **Suggest never saves.** `rename-suggest` sends `sessions.title.prepare` with the chat's assistant
  id and the first user message currently loaded (its first 1000 characters), and only fills the
  field (60 characters at most). Enter or blur saves through `sessions.patch {label}`. A refusal, the
  rate limit or a `null` title shows `rename-error`.
* **Assistant picker.** `NewChatPicker` is opened by `sidebar-new-chat-with` and
  `sidebar-browse-assistants`. A pick sends exactly one `sessions.create` (a ref guard, not just
  React state, stops a double click), carrying the optional model from `models.list`; a failing
  `models.list` never blocks creating with "Assistant default". `sidebar-new-chat` creates in one
  step for the filtered assistant, else the open chat's. Both number the default label ("New chat",
  "New chat 2", ...) against the labels on screen and retry on `label already in use`, at most 20
  attempts.
* **History and the chat leaf.** `useChatHistory` loads the newest page (`HISTORY_PAGE_SIZE`, 50)
  and pages older with the returned `cursor`. `WebClientRuntime` records the chat's leaf from its
  newest-page `chat.history` reads (the thinking reconcile opts out with `noteLeaf: false`); a
  `before` page never updates it. `LeafTracker` tells "never learned" (`undefined`: the send omits
  `expectedLeafEntryId`) from "known empty" (`null`: the send asserts an empty chat). After a
  final, aborted or error event for a run this client owns, `readLeafUntilStable` re-reads with
  `limit: 1` until two reads agree (every 25 ms, at most 40 attempts) and a send waits for it. A
  run that belongs to another device never refreshes the leaf, so a genuinely stale client still
  meets the gateway's refusal. On `active-leaf-changed` the runtime reloads the thread, raises
  `chat-changed-notice` and rejects with `ActiveLeafChangedError`; `useChatSend` puts the unsent
  draft back in the composer.
* **Queue and Send now.** While a reply streams, or a queued turn is still waiting, Enter sends
  `sessions.steer` with `mode: "queue"` and shows the turn at once with `message-queued-badge`;
  Ctrl/Cmd+Enter or the send menu's Send now (`composer-send-menu-now`) sends `mode: "inject"`. Both
  send `fallback: "queue"`, and only `queue` carries attachments, so Send now is unavailable while a
  file is staged. Neither can carry a reply: the composer refuses both while a reply chip is set,
  because `sessions.steer` has no `replyToId`. A queued turn is saved in `sessionStorage`
  (`openclaw.web.pendingQueuedTurns.v1`, dropped after 24 hours) until its run drains, so it
  survives a reload or reconnect; `useChatHistory` shows it again only when `sessions.runs.list`
  still reports its run.
* **Replies.** Only a message with a real entry id (`__openclaw.id`) offers Reply. It sets the
  `replyChip` in the store (`composer-reply`, excerpt up to 140 characters), and the next `chat.send`
  carries its entry id as `replyToId` and clears the chip, whether or not the send succeeds. The
  quote renders from `__openclaw.replyTo` as `message-reply-quote`, live and after a reload.
* **Chat list.** `SessionListRuntime` owns the assistant filter, the search (debounced 250 ms),
  `loadMore` (the next `offset` is the number of rows already loaded) and the `sessions.changed`
  refetch, which re-reads offset 0 up to the rows already loaded so later pages are not dropped.
  Every request carries a sequence number and a superseded response is dropped. The open chat's own
  row is fetched separately, so the top bar stays live when a filter or search hides the chat from
  the list.
  Rows show the exact session key instead of a message preview; the client no longer requests
  unused last-message previews. Internal sessions are hidden by default (`includeInternal: false`).
  Gateway sorting precedes pagination; title sort uses labels before derived titles. Global title
  enrichment caps transcript reads at 16 at a time and yields between batches; normalized sort keys
  are computed once and are not returned. `includeDerivedTitles` remains opt-in: callers displaying
  the effective sorted titles should request it explicitly.
  `agentActivity` aggregates the newest eligible session timestamp before
  assistant, search, activity-window and pagination narrowing. The assistant list defaults to a
  4-hour window, pins the selected assistant, and refreshes its cutoff once per minute.
* **Assistant navigation.** Clicking an assistant scopes Chats and restores its last explicitly
  selected eligible chat, independently of the loaded page. Otherwise it opens the newest
  user-facing chat or clears the runtime selection without creating a chat. Explicit creation and
  first-send creation remember the new chat too. Every actual activation invalidates older
  restoration callbacks, so delayed startup lookups cannot replace a newly created or selected chat.
  The selection is visible during lookup; the draft remains editable, but submission waits for
  lookup to finish or be superseded. Changing visibility only refilters a settled chat; an already
  pending assistant lookup restarts with the new eligibility rules. Creation revisions reject late
  creation replies. Separate send-navigation revisions invalidate leaf-gated sends on genuine
  navigation, but not on `All assistants`, the already-selected assistant, or the same open chat.
  `All assistants` explicitly clears the scope. Navigation preferences and remembered selections
  are scoped by gateway URL and client ID in browser storage; malformed remembered entries are discarded.
* **Display is not delivery.** `DisplayMenu` stores `showReasoning: false` and `tools: "minimal"`
  by default, separately from `reasoningLevel`. `extractTranscriptBlocks` preserves ordered text,
  thinking, tool calls, results and media; `Thread` pairs tools by call ID across loaded pages.
  Legacy `tool` roles and snake\_case result metadata remain tools, not assistant prose.
  Hidden tool details retain errors, and wholly hidden execution turns leave no empty assistant
  row. Local final/aborted turns trigger a `completed-turn` reconciliation after the leaf settles,
  even while a newer local or queued run exists. The completed run ID and terminal outcome bound
  that snapshot: a final replaces its stale partial, while an aborted run keeps the observed stopped
  text rather than an ahead-of-stream durable suffix. `completed-history-boundary.ts` excludes newer
  assistant/execution data but admits matched newer durable users. Those rows keep their client IDs
  and run ownership; the queued marker clears on the runtime's start event, not on storage adoption.
  The merge retains newer partials, optimistic users, queued turns, older pages and errors without
  advancing the leaf guard. Legacy identity-less matches are one-to-one FIFO within durable-entry
  anchor intervals, so identical distinct turns remain distinct. Scoped loaded-history and
  older-page IDs distinguish replaceable heads from optimistic rows and preserve imported legacy
  entries. Superseded older-page responses cannot splice into a replacement load.
  Events received before `chat.send` acknowledges its run are buffered for exact attribution.
  A rejected send recovers the foreign question/reply through the non-destructive merge, including
  first-send-created chats, without deleting its own optimistic question or refusal. A durable foreign
  terminal event clears the fresh-empty-chat skip guard just like a local completion.
  Send failures belong to their original optimistic turn: an obsolete rejection cannot rewrite a
  newer chat or foreign reply. Distinct assistant message IDs remain distinct even with identical text.
  Media links and derived attachments share `normalizeMediaUrl`: HTTP(S) and inline raster
  images/WAV are allowed; unsupported schemes and data types remain non-interactive metadata.
* **Colours.** `ui/assistant-color.ts` maintains a browser-safe palette identical, in order, to
  `ASSISTANT_IDENTITY_COLORS` in
  `src/shared/assistant-identity-values.ts`. A configured `identity.color` wins; otherwise FNV-1a
  32-bit of the agent id modulo 8 picks one, so an unconfigured assistant keeps its colour across
  reloads and devices. The `--wai-assistant-<name>` and `-fg` tokens are in
  `packages/brand-tokens/tokens.css`; ADR 0084 section 6 records the contrast guarantee.
* **Menus.** `useDismissibleMenu` closes a menu on an outside mousedown and on Escape, returning
  focus to its trigger. `ModelMenu`, `ThinkMenu` and the Composer send menu all use it; do not copy
  its body into a new menu.

## Stay inside the ADR 0077 allowlist

The client connects with only `operator.sessions.read` and `operator.sessions.write`, and the
gateway admits a fixed method and event set for them (listed under
[narrow scope method allowlist](/gateway/protocol#narrow-scope-method-allowlist) and
[event allowlist](/gateway/protocol#narrow-scope-event-allowlist); ADR 0077 records the decision; the
sources of truth are `NARROW_SESSION_READ_METHODS` and `NARROW_SESSION_WRITE_METHODS` in
`src/gateway/method-scopes.ts` and `NARROW_SESSION_EVENT_ALLOWLIST` in
`src/gateway/server-broadcast.ts`).

* Methods: reads such as `agents.list`, `sessions.list`, `chat.history`, `sessions.runs.*` and
  `tasks.runs.*`; writes `chat.send`, `chat.abort`, `sessions.create`, `sessions.steer`,
  `sessions.abort`, `sessions.runs.cancel`, `sessions.runs.continue`, `sessions.patch` and
  `sessions.title.prepare` (ADR 0083). `sessions.patch` is field-guarded even for this narrow
  client: only `label`, `model`, `thinkingLevel` and `reasoningLevel` are admitted
  (`NARROW_SESSION_PATCH_FIELDS`); every other field still needs `operator.admin` and is refused
  with `missing scope: operator.admin`, the same shape as a fully unlisted method. The patch
  response is redacted for this client (no `path`, none of the admin-only entry fields), so never
  read those from a patch result.
* Events: `chat`, `sessions.changed`, `runs.invalidated`, `tick`, `heartbeat`.
* Not admitted: `talk.*`, `config.*`, `logs.*`, `cron.*`, `node.*`, `send`, and anything not listed.
  A method outside the list is refused by the gateway, so a feature that needs one is an ADR
  amendment, not a client-only change.

Before you add an RPC call, check it against the list. The brand e2e records every frame the
browser sends and fails if one is outside the allowlist, which catches a stray call even when a
unit test with a mocked runtime passes.

## Test at the real entry point

Unit tests under `apps/web/src/**` (`pnpm test:web`) prove components in jsdom. They cannot see
scrolling, fonts, `color-scheme` or gateway frames. For a behaviour change, also drive the built
bundle in real Chromium against a real gateway using the harness in
`test/helpers/user-client-web-harness.ts`; see
[Testing](/help/testing#web-client-and-brand-e2e-suites) for the suites and their caveats.

## Related

* [Brand tokens](/reference/brand-tokens) - the shared tokens and fonts under `client.css`
* [Web client hosting](/web/user-client) - deployment, origins, `config.json`
* [Using the web client](/web/using-the-web-client) - what users see
* [Gateway protocol](/gateway/protocol) - client identity and scopes


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.