docs/mobile-chat/input-bar-controls/01-research.md
Status: draft · Task: input-bar-controls
Port web's chat input-bar toolbar controls — the ActionsPopover (tools/actions menu) and the deep-research toggle — into the Onyx React Native mobile composer, at Tier 2 (Standard) scope.
Q (scope): How much of web's ActionsPopover do we port?
A: Tier 2 — Standard. In scope: deep-research toggle; a tap-to-"force" actions list
(forced_tool_id); enable/disable individual tools (allowed_tool_ids); a source/connector
selection sub-view (internal_search_filters). Out of scope: MCP servers, per-MCP-tool
switches, OAuth re-authentication, the admin "More Actions" link, and the action search box.
Load-bearing facts (verified against source, not assumed):
GET /persona
serves MinimalPersonaSnapshot, which already carries tools: list[ToolSnapshot]
(backend/onyx/server/features/persona/models.py:202) and
knowledge_sources: list[DocumentSource] (:212). Mobile's useAgents()
(mobile/src/api/chat/agents.ts) already hits this endpoint; MinimalAgent
(mobile/src/chat/agents.ts:15) is just a hand-picked subset that omits those two fields.
→ Widen the type; zero new network calls.SendMessageRequest
(backend/onyx/server/query_and_chat/models.py) has allowed_tool_ids (:110),
forced_tool_id (:111), internal_search_filters (:115, a BaseFilters), and
deep_research (:117). No backend change required (additive optional fields only).deep_research_enabled admin flag exists. Settings.deep_research_enabled: bool | None
(backend/onyx/server/settings/models.py:50). Surfaced through GET /settings; mobile's
useWorkspaceSettings() (mobile/src/api/settings.ts) just doesn't type it yet.Mobile — current state:
mobile/src/components/chat/InputBar.tsx — toolbar control row at lines 119-148.
LEFT cluster currently holds only the paperclip Button (onPress={() => setPickerOpen(true)});
RIGHT cluster holds send/stop. InputBar is stateless about the send body.mobile/src/components/chat/ChatSurface.tsx — persistent overlay that mounts
InputBar, wires onSend/onStop to useChatController, computes personaId/liveAgent, and
passes the composer draft. This is where new composer-level state threads from.mobile/src/api/chat/stream.ts SendMessageBody sends only
{message, chat_session_id, parent_message_id, file_descriptors, deep_research, origin}.
deep_research is hardcoded false (mobile/src/hooks/useChatController.ts:296).
allowed_tool_ids, forced_tool_id, internal_search_filters are missing — must be added
to the interface, the body build, and threaded through submit() → runChatStream().mobile/src/components/chat/FilePickerSheet.tsx — a RN Modal bottom sheet (transparent,
animationType="slide", rgba(0,0,0,0.4) scrim Pressable, inner Pressable sheet
rounded-t-20, composes LineItemButton + Separator), explicitly "web's FilePickerPopover, as
a bottom sheet instead of a hover popover" (:32), with the iOS "defer action past onDismiss"
gotcha handled. Also available: @rn-primitives/portal + react-native-reanimated@4.3.1 +
react-native-gesture-handler; <PortalHost/> mounted at mobile/src/app/_layout.tsx:71;
mobile/src/components/sidebar/Sidebar.tsx already drives a Portal + reanimated + gesture overlay.mobile/src/components/ui/): Button (icon-only + label pill; prominence
primary/secondary/tertiary; has an active color cell in button.styles.ts), LineItemButton
(selectable full-width row, icon + title + description + selected, leading slot), Text,
Icon, Separator, Spinner. No Switch/Toggle, no SelectButton pill primitive.mobile/src/icons/, hand-rolled react-native-svg): has sliders/sliders-small
(tools trigger), plus, terminal-small, settings, search, check-small, chevron-down,
paperclip, x. Missing: hourglass (web's deep-research icon) and several per-tool icons
(web maps in_code_tool_id → SvgSearch/SvgGlobe/SvgImage/SvgTerminal/SvgLink/SvgCpu).mobile/src/hooks/useComposerDraft.ts + ComposerDraftProvider
keyed ${sessionId}:${projectId}; zustand stores in mobile/src/state/ incl. settingsStore.ts
(persisted-to-MMKV prefs). Server state = TanStack Query keyed by serverUrl
(mobile/src/api/query-keys.ts); persisted MMKV cache excludes PII keys.mobile/src/chat/agents.ts MinimalAgent — id/name/description/starter_messages/ uploaded_image_id/icon_name/.../labels, no tools, no knowledge_sources.Web — port source of truth:
web/src/refresh-components/popovers/ActionsPopover/index.tsx — Opal
Popover/PopoverMenu over Radix (@radix-ui/react-popover), trigger = Opal
Button icon={SvgSliders} tertiary tooltip "Manage Actions", Popover.Content side="bottom" align="start" width="lg" (~15rem/240px). Primary view: search box + ActionLineItem rows
(ActionLineItem.tsx: tap row = force the tool → highlighted selected; hover SvgSlash
sub-button = enable/disable; SvgChevronRight = drill into sources). Secondary SwitchList.tsx:
back-chevron + Enable/Disable-All + rows of label + right-aligned Switch (source rows show a
leading SourceIcon).SelectButton variant="select-light" icon={SvgHourglass} state={on?"selected":"empty"} foldable={!on} + label "Deep Research" (pill, folds to icon-only
when off). State: web/src/hooks/useDeepResearchToggle.ts — ephemeral useState(false),
auto-resets on session/agent change, not persisted. Visibility gated by admin
deep_research_enabled (default true) + not-in-project + hasSearchToolsAvailable(agent.tools).forcedToolIds, a SelectButton state="selected" with the
tool icon + display_name, click-to-remove — shares the pill component with deep-research.web/src/lib/tools/interfaces.ts ToolSnapshot {id, name, display_name, description, in_code_tool_id, mcp_server_id?, chat_selectable, ...}. Force = zustand
useForcedTools (cleared on agent change; forced_tool_id = forcedToolIds[0]). Enable/disable =
useAgentPreferences.disabled_tool_ids — server-persisted per-agent via PATCH (not
localStorage); allowed_tool_ids = agent tools − disabled_tool_ids. Sources =
filterManager.selectedSources → internal_search_filters (only source_type used at Tier 2).Known gap (affects the most-used agent): the default agent (id 0) has empty
knowledge_sources; web special-cases it to "all accessible sources" via a connectors/CC-pairs
fetch. Mobile has no connectors API today, so the id-0 source sub-view is either empty or needs a
new lightweight connectors query. Flagged for the design phase.
triggerRef.measure((ox,oy,w,h,px,py) => …) / measureInWindow(...), then render the panel
through a Portal/Modal near the root and compute position from the measured rect; place the panel
"as close to its trigger as possible" and clamp so it never clips. —
react-native-popover-view,
Popover UX best practicesreact-native-popover-view (measures anchor,
auto-placement engine, pre-styled), react-native-modal-popover (RN Modal-based), NativeBase
Popover (WCAG-compliant, headless-capable), plus fully headless options that give logic only
and leave styling to the design system. —
DEV article,
SteffeyDev/react-native-popover-viewFilePickerSheet). This is the central tension the
three approaches take different positions on.Clone FilePickerSheet into a single ActionsSheet bottom sheet; the source sub-view is a swapped
content state (useState<"actions" | "sources">) inside the same Modal (a back-chevron header
mirrors web's secondaryView). Deep-research is a labeled Button in the left cluster
(interaction="active" for the on state); forced/disabled/source selection is ephemeral React state
in a small useComposerTools hook mounted in ChatSurface. No generalized popover, no measure(),
no Switch — the no-hover enable/disable maps to a row tap = force + a trailing icon-Button
(SvgCheckSmall) = enable/disable on the same LineItemButton; sources are whole-row toggles.
All four fields thread onto the body via one new sendOptions arg on submit().
Size: ~590-680 LOC, 2 PRs. Biggest bet: selections are session-ephemeral (web persists
disabled_tool_ids per-agent server-side) and web's search-tool↔source auto-sync is simplified.
Build a real anchored Popover primitive on the Sidebar's Portal + reanimated stack —
measureInWindow() the SvgSliders trigger, render the 240px panel through <Portal>, open
upward (documented divergence from Radix side="bottom"), Keyboard.dismiss() on open, clamp
on-screen, maxHeight + ScrollView. Port three primitives pixel/behavior-exact via
port-web-component-to-mobile: SelectButton (stateful pill, shared by deep-research + forced
chips), Switch (source rows), and the Popover itself. Full Tier-2 parity: the drill-in source
SwitchList (back-chevron / Enable-All / rows + switches), server-persisted per-agent
disabled_tool_ids (new GET+PATCH agent-preferences API), the search-tool↔source coupling, and
correct agent-change resets. Size: ~2,600-2,900 LOC, 5 PRs. Highest fidelity and gives the app a
reusable Popover/Switch/SelectButton, at the cost of the app's riskiest new pattern (manual
measure/position/keyboard math on a keyboard-adjacent bottom bar) and its first agent-preferences
write path.
Three seams carry future growth without over-engineering. (a) Extract a BottomSheet shell from the
boilerplate FilePickerSheet already hand-rolls (two proven consumers) — renders a sheet today, but
lifecycle (open/dismiss/safe-area/sub-view) is kept separate from positioning so an anchored mode
can slot in later without touching menu content. (b) A bounded toolbar-control registry
(buildToolbarControls()) turns deep-research, forced-tool pills, and future controls (MCP, voice,
tab-reading) into ordered pill specs the composer maps over — no InputBar surgery per control.
(c) All four fields flow through one sendConfig object on submit(). Ports the two genuinely
missing primitives (Switch, SelectButton); conservatively extracts only the small
backend-wire-coupled tools unit (the ToolSnapshot type + in_code_tool_id constants + pure
hasSearchToolsAvailable/computeAllowedToolIds) to @onyx-ai/shared/contracts, explicitly not
the icon map, MCP/OAuth types, or the registry. Size: ~1,150-1,300 LOC, 3 PRs. Sources start
without the web auto-sync coupling; per-agent persistence is a documented upgrade seam.
FilePickerSheet); B builds an anchored popover for
literal web fidelity and accepts the measure/keyboard risk. C keeps anchoring as a future swap.Button/LineItemButton); B & C both port Switch
SelectButton; B additionally builds a full anchored Popover primitive.InputBar + the sheet for each; B gives reusable primitives but
no control registry.Switch, A via a check-Button); the default-agent (id 0) empty-sources
gap is a shared open item.Approach B — Web-Parity-First (selected at GATE 1, 2026-07-16). Consistent with the owner-enforced WEB-PARITY PRINCIPLE and the owner's track record of choosing full parity on PR3/4/5/7/8.
Commitments carried into design:
Popover primitive built on the Sidebar's Portal + reanimated stack —
measureInWindow() the SvgSliders trigger, render a ~240px panel through <Portal>, open
upward (documented divergence from Radix side="bottom" — forced by the bottom-docked bar),
Keyboard.dismiss() on open, clamp on-screen, maxHeight + ScrollView.port-web-component-to-mobile: SelectButton (stateful pill,
shared by the deep-research toggle and forced-tool chips), Switch (source sub-view rows),
and the anchored Popover itself.SwitchList (back-chevron / Enable-All /
rows + switches), server-persisted per-agent disabled_tool_ids (new GET+PATCH
agent-preferences API on mobile — the app's first agent-preferences write path), the
search-tool↔source coupling, and correct agent-change / session-change resets.deep_research_enabled +
hasSearchToolsAvailable(agent.tools).Open items to resolve in design:
knowledge_sources; needs a lightweight connectors
query for the "all accessible sources" case (affects the most-used agent).Est. ~2,600-2,900 LOC across 5 PRs (primitives → anchored popover → state layer → ActionsPopover/sources → wire-up).