UI Surface Model¶
The frontend runtime is organized around one persistent session layer and a small state machine that decides which surface is visible.
The canonical owner is chat-ui/src/state/uiSurfaceReducer.js. ChatUIContext stores the reducer state plus the session caches that survive navigation:
activeChatIdactiveWorkflowNameaskMessagesworkflowMessagesworkflowStatus
Core rule¶
There is one session, not separate chat products.
The user is either:
- on the full chat surface
- on another route with the same session available through the widget
- in workflow
viewlayout, where the artifact takes over the screen and the widget becomes the re-entry point back into chat
Visible surfaces¶
| Surface | When visible | Owner |
|---|---|---|
| Full chat surface | /chat and workflow chat routes | chat-ui/src/pages/ChatPage.js |
| Floating widget | non-chat routes | chat-ui/src/widget/GlobalChatWidgetWrapper.jsx + chat-ui/src/components/chat/PersistentChatWidget.jsx |
Floating widget in view layout | fullscreen artifact inside ChatPage | ArtifactPanel via floatingWidget |
view is not a third conversation product. It is a workflow layout state.
Surface state machine¶
The reducer tracks three related concepts:
Conversation mode¶
askmeans general chatworkflowmeans an active workflow run
ask always collapses to full layout because it does not own an artifact panel.
Layout mode¶
fullmeans chat onlysplitmeans chat plus artifactminimizedmeans chat compressed while artifact remains visibleviewmeans fullscreen artifact with widget re-entry
Widget state¶
The reducer also tracks widget visibility separately from route ownership:
isInWidgetModeisWidgetVisibleisChatOverlayOpenwidgetOverlayOpen
GlobalChatWidgetWrapper suppresses widget rendering on primary chat routes and enables it on other routes.
navigation/shellRoutes.js selects the routes shared by RouteRenderer and the widget wrapper. A declared routable / replaces the default ChatPage; an explicit non-root landing_spot still redirects / first. The wrapper uses React Router's matching precedence to suppress the launcher on the selected ChatPage, including declared chat aliases. Custom app pages retain the launcher, including root pages and static routes that outrank the core /chat/* or /app/* fallback. Route authorization remains in RouteWrapper. The wrapper hides during authentication loading, on login/callback surfaces, and when the selected page requires a user or role the viewer lacks. Explicit public pages (meta.requiresAuth: false) keep their launcher without a user.
Event-driven surface changes¶
Frontend surfaces are not changed by ad hoc component logic alone. The reducer reacts to event classes:
| Event family | Effect on surface |
|---|---|
chat.tool_call with display=artifact|view|fullscreen | opens artifact surface |
This mapping lives in mapSurfaceEventToAction(...).
Session continuity¶
Navigation must not discard the active session.
Current implementation:
ChatUIContextsurvives route changesChatPagerestores cachedaskMessagesandworkflowMessages- artifact state is cached for restoration
- the widget shares the selected general conversation and message cache, using its own Ask carrier connection to restore that conversation from the server
A user can leave /chat, browse elsewhere, and reopen the same general conversation in the widget. Workflow sessions remain in full ChatPage.
Widget contract¶
The widget is the session entry point outside the full chat surface.
Where it renders¶
| Context | Mounted by |
|---|---|
| non-chat routes | GlobalChatWidgetWrapper |
ChatPage view layout | ArtifactPanel |
What it shows¶
The widget is ask-mode only. It always renders askMessages from its own general-mode connection; workflow sessions never render inside the widget. Its WebSocket declares transport_purpose=ask_carrier at connect time, so the backend never binds the widget's carrier chat to a workflow session, auto-starts a workflow on it, or replays workflow history into it. Each message sends the current route's page_context (the page's declared description) and page_path (the route pattern). The backend uses page_path to resolve the page's declared meta.ask_context actions — read-only module actions whose results ground the ask agent's answers in live page data. The client only ever names the page; the declarations and dispatch are server-side.
The platform host resolves a workflow name only for workflow transport. An explicit Ask carrier does not require any app-local workflow to exist; its authentication, app scope and general-mode checks still apply.
Both schema-native AppPageMeta and custom-route metadata support the same AppAskContextAction declarations. App loading checks declared module/action references and ask eligibility; Factory acceptance checks the actual saved module contracts, including custom-route manifest metadata. Unknown references, actions without ask_context_safe: true, and actions requiring permissions fail before promotion. Plan-only action names cannot authorize ask context. Eligibility is independent of api_surface; it never makes an action public. Runtime dispatch remains best-effort for operational failures, not a substitute for this artifact validation. No user permissions or identity are added by it.
Header contract¶
The expanded widget keeps a fixed header:
- left button (app display name, then theme brand name, then "Assistant"): opens the full ask chat page
- support button (🛟): opens the operator support form
- right logo button (same brand logo as the collapsed toggle): returns to the active workflow workspace when one exists — resolved from app/user-scoped session storage or the server's owned session snapshot/list. With no session, it opens the app's declared
meta.freshStartentrypoint. Apps declaring neither a resumable session nor an entrypoint omit this button; unscoped storage does not authorize a guessed workflow destination.
The compose affordance for ask mode lives in the sub-header as + New conversation. It sends the existing chat.start_general_chat command and changes the visible conversation only after chat.general_session_created acknowledges its server ID. Older queued input must finish before starting another conversation; input typed while the new acknowledgement is pending waits for the new conversation.
Saved Ask history¶
useWidgetAskWS restores the server-acknowledged general conversation through the authenticated fetchGeneralChatTranscript API. The widget and full ChatPage share session/generalTranscript.js for message presentation. Carrier storage is scoped by app and user; a stored general ID is only a request to the server, not authority to display another user's transcript.
Queued input waits for successful history restoration. A missing, unavailable or wrong-scope response shows History unavailable with Retry history, distinct from a valid empty conversation. A closed socket offers Retry connection. Late responses from an old identity, connection or selected conversation cannot replace the current messages. Minimize/reopen keeps the same connection and does not reload history. Live additions and optimistic messages survive a pending restore; overlaps use the persisted ID forwarded as metadata.general_message_id, never equal text or the carrier's transport sequence. Unscoped stream chunks are ignored; accepted sends remain visibly pending until their scoped completions. Queued drafts stay in the existing in-memory queue under their acknowledged conversation ID. Switching conversations hides those drafts and holds their delivery until that same conversation is selected again. Changing app/user clears the queue; no new draft persistence is introduced.
Route suppression¶
GlobalChatWidgetWrapper returns null for the selected full ChatPage route, including the default root fallback, core /chat/* and /app/* fallbacks, and declared chat aliases. A more specific declared app page uses its own widget. Unresolved routes and authentication surfaces also suppress the launcher.
That keeps the widget from competing with the full chat surface.
Canonical implementation files¶
chat-ui/src/state/uiSurfaceReducer.jschat-ui/src/context/ChatUIContext.jsxchat-ui/src/pages/ChatPage.jschat-ui/src/widget/GlobalChatWidgetWrapper.jsxchat-ui/src/components/chat/PersistentChatWidget.jsx