docs/mobile-chat/input-bar-controls/02-high-level-design.md
Status: active · Task: input-bar-controls · Approach: B — Web-Parity-First
Adds two controls to the mobile chat composer, mirroring the web app: a deep-research toggle (a pill that tells the model to do a thorough, multi-step search before answering) and an Actions menu (a popover where the user picks which tools the agent may use, forces one specific tool, and chooses which knowledge sources to search). Today the mobile composer only lets you type, attach files, and send — these controls unlock the same tool/search control that web users already have.
When a chat screen is open, the composer already knows which agent is active (the "live agent"). Every agent that comes back from the server already carries the list of tools it can use and the knowledge sources it can search — that data is already on the wire today, the mobile app just wasn't reading it. So the moment we teach the mobile agent type to expose those two fields, the composer has everything it needs with no extra network calls.
The composer's toolbar row gains, next to the existing paperclip:
Inside the Actions panel, each tool the agent has is a row. Tapping a row forces that tool (the model must use it this turn) and highlights the row; tapping it again clears the force. A separate toggle on the same row enables or disables that tool — a disabled tool is removed from the set the agent is allowed to use. The search tool's row has a chevron that drills into a second sources view: a back button, an "Enable All / Disable All" row, and one switch per knowledge source. Turning sources on and off is coupled to the search tool the way web does it — turning a source on makes sure search is active; turning the last one off releases it.
When the user hits send, the composer gathers four values and adds them to the message request
that already goes to the backend: deep_research (the toggle), forced_tool_id (the forced tool),
allowed_tool_ids (all the agent's tools minus the disabled ones), and internal_search_filters
(the chosen sources). The backend has always accepted these fields; mobile simply wasn't sending
them. The rest of the streaming path is unchanged.
Two of these values are remembered differently, matching web exactly: the enable/disable choice per tool is saved on the server per agent (so it survives app restarts and stays in sync with web), while the forced tool, the sources, and the deep-research toggle are ephemeral — they reset when you switch agents or open a different conversation.
GET /persona ──► agent.tools[], agent.knowledge_sources[]
│ (already on the wire; just widen the type)
▼
┌──────────────────────────── ChatSurface (composer host) ────────────────────────────┐
│ useLiveAgent → selectedAgent useWorkspaceSettings → deep_research_enabled │
│ │
│ ComposerToolsProvider (state hub, keyed by session+agent) │
│ ├─ useDeepResearchToggle → deepResearchEnabled (ephemeral) │
│ ├─ useForcedTools → forcedToolId (ephemeral) │
│ ├─ useAgentPreferences → disabledToolIds ◄──► GET/PATCH /…/agent-preferences │
│ └─ useSourceSelection → selectedSources (ephemeral) │
│ │ resolves → { deep_research, allowed_tool_ids, │
│ │ forced_tool_id, internal_search_filters } │
│ ▼ │
│ ┌────────── InputBar (toolbar row) ──────────┐ │
│ │ [paperclip] [Actions ▸] [DeepResearch pill] │ │
│ │ [forced-tool pills…] … [send] │ │
│ └───────────────────┬───────────────────────────┘ │
│ │ opens │
│ ▼ │
│ ActionsPopover ── anchored via Popover primitive ──► <PortalHost/> │
│ ├─ primary: ActionLineItem rows (force + enable/disable + chevron) │
│ └─ secondary: SourceSwitchList (back + Enable-All + Switch rows) │
└──────────────────────────────────┬────────────────────────────────────────────────────┘
│ send
▼
useChatController.submit(text, files, onAccepted, toolOptions)
│ builds SendMessageBody (+4 fields)
▼
runChatStream → streamChatMessage → POST /chat/send-chat-message
New primitives (Popover, SelectButton, Switch) sit under mobile/src/components/ui/ and are
reused by these chat components.
Popover — anchored floating-panel primitive: measures the trigger, renders through a Portal,
opens upward, clamps on-screen, handles the keyboard. (new, components/ui/)SelectButton — stateful pill (empty/selected, foldable to icon-only); backs the deep-research
toggle and the forced-tool chips. (new, components/ui/)Switch — track+thumb toggle for the source rows. (new, components/ui/)ActionsPopover — the tools menu: composes Popover + the primary action list + the source
sub-view. (new, components/chat/)ActionLineItem — one tool row (force-on-tap + enable/disable + drill-in chevron). (new)SourceSwitchList — the secondary sources view (back + Enable-All/Disable-All + Switch rows).
(new)ComposerToolsProvider — the state hub that aggregates the four send-body values for the
current conversation. (new, state/)useDeepResearchToggle / useForcedTools / useAgentPreferences / useSourceSelection — the
state hooks (three ephemeral, one server-persisted). (new, hooks/)tools.ts / sources.ts — the ToolSnapshot type, tool-id constants, getIconForAction,
hasSearchToolsAvailable, computeAllowedToolIds, buildInternalSearchFilters. (new, chat/)InputBar — renders the new controls in its left cluster. (modified)ChatSurface — wraps ComposerToolsProvider, feeds the agent's tools in, threads the resolved
options into send. (modified)useChatController / stream.ts / agents.ts / settings.ts — widen the send body + the
agent + settings types; drop the hardcoded deep_research: false. (modified)deep_research: true, forced_tool_id: <image-gen id>,
allowed_tool_ids: [<all tools except Web Search>], and
internal_search_filters: { source_type: [<the three kept connectors>] }.GET /persona returns agents with tools + knowledge_sources (already happening; type widened).ChatSurface resolves the live agent + workspace settings; ComposerToolsProvider mounts the
four state hooks and fetches per-agent disabled_tool_ids.InputBar conditionally renders the deep-research pill and Actions trigger from gating predicates.ActionsPopover; Popover measures the trigger and positions the panel.useChatController.submit writes them into
SendMessageBody; runChatStream posts unchanged.disabled_tool_ids reloads.Popover on the same Portal + reanimated stack the sidebar already proves.
Because the bar is bottom-docked above the keyboard, the panel opens upward and dismisses the
keyboard on open — the one documented, platform-driven divergence from Radix's literal
side="bottom".MinimalPersonaSnapshot already returns tools +
knowledge_sources; we widen the mobile type instead of adding a fetch. (Verified:
backend/onyx/server/features/persona/models.py:202,212.)disabled_tool_ids per agent; keep the rest ephemeral. This matches web exactly
(web persists via PATCH, resets forced-tool/deep-research per session) so the two clients stay in
sync and the behavior is predictable. It also adds the mobile app's first agent-preferences write
path.SelectButton, Switch, Popover). They don't exist on mobile
yet, are needed for faithful look, and become reusable infrastructure for every future toolbar/menu
port — done pixel/behavior-exact via port-web-component-to-mobile.deep_research
moves from a hardcoded false to the toggle's value (defaulting to false when the pill isn't
shown or is off). No backend change.disabled_tool_ids) starts being written from mobile — shared with the
same web preference, so a tool disabled on mobile is disabled on web for that agent, and vice
versa.