Skip to main content

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. This page is the contract for changing it. For hosting and configuration see Web client hosting.

Layout

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.

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:
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.
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. 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.
  • 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. 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.
  • 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.

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.
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.
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:
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.
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 and 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 for the suites and their caveats.