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
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.cssis prefixedcl-(cl-sidebar,cl-composer,cl-msg). There is no shadow DOM and no CSS-in-JS. Boot views (loading, pairing, configuration error) live inapp/app.csswith aboot-prefix. - Colours, spacing, radii, motion and fonts come from
brand-tokenscustom 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 ofclient.css, not in the shared package. - Icons are inline SVG paths in
ui/Icon.tsx(Lucide-style, ISC notice inapps/web/NOTICE). Add a glyph toIconNamerather than an icon-library dependency. - Markdown replies render under a
.prosewrapper. 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-testidanddata-role, never bycl-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 throughDisabledControl
(apps/web/src/ui/DisabledControl.tsx), visible but inert:
- It renders a
<button type="button" aria-disabled="true">with atitletooltip. It usesaria-disabled, not thedisabledattribute, 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.voicedoes not enable Dictate or Voice mode. - The props are a union: pass either
phaseorfinding, never both. Usephasefor 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). Usefindingfor 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). Thefindingtooltip 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 hasaria-disabled="true", a tooltip naming its phase or finding, and sends no gateway frame when activated.
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’ssessions.* 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: theGatewayContextValue(providers/gateway.tsx, request-style calls such assendMessage,switchSession,createSession,uploadFile,abortMessage) and theEventsContextValue(providers/events.tsx, subscriptions such asonChatChunk,onSessionChanged,onSessionsList,onConnectionStatus). It also has WednesdayAI-native members the vendored seams have no room for, which theapp/hooks call directly:listAgents,listModels,patchSession,prepareSessionTitle,createSessionForAgent,sendChatMessage(the onechat.sendseam),queueMessage,sendNow,listActiveRunIds, thesessionListruntime, and theonSidebarSessionsChange,onActiveSessionRowChange,onQueuedRunStartedandonActiveLeafConflictsubscriptions.app/AppShell.tsxsubscribes 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/behindRunsController. Itsstart()andstop()are one boolean, so components take a reference-counted lease withuseRunsLease(controller)(ui/use-runs-lease.ts); the Sidebar holds one for the live badge and theRightPanelholds another while open.
Threaddoes not scroll itself. The scrolling element is the.cl-scrollwrapperAppShellplaces 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 splitAppShell.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 advancesloadedBoundarythrough 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 existingloadedBoundaryand 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 bothgatewayUrlandclientId. Defaults areassistantSort: "newest",activityWindowHours: 4,includeInternal: false,chatSortBy: "updatedAt",chatSortOrder: "desc",selectedAgentId: null, andlastChatByAgent: {}. - Transcript display uses the
openclaw.web.transcript-display.v1object, keyed within that object by<gatewayUrl>|<clientId>. Defaults areshowReasoning: falseandtools: "minimal".
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:
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 === seqguard and invalidate the sequence indispose(). - Do not refetch only
pageSizerows onsessions.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.
- Settings are not optimistic.
ModelMenu,ThinkMenuand the rename field patch the open chat throughWebClientRuntime.patchSessionand only ever show that chat’s own row (sessions.describe, refreshed on everysessions.changedfor 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 sendsmodel: null; Think sendsoff,low,mediumorhigh; Send reasoning sendsreasoningLevelonoroff.ThinkMenuis inert (aria-disabled, a tooltip naming the model, no frame sent) whenmodels.listreportsreasoning: falsefor the chat’s model. - Suggest never saves.
rename-suggestsendssessions.title.preparewith 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 throughsessions.patch {label}. A refusal, the rate limit or anulltitle showsrename-error. - Assistant picker.
NewChatPickeris opened bysidebar-new-chat-withandsidebar-browse-assistants. A pick sends exactly onesessions.create(a ref guard, not just React state, stops a double click), carrying the optional model frommodels.list; a failingmodels.listnever blocks creating with “Assistant default”.sidebar-new-chatcreates 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 onlabel already in use, at most 20 attempts. - History and the chat leaf.
useChatHistoryloads the newest page (HISTORY_PAGE_SIZE, 50) and pages older with the returnedcursor.WebClientRuntimerecords the chat’s leaf from its newest-pagechat.historyreads (the thinking reconcile opts out withnoteLeaf: false); abeforepage never updates it.LeafTrackertells “never learned” (undefined: the send omitsexpectedLeafEntryId) from “known empty” (null: the send asserts an empty chat). After a final, aborted or error event for a run this client owns,readLeafUntilStablere-reads withlimit: 1until 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. Onactive-leaf-changedthe runtime reloads the thread, raiseschat-changed-noticeand rejects withActiveLeafChangedError;useChatSendputs 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.steerwithmode: "queue"and shows the turn at once withmessage-queued-badge; Ctrl/Cmd+Enter or the send menu’s Send now (composer-send-menu-now) sendsmode: "inject". Both sendfallback: "queue", and onlyqueuecarries 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, becausesessions.steerhas noreplyToId. A queued turn is saved insessionStorage(openclaw.web.pendingQueuedTurns.v1, dropped after 24 hours) until its run drains, so it survives a reload or reconnect;useChatHistoryshows it again only whensessions.runs.liststill reports its run. - Replies. Only a message with a real entry id (
__openclaw.id) offers Reply. It sets thereplyChipin the store (composer-reply, excerpt up to 140 characters), and the nextchat.sendcarries its entry id asreplyToIdand clears the chip, whether or not the send succeeds. The quote renders from__openclaw.replyToasmessage-reply-quote, live and after a reload. - Chat list.
SessionListRuntimeowns the assistant filter, the search (debounced 250 ms),loadMore(the nextoffsetis the number of rows already loaded) and thesessions.changedrefetch, 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.includeDerivedTitlesremains opt-in: callers displaying the effective sorted titles should request it explicitly.agentActivityaggregates 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 assistantsexplicitly 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.
DisplayMenustoresshowReasoning: falseandtools: "minimal"by default, separately fromreasoningLevel.extractTranscriptBlockspreserves ordered text, thinking, tool calls, results and media;Threadpairs tools by call ID across loaded pages. Legacytoolroles 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 acompleted-turnreconciliation 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.tsexcludes 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 beforechat.sendacknowledges 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 sharenormalizeMediaUrl: HTTP(S) and inline raster images/WAV are allowed; unsupported schemes and data types remain non-interactive metadata. - Colours.
ui/assistant-color.tsmaintains a browser-safe palette identical, in order, toASSISTANT_IDENTITY_COLORSinsrc/shared/assistant-identity-values.ts. A configuredidentity.colorwins; 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-fgtokens are inpackages/brand-tokens/tokens.css; ADR 0084 section 6 records the contrast guarantee. - Menus.
useDismissibleMenucloses a menu on an outside mousedown and on Escape, returning focus to its trigger.ModelMenu,ThinkMenuand 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 onlyoperator.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.*andtasks.runs.*; writeschat.send,chat.abort,sessions.create,sessions.steer,sessions.abort,sessions.runs.cancel,sessions.runs.continue,sessions.patchandsessions.title.prepare(ADR 0083).sessions.patchis field-guarded even for this narrow client: onlylabel,model,thinkingLevelandreasoningLevelare admitted (NARROW_SESSION_PATCH_FIELDS); every other field still needsoperator.adminand is refused withmissing scope: operator.admin, the same shape as a fully unlisted method. The patch response is redacted for this client (nopath, 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.
Test at the real entry point
Unit tests underapps/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.
Related
- Brand tokens - the shared tokens and fonts under
client.css - Web client hosting - deployment, origins,
config.json - Using the web client - what users see
- Gateway protocol - client identity and scopes