From ad09138cbcbcd2531f59d91e811289339dadb5de Mon Sep 17 00:00:00 2001 From: SubashMohan Date: Thu, 30 Jul 2026 11:18:27 +0530 Subject: [PATCH] feat(mobile): input-bar controls spec + tool-options foundation (#13091) Co-authored-by: Claude Opus 4.8 (1M context) --- .../input-bar-controls/00-index.md | 30 ++ .../input-bar-controls/01-research.md | 223 ++++++++++ .../02-high-level-design.md | 174 ++++++++ .../input-bar-controls/03-detailed-design.md | 383 ++++++++++++++++++ .../04-implementation-plan.md | 213 ++++++++++ .../input-bar-controls/05-pr-roadmap.md | 191 +++++++++ mobile/src/api/chat/stream.ts | 13 + mobile/src/api/settings.ts | 4 + mobile/src/chat/__tests__/agents.test.ts | 2 + mobile/src/chat/__tests__/tools.test.ts | 103 +++++ mobile/src/chat/agents.ts | 7 + mobile/src/chat/sources.ts | 47 +++ mobile/src/chat/tools.ts | 90 ++++ .../avatars/__tests__/AgentAvatar.test.tsx | 2 + .../ui/__tests__/select-button.test.tsx | 56 +++ .../src/components/ui/select-button.styles.ts | 69 ++++ mobile/src/components/ui/select-button.tsx | 86 ++++ mobile/src/components/ui/switch.tsx | 77 ++++ .../__tests__/useChatController.test.tsx | 55 +++ mobile/src/hooks/useChatController.ts | 10 +- mobile/src/icons/cpu.tsx | 23 ++ mobile/src/icons/globe.tsx | 23 ++ mobile/src/icons/hourglass.tsx | 23 ++ mobile/src/icons/link.tsx | 26 ++ mobile/src/icons/plug.tsx | 23 ++ mobile/src/icons/server.tsx | 23 ++ mobile/src/icons/slash.tsx | 29 ++ mobile/src/icons/unplug.tsx | 23 ++ 28 files changed, 2027 insertions(+), 1 deletion(-) create mode 100644 docs/mobile-chat/input-bar-controls/00-index.md create mode 100644 docs/mobile-chat/input-bar-controls/01-research.md create mode 100644 docs/mobile-chat/input-bar-controls/02-high-level-design.md create mode 100644 docs/mobile-chat/input-bar-controls/03-detailed-design.md create mode 100644 docs/mobile-chat/input-bar-controls/04-implementation-plan.md create mode 100644 docs/mobile-chat/input-bar-controls/05-pr-roadmap.md create mode 100644 mobile/src/chat/__tests__/tools.test.ts create mode 100644 mobile/src/chat/sources.ts create mode 100644 mobile/src/chat/tools.ts create mode 100644 mobile/src/components/ui/__tests__/select-button.test.tsx create mode 100644 mobile/src/components/ui/select-button.styles.ts create mode 100644 mobile/src/components/ui/select-button.tsx create mode 100644 mobile/src/components/ui/switch.tsx create mode 100644 mobile/src/icons/cpu.tsx create mode 100644 mobile/src/icons/globe.tsx create mode 100644 mobile/src/icons/hourglass.tsx create mode 100644 mobile/src/icons/link.tsx create mode 100644 mobile/src/icons/plug.tsx create mode 100644 mobile/src/icons/server.tsx create mode 100644 mobile/src/icons/slash.tsx create mode 100644 mobile/src/icons/unplug.tsx diff --git a/docs/mobile-chat/input-bar-controls/00-index.md b/docs/mobile-chat/input-bar-controls/00-index.md new file mode 100644 index 0000000000..b19bc1180f --- /dev/null +++ b/docs/mobile-chat/input-bar-controls/00-index.md @@ -0,0 +1,30 @@ +# Mobile Input-Bar Controls (ActionsPopover + Deep-Research Toggle) + +> Status: active · Task: input-bar-controls · Approach: B — Web-Parity-First + +Port web's chat input-bar toolbar controls to the Onyx React Native mobile composer at **Tier-2 +(Standard)** scope: a **deep-research toggle** and an **anchored ActionsPopover** (force-a-tool + +enable/disable tools + a source-selection sub-view). Out of scope: MCP servers/tools, OAuth re-auth, +the admin "More Actions" link, the action search box. + +Feature-flow spec (read in order): + +1. [01-research.md](01-research.md) — requirement, Tier-2 scope decision, verified codebase scan + (both ends), popover industry analysis (bottom-sheet vs anchored), 3 approaches + the chosen one. +2. [02-high-level-design.md](02-high-level-design.md) — plain-language end-to-end flow, component + interaction diagram, end-to-end scenario, key decisions. +3. [03-detailed-design.md](03-detailed-design.md) — no DB/backend changes (endpoints + fields already + exist); interface design (types, primitives, hooks), new-files list, file tree, per-file contents, + pre-implementation notes. +4. [04-implementation-plan.md](04-implementation-plan.md) — CLAUDE.md-format plan + the appended + `plan-challenge` results (all six checks clear) + exact SVG path data for the 8 new icons. +5. [05-pr-roadmap.md](05-pr-roadmap.md) — 4 review-sized PRs (foundation → deep-research skeleton → + ActionsPopover tools → sources + coupling) with scope, files, tests, and drift checkpoints. + +**Load-bearing facts (verified):** the `/persona` payload already carries `tools` + +`knowledge_sources` (no new fetch for the catalog); the backend `SendMessageRequest` already accepts +all four fields; per-agent `disabled_tool_ids` already has a table + `GET`/`PATCH` endpoints. **No +backend work, no migration.** + +**Owner-owed gate:** the anchored popover's keyboard/off-screen behavior needs an on-device dev build +(PR 3); fallback is the `FilePickerSheet` bottom-sheet shell. diff --git a/docs/mobile-chat/input-bar-controls/01-research.md b/docs/mobile-chat/input-bar-controls/01-research.md new file mode 100644 index 0000000000..79995dd055 --- /dev/null +++ b/docs/mobile-chat/input-bar-controls/01-research.md @@ -0,0 +1,223 @@ +> Status: draft · Task: input-bar-controls + +# Mobile Input-Bar Controls (ActionsPopover + Deep-Research Toggle) — Research + +## Requirement + +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. + +## Clarifications + +**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. + +## Current status & reuse (from codebase scan — verified paths) + +**Load-bearing facts (verified against source, not assumed):** + +- **The tool + source catalog already rides the wire — no new API needed.** `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.** +- **Backend already accepts all four send fields.** `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:** + +- **Composer:** `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. +- **Composer host:** `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. +- **Send body:** `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()`. +- **Overlay primitives:** **no** generalized popover/menu/action-sheet. Closest pattern: + `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`; `` mounted at `mobile/src/app/_layout.tsx:71`; + `mobile/src/components/sidebar/Sidebar.tsx` already drives a Portal + reanimated + gesture overlay. +- **UI primitives** (`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. +- **Icons** (`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`). +- **State:** composer drafts via `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. +- **Agent model:** `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:** + +- **ActionsPopover:** `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`). +- **Deep research:** Opal `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)`. +- **Forced-tool pills:** for each id in `forcedToolIds`, a `SelectButton state="selected"` with the + tool icon + `display_name`, click-to-remove — **shares the pill component** with deep-research. +- **Tool model:** `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. + +## Industry best practices (how to build the popover on mobile) + +- **Bottom sheet vs. anchored popover is the core decision.** 2026 RN guidance: **bottom sheets** + are the native idiom for contextual actions/filtering anchored to the screen bottom; + **anchored popovers** point at a trigger and float above, but "must never render off-screen" and + "must reposition when the keyboard appears." — + [DEV: Top 5 RN Popover Components 2026](https://dev.to/eira-wexford/top-5-react-native-popover-components-for-developers-2026-1ge4), + [Reanimated Bottom Sheet docs](https://docs.swmansion.com/react-native-reanimated/examples/bottomsheet/) +- **Anchored popovers are built by measuring the trigger.** The canonical pattern is + `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](https://www.npmjs.com/package/react-native-popover-view), + [Popover UX best practices](https://www.eleken.co/blog-posts/popover-ux) +- **Libraries exist but each has a cost.** `react-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](https://dev.to/eira-wexford/top-5-react-native-popover-components-for-developers-2026-1ge4), + [SteffeyDev/react-native-popover-view](https://github.com/SteffeyDev/react-native-popover-view) +- **Headless-core is the 2026 recommendation.** Best-in-class popovers expose an unstyled/"headless" + API — logic (open/close, position, focus) separate from look — so a design system fully owns + styling. This maps directly to Onyx's token-based primitives. — + [Base UI Popover](https://base-ui.com/react/components/popover), + [react-native-popper](https://github.com/intergalacticspacehighway/react-native-popper) +- **The keyboard constraint is decisive here.** The Onyx composer is docked at the very bottom, + above an open keyboard. An *upward* anchored popover from a bottom trigger is precisely the + off-screen / keyboard-collision failure mode the guidance warns about, and mobile **already chose** + a bottom sheet over web's hover popover once (`FilePickerSheet`). This is the central tension the + three approaches take different positions on. + +## Approaches + +### Approach A — Simplicity-First: Bottom-sheet ActionsSheet, no new primitives + +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. + +### Approach B — Web-Parity-First: Anchored popover + ported primitives + +Build a real **anchored `Popover`** primitive on the Sidebar's Portal + reanimated stack — +`measureInWindow()` the `SvgSliders` trigger, render the 240px panel through ``, 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. + +### Approach C — Flexibility-First: Headless sheet + bounded control registry + +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. + +## Cross-comparison + +- **Overlay idiom:** A & C use a **bottom sheet** (native idiom, dodges the keyboard/off-screen + risk, consistent with the adjacent `FilePickerSheet`); B builds an **anchored popover** for + literal web fidelity and accepts the measure/keyboard risk. C keeps anchoring as a future swap. +- **Primitives:** A introduces **none** (reuses `Button`/`LineItemButton`); B & C both port `Switch` + + `SelectButton`; B additionally builds a full anchored `Popover` primitive. +- **Persistence & parity of behavior:** B matches web exactly (server-persisted per-agent tool prefs, + full source auto-sync); A & C keep selections ephemeral-per-conversation first, with persistence + as a follow-up. +- **Extensibility:** C is built for the four *named* future controls (MCP/voice/tab-reading + more) + to land additively; A would reopen `InputBar` + the sheet for each; B gives reusable primitives but + no control registry. +- **Cost:** A ≈ 2 PRs / ~650 LOC · C ≈ 3 PRs / ~1,200 LOC · B ≈ 5 PRs / ~2,700 LOC. +- **Shared no-hover enable/disable UX** (all three): row tap = force, a distinct trailing target = + enable/disable (B/C via a `Switch`, A via a check-`Button`); the default-agent (id 0) empty-sources + gap is a shared open item. + +## Chosen approach + +**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: +- **Anchored `Popover` primitive** built on the Sidebar's Portal + reanimated stack — + `measureInWindow()` the `SvgSliders` trigger, render a ~240px panel through ``, open + **upward** (documented divergence from Radix `side="bottom"` — forced by the bottom-docked bar), + `Keyboard.dismiss()` on open, clamp on-screen, `maxHeight` + `ScrollView`. +- **Three ported primitives** via `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. +- **Full Tier-2 parity behavior:** the drill-in source `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 stays **ephemeral** (matches web), gated by `deep_research_enabled` + + `hasSearchToolsAvailable(agent.tools)`. + +Open items to resolve in design: +- **Default agent (id 0) sources gap** — empty `knowledge_sources`; needs a lightweight connectors + query for the "all accessible sources" case (affects the most-used agent). +- The anchored-popover keyboard/off-screen behavior is the highest-risk piece → an on-device gate. + +Est. **~2,600-2,900 LOC across 5 PRs** (primitives → anchored popover → state layer → +ActionsPopover/sources → wire-up). diff --git a/docs/mobile-chat/input-bar-controls/02-high-level-design.md b/docs/mobile-chat/input-bar-controls/02-high-level-design.md new file mode 100644 index 0000000000..8f5b539141 --- /dev/null +++ b/docs/mobile-chat/input-bar-controls/02-high-level-design.md @@ -0,0 +1,174 @@ +> Status: active · Task: input-bar-controls · Approach: B — Web-Parity-First + +# Mobile Input-Bar Controls (ActionsPopover + Deep-Research Toggle) — High-Level Design + +## What it does + +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. + +## How it works (end-to-end walkthrough) + +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: + +1. A **deep-research pill** — but only when the workspace has deep research turned on *and* the + current agent actually has a search tool. Tapping it flips an on/off value that lives in memory + for the current conversation. +2. An **Actions trigger** (a sliders icon) — shown only when the agent has at least one selectable + tool. Tapping it opens a small floating panel anchored just above the trigger. + +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. + +## Component interaction + +``` + 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 ──► │ + │ ├─ 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. + +## Key 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) + +## End-to-end scenario + +1. User opens a chat with an agent that has Search, Web Search, and Image Generation tools, and the + workspace has deep research enabled. +2. The composer shows the paperclip, a **sliders "Actions" button**, and a folded **hourglass** + (deep-research) pill. +3. User taps **Actions** → a panel pops up above the button. The keyboard dismisses so the panel + isn't clipped. +4. User taps the **Image Generation** row → it highlights (forced). User taps the enable/disable + toggle on **Web Search** → Web Search is now disabled. +5. User taps the chevron on **Search** → the panel slides to the **sources** view. User turns off two + connectors, leaving three on, and taps back. +6. User taps the **hourglass** pill → it expands to "Deep Research" and highlights. +7. User types a question and taps **send**. The request now carries + `deep_research: true`, `forced_tool_id: `, + `allowed_tool_ids: []`, and + `internal_search_filters: { source_type: [] }`. +8. The enable/disable choice (Web Search off) is saved to the server for this agent; next time the + user opens this agent, Web Search is still disabled. The forced tool, sources, and deep-research + pill reset. + +## Sequence of key operations + +1. `GET /persona` returns agents with `tools` + `knowledge_sources` (already happening; type widened). +2. `ChatSurface` resolves the live agent + workspace settings; `ComposerToolsProvider` mounts the + four state hooks and fetches per-agent `disabled_tool_ids`. +3. `InputBar` conditionally renders the deep-research pill and Actions trigger from gating predicates. +4. User opens `ActionsPopover`; `Popover` measures the trigger and positions the panel. +5. User forces/enables/disables tools and picks sources; state updates flow into the provider; the + enable/disable change PATCHes the server. +6. On send, the provider resolves the four fields; `useChatController.submit` writes them into + `SendMessageBody`; `runChatStream` posts unchanged. +7. On agent/session change, ephemeral state resets; server-persisted `disabled_tool_ids` reloads. + +## Key decisions & why + +- **Anchored popover, not a bottom sheet (the web-parity call).** Web's control is a small panel + pinned to its trigger with a foldable-pill row and an in-place drill-in; a full-width sheet would + lose that relationship and make two adjacent toolbar buttons open different overlay idioms. We + build a real anchored `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"`. +- **No new API for the tool/source catalog.** `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`.) +- **Server-persist `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. +- **Port three real primitives (`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`. + +## What existing behavior changes + +- Nothing regresses for existing users: the four fields are additive and optional; `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. +- The composer's left cluster gains up to two new controls (shown only when their gates pass), so + agents with no tools / workspaces with deep research off see no visible change. +- A new per-agent preference (`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. diff --git a/docs/mobile-chat/input-bar-controls/03-detailed-design.md b/docs/mobile-chat/input-bar-controls/03-detailed-design.md new file mode 100644 index 0000000000..262f3d26db --- /dev/null +++ b/docs/mobile-chat/input-bar-controls/03-detailed-design.md @@ -0,0 +1,383 @@ +> Status: active · Task: input-bar-controls + +# Mobile Input-Bar Controls (ActionsPopover + Deep-Research Toggle) — Detailed Design + +Approach **B — Web-Parity-First**. All paths are repo-relative. Web references are the port source of +truth; mobile mirrors look **and** structure, documenting platform-driven divergences. + +--- + +## Database design + +**N/A — no schema changes.** The only persisted state is per-agent `disabled_tool_ids`, and the +table + endpoints already exist: + +- Table `assistant__user_specific_config` (`backend/onyx/db/models.py:4058-4069`): composite PK + `(assistant_id, user_id)`, `disabled_tool_ids: ARRAY(Integer) NOT NULL`. Already migrated + (`backend/alembic/versions/b329d00a9ea6_adding_assistant_specific_user_.py`). +- DB layer `backend/onyx/db/user_preferences.py` (`get_all_user_assistant_specific_configs:375`, + `update_assistant_preferences:387` upsert). +- Endpoints (`backend/onyx/server/manage/users.py`), both `BASIC_ACCESS`: + - `GET /user/assistant/preferences` → `UserSpecificAssistantPreferences` + (`= dict[int, {disabled_tool_ids: list[int]}]`). + - `PATCH /user/assistant/{assistant_id}/preferences`, body + `UserSpecificAssistantPreference {disabled_tool_ids: list[int]}` → 200, null body. + +**No backend work.** The four send fields (`deep_research`, `allowed_tool_ids`, `forced_tool_id`, +`internal_search_filters`) already exist on `SendMessageRequest` +(`backend/onyx/server/query_and_chat/models.py:110,111,115,117`). + +--- + +## Class / interface design + +### New mobile contracts (`mobile/src/chat/`) + +```ts +// mobile/src/chat/tools.ts — mirrors web/src/lib/tools/interfaces.ts (Tier-2 subset) +export interface ToolSnapshot { + id: number; + name: string; + display_name: string; + description: string; + in_code_tool_id: string | null; // matches SEARCH_TOOL_ID etc. + mcp_server_id: number | null; // MCP tools excluded from the list at Tier-2 + chat_selectable: boolean; // visibility filter +} + +// in-code tool identifiers — from web/src/app/app/components/tools/constants.ts +export const SEARCH_TOOL_ID = "SearchTool"; +export const WEB_SEARCH_TOOL_ID = "WebSearchTool"; +export const IMAGE_GENERATION_TOOL_ID = "ImageGenerationTool"; +export const FILE_READER_TOOL_ID = "FileReaderTool"; // always hidden from the list +// (PYTHON_TOOL_ID, OPEN_URL_TOOL_ID, CODING_AGENT_TOOL_ID for icon mapping) + +export function isSearchTool(t: ToolSnapshot): boolean; // in_code_tool_id === SEARCH_TOOL_ID +export function hasSearchToolsAvailable(tools: ToolSnapshot[]): boolean; // Search OR WebSearch present +export function displayableTools(tools: ToolSnapshot[]): ToolSnapshot[]; // chat_selectable, no MCP, no FileReader +export function computeAllowedToolIds( // agent tools − disabled + tools: ToolSnapshot[], disabledToolIds: number[], +): number[] | null; // null when nothing disabled (backend = allow all) +export const getIconForToolId: (inCodeToolId: string | null) => IconFunctionComponent; // mobile @/icons map +``` + +```ts +// mobile/src/chat/sources.ts — mirrors web ValidSources + source metadata (Tier-2 subset) +export type DocumentSource = string; // snake_case wire values ("web","google_drive",…) +export interface SourceMeta { icon: IconFunctionComponent; displayName: string; } +export const SOURCE_META: Record; // fallback → generic globe/file +export function getSourceMeta(s: DocumentSource): SourceMeta; +export function buildInternalSearchFilters( // → { source_type } | null + selectedSources: DocumentSource[], +): InternalSearchFilters | null; + +// mobile/src/api/chat/stream.ts — new wire type +export interface InternalSearchFilters { source_type: DocumentSource[] | null; } +``` + +```ts +// The resolved bundle threaded into submit() +export interface ChatToolOptions { + deepResearch: boolean; + allowedToolIds: number[] | null; + forcedToolId: number | null; + internalSearchFilters: InternalSearchFilters | null; +} +``` + +### New primitives (`mobile/src/components/ui/`) + +```ts +// select-button.tsx — mirrors Opal SelectButton (state-driven, no hover) +type SelectState = "empty" | "selected"; +type SelectVariant = "select-light"; // only variant needed at Tier-2 +interface SelectButtonProps { + icon?: IconFunctionComponent; + children?: string; // label + state?: SelectState; // default "empty" + variant?: SelectVariant; // default "select-light" + foldable?: boolean; // label hidden when folded (icon-only) + disabled?: boolean; + onPress?: () => void; + accessibilityLabel?: string; +} +// SELECT_COLORS: Record>> +// resolveSelectState(disabled, pressed) — parallels button.styles.ts resolveButtonState (no hover) + +// switch.tsx — mirrors Opal Switch +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + accessibilityLabel?: string; +} // track 32×18 rounded-full; thumb 14×14; checked bg = action-link-05; reanimated thumb translate + +// popover.tsx — anchored floating panel (Portal + reanimated + measureInWindow) +interface PopoverProps { + open: boolean; + onClose: () => void; + anchorRef: RefObject; // the trigger, measured on open + width?: number; // default 240 (web "lg" = w-60) + children: ReactNode; +} // opens UPWARD from a bottom-docked trigger; clamps on-screen; Keyboard.dismiss() on open +``` + +### State hooks (`mobile/src/hooks/`) + provider (`mobile/src/state/`) + +```ts +useDeepResearchToggle({ chatSessionId: string | null; agentId: number | undefined }): + { deepResearchEnabled: boolean; toggleDeepResearch: () => void } + // EXACT web reset semantics: ref-guarded null→new-session preservation; reset on real session switch + // and on agentId change. Port of web/src/hooks/useDeepResearchToggle.ts (55 lines). + +useForcedTools({ agentId }): { forcedToolId: number | null; toggleForcedTool(id): void; clear(): void } + // single-element force semantics; reset on agent change. + +useAgentPreferences(): { + disabledToolIdsFor(agentId): number[]; + setDisabledToolIds(agentId, ids: number[]): Promise; // optimistic + PATCH + invalidate +} // TanStack Query GET /user/assistant/preferences keyed by serverUrl. + +useConnectorSources(): { sources: DocumentSource[]; isLoading: boolean } + // TanStack Query GET /manage/connector-status → BasicCCPairInfo[].map(c => c.source), deduped. + // (federated /federated optional; behind EE — deferred, see notes.) + +useSourceSelection({ agentId, availableSources, hasSearchTool }): { + selectedSources: DocumentSource[]; isEnabled(s): boolean; toggle(s): void; + enableAll(): void; disableAll(): void; initialized: boolean; +} // ephemeral per-agent; self-initializes to all when availableSources first non-empty. + +// state/ComposerToolsProvider.tsx — context aggregating the four resolved fields +useComposerTools(): { + ...triggers/state for InputBar + ActionsPopover... + resolveToolOptions(): ChatToolOptions; // the object submit() consumes +} +``` + +--- + +## New files + +| File | Responsibility | +|------|----------------| +| `mobile/src/components/ui/popover.tsx` | Anchored floating-panel primitive (measure trigger, Portal, upward, keyboard). | +| `mobile/src/components/ui/select-button.tsx` | Stateful pill primitive (empty/selected, foldable); backs deep-research + forced chips. | +| `mobile/src/components/ui/select-button.styles.ts` | `SELECT_COLORS` matrix + `resolveSelectState` (mirrors `button.styles.ts`). | +| `mobile/src/components/ui/switch.tsx` | Track+thumb toggle (reanimated); source rows. | +| `mobile/src/components/chat/ActionsPopover.tsx` | Tools menu: composes `Popover` + primary list + source sub-view; owns `open`/`subView`. | +| `mobile/src/components/chat/ActionLineItem.tsx` | One tool row: tap=force, trailing enable/disable + drill-in chevron. | +| `mobile/src/components/chat/SourceSwitchList.tsx` | Secondary view: back + Enable-All/Disable-All + `Switch` rows. | +| `mobile/src/components/chat/SourceIcon.tsx` | `DocumentSource` → logo/glyph (uses `SOURCE_META`). | +| `mobile/src/components/chat/ToolbarControls.tsx` | Renders the deep-research pill + forced-tool pills + Actions trigger in `InputBar`. | +| `mobile/src/chat/tools.ts` | `ToolSnapshot` type, tool-id constants, predicates, `getIconForToolId`. | +| `mobile/src/chat/sources.ts` | `DocumentSource`, `SOURCE_META`, `buildInternalSearchFilters`. | +| `mobile/src/hooks/useDeepResearchToggle.ts` | Ephemeral deep-research state (port of web hook). | +| `mobile/src/hooks/useForcedTools.ts` | Single-force state, reset on agent change. | +| `mobile/src/hooks/useAgentPreferences.ts` | GET/PATCH per-agent `disabled_tool_ids`. | +| `mobile/src/hooks/useConnectorSources.ts` | GET `/manage/connector-status` → source list. | +| `mobile/src/hooks/useSourceSelection.ts` | Ephemeral per-agent source selection + search coupling. | +| `mobile/src/api/chat/agentPreferences.ts` | `getAgentPreferences()` / `patchAgentPreferences(agentId, ids)`. | +| `mobile/src/api/chat/connectors.ts` | `getConnectorSources()` (connector-status fetch). | +| `mobile/src/state/ComposerToolsProvider.tsx` | Context hub aggregating the four fields; `resolveToolOptions()`. | +| `mobile/src/icons/{hourglass,globe,cpu,link,server,plug,unplug,slash}.tsx` | 8 new SVG icons (exact web path data in the plan). | + +**Modified:** + +| File | Change | +|------|--------| +| `mobile/src/chat/agents.ts` | Widen `MinimalAgent` with `tools: ToolSnapshot[]`, `knowledge_sources: DocumentSource[]`. | +| `mobile/src/api/settings.ts` | Add `deep_research_enabled?: boolean` to `WorkspaceSettings`. | +| `mobile/src/api/chat/stream.ts` | Add `allowed_tool_ids?`, `forced_tool_id?`, `internal_search_filters?` to `SendMessageBody`; add `InternalSearchFilters` type. | +| `mobile/src/hooks/useChatController.ts` | `submit(text, files?, onAccepted?, toolOptions?)`; replace hardcoded `deep_research: false` (`:296`) + populate three new fields (`:291`). | +| `mobile/src/components/chat/InputBar.tsx` | Render `` in the left cluster (`:119-127`); accept agent + toolbar props. | +| `mobile/src/components/chat/ChatSurface.tsx` | Wrap in `ComposerToolsProvider`; pass `liveAgent.tools`; thread `resolveToolOptions()` into `sendWithAttachments`→`submit`. | +| `mobile/src/api/query-keys.ts` | Add `agentPreferences`, `connectorSources` keys (keyed by `serverUrl`). | + +--- + +## File structure (tree) + +``` +mobile/src/ +├── components/ +│ ├── ui/ +│ │ ├── popover.tsx (new) +│ │ ├── select-button.tsx (new) +│ │ ├── select-button.styles.ts (new) +│ │ ├── switch.tsx (new) +│ │ ├── button.tsx / button.styles.ts (reference for SELECT_COLORS) +│ │ └── line-item-button.tsx (reused — rightChildren slot already exists) +│ └── chat/ +│ ├── ActionsPopover.tsx (new) +│ ├── ActionLineItem.tsx (new) +│ ├── SourceSwitchList.tsx (new) +│ ├── SourceIcon.tsx (new) +│ ├── ToolbarControls.tsx (new) +│ ├── InputBar.tsx (modified: left cluster renders ToolbarControls) +│ ├── ChatSurface.tsx (modified: ComposerToolsProvider + thread options) +│ └── FilePickerSheet.tsx (unchanged — separate paperclip sheet) +├── chat/ +│ ├── tools.ts (new) +│ ├── sources.ts (new) +│ └── agents.ts (modified: widen MinimalAgent) +├── hooks/ +│ ├── useDeepResearchToggle.ts (new) +│ ├── useForcedTools.ts (new) +│ ├── useAgentPreferences.ts (new) +│ ├── useConnectorSources.ts (new) +│ ├── useSourceSelection.ts (new) +│ └── useChatController.ts (modified: submit toolOptions + body build) +├── api/ +│ ├── chat/ +│ │ ├── agentPreferences.ts (new) +│ │ ├── connectors.ts (new) +│ │ └── stream.ts (modified: SendMessageBody + InternalSearchFilters) +│ ├── settings.ts (modified: deep_research_enabled) +│ └── query-keys.ts (modified) +├── state/ +│ └── ComposerToolsProvider.tsx (new) +└── icons/ + ├── hourglass.tsx globe.tsx cpu.tsx link.tsx (new) + └── server.tsx plug.tsx unplug.tsx slash.tsx (new) +``` + +--- + +## What each file will contain + +- **`popover.tsx`** — `Popover` component. On `open`, `anchorRef.current.measureInWindow((x,y,w,h)=>…)` + → store the rect; render a full-screen `` containing a transparent + outside-tap `Pressable` (closes) + an `Animated.View` panel positioned with + `bottom = windowHeight - anchorY + GAP`, `left = clamp(anchorX, GUTTER, screenW - width - GUTTER)`, + `maxHeight = anchorY - insets.top - GAP`, content in a `ScrollView`. `Keyboard.dismiss()` on open; + reanimated `FadeIn` + slight `translateY`/`scale` from the bottom origin. Mirrors Opal + `Popover.Content side="bottom" align="start" width="lg"` (`web/lib/opal/.../popover/components.tsx`), + flipped upward (documented divergence). +- **`select-button.tsx` + `.styles.ts`** — pill built like `Button` but with a **stateful** matrix + `SELECT_COLORS[variant][state][colorState]` (cells `{bg,fg,icon}`), `resolveSelectState(disabled, + pressed)` (no hover). `select-light`: transparent bg in all states; `empty` fg=`text-04`/icon=`text-03`, + `selected` fg/icon=`action-link-05` (from `stateful/styles.css:228-316`). `foldable` collapses the + label to icon-only — since mobile has no `:hover`, implement as **conditional label render** driven + by `state`/press (web's `foldable={!enabled}` = icon-only when off, label when on; the deep-research + use needs exactly that, no hover-expand). Icon 16px (`iconWrapper` lg = 1rem). +- **`switch.tsx`** — `role="switch"` `Pressable` track (32×18, `rounded-full`), reanimated thumb + (14×14) translating `2px`↔`17px`. Track bg `background-tint-03` → checked `action-link-05`; disabled + variants per `switch/styles.css`. Controlled via `checked`/`onCheckedChange`. +- **`ActionsPopover.tsx`** — composes `Popover`; local `subView: {type:"sources"} | null` (mirrors web + `secondaryView`). Primary body: `displayableTools(agent.tools).map(t => )`. + Secondary: ``. Reads/writes `useComposerTools`. In-place body swap with a slide + (reanimated `LinearTransition`), same panel/anchor. +- **`ActionLineItem.tsx`** — one `LineItemButton`: `selected={forcedToolId===tool.id}`, `onPress`=force + toggle (search tool not-yet-forced → open sources sub-view, mirroring web `ActionLineItem.tsx:98-108`). + `rightChildren`: a trailing enable/disable control (always-visible `Switch` **or** an icon-`Button` + with `SvgSlash` — mobile has no hover, so it can't be hover-revealed) + `EnabledCount` text for + partial search sources + `SvgChevronRight` for the search drill-in. Disabled tools render dimmed + (mobile `LineItemButton` lacks `strikethrough` — use `color="muted"` + a strike style, documented + divergence). +- **`SourceSwitchList.tsx`** — back-chevron header (`SvgChevronLeft` `Button`) + Enable-All/Disable-All + `LineItemButton` (`SvgPlug`/`SvgUnplug`) + per-source rows (`leading=`, label, + `rightChildren=`). Mirrors `SwitchList.tsx:61-119`. (Search box omitted — Tier-2 scope.) +- **`SourceIcon.tsx`** — `getSourceMeta(source).icon` via `Icon`; fallback glyph. +- **`ToolbarControls.tsx`** — renders, in order: the Actions trigger (`SvgSliders` `Button`, `ref` for + the popover anchor) when `displayableTools(agent.tools).length>0`; the deep-research `SelectButton` + (`SvgHourglass`, `state=on?"selected":"empty"`, `foldable={!on}`) when + `deep_research_enabled && hasSearchToolsAvailable(agent.tools)`; one forced-tool `SelectButton` + (`state="selected"`, tool icon+name, tap removes). Mounts ``. +- **`tools.ts` / `sources.ts`** — as specified in Class design. +- **hooks** — as specified; `useAgentPreferences` optimistic-set then PATCH then invalidate; + `useSourceSelection` ports the coupling (see notes). +- **`agentPreferences.ts` / `connectors.ts`** — thin `apiFetch` wrappers (paths bare — `getBaseUrl()` + already appends `/api`): `apiFetch("/user/assistant/preferences")`, + `apiFetch("/user/assistant/${id}/preferences", {method:"PATCH", body})`, + `apiFetch("/manage/connector-status")`. +- **`ComposerToolsProvider.tsx`** — mounts the hooks (keyed `${sessionId}:${projectId}` + `agentId`), + exposes the triggers/state and `resolveToolOptions()` = `{ deepResearch, allowedToolIds: + computeAllowedToolIds(tools, disabled), forcedToolId, internalSearchFilters: + buildInternalSearchFilters(selectedSources) }`. +- **8 icons** — exact `react-native-svg` ports of the web path data (viewBox `0 0 16 16` except + `link` = `0 0 17 9` + `rotate(315deg)`), captured verbatim in `04-implementation-plan.md`. + +--- + +## Integration points + +- **`InputBar.tsx:119-148`** — the left cluster (`flex-row items-center gap-8`, currently paperclip + only) gains `` after the paperclip. RIGHT cluster (send/stop) + unchanged. +- **`ChatSurface.tsx`** — wraps its subtree in ``; `sendWithAttachments` calls `submit(text, descriptors, onAccepted, + resolveToolOptions())`. +- **`useChatController.ts:228,291-298`** — `submit` gains a 4th `toolOptions?: ChatToolOptions` arg; + the body literal sets `deep_research: toolOptions?.deepResearch ?? false`, `allowed_tool_ids`, + `forced_tool_id`, `internal_search_filters`. `runChatStream` forwards `body` unchanged. +- **`stream.ts`** — `SendMessageBody` widened; JSON serialized as-is. +- **`useAgents()` / `useLiveAgent`** — no change; `liveAgent.tools`/`knowledge_sources` resolve once + `MinimalAgent` is widened (data already on the `/persona` wire, + `backend/onyx/server/features/persona/models.py:202,212`). +- **`useWorkspaceSettings()`** — `deep_research_enabled` read from the existing `/settings` GET + (`backend/onyx/server/settings/models.py:50`). +- **`app/_layout.tsx:71`** — existing `` hosts the popover; no change. +- **`@onyx-ai/shared`** — **not touched** in this feature. Contracts live natively in + `mobile/src/chat/` (matching the standing "chat layer is native, not shared" decision and the + `mobile/src/chat/contracts/projects.ts` precedent). Shared extraction deferred to a future + proven-reuse moment. + +--- + +## Important notes before implementation + +- **Anchored popover on a keyboard-adjacent bottom bar is the #1 risk.** Open upward + (`bottom`-anchored math), `Keyboard.dismiss()` on open, clamp `left`/`width` to the screen, cap + `maxHeight` + scroll. Re-measure on `keyboardWillShow/Hide` and on rotation. This is an on-device + gate — the agent can't verify it; the owner must run a dev build. Fallback if anchored proves + unstable on device: render the same `ActionsPopover` content through the `FilePickerSheet` + bottom-sheet shell (swap only the container) — content is renderer-agnostic. +- **`SELECT_COLORS` must be a fully-typed literal matrix** like `BUTTON_COLORS` + (`button.styles.ts:30-189`) — no computed keys — and use only NativeWind semantic token classes + (`bg-*`, `text-*`). Mobile has **no hover**, so collapse the web hover cells; keep `icon` distinct + from `fg` (web sets them separately). +- **`foldable` = state-driven, not hover-driven.** Web's `Interactive.Foldable` is a CSS + `:hover`-triggered grid animation. On mobile, render the label only when the pill is expanded + (`state==="selected"` for deep-research). Don't attempt a hover-expand. +- **No-hover enable/disable.** Web reveals `SvgSlash` on row hover. Decide one mobile affordance (a + persistent trailing `Switch` on the tool row, or a persistent `SvgSlash` icon-`Button`) and apply + it consistently. Port web's guard: **disabling a currently-forced tool clears the force** + (`ActionLineItem.tsx:98-108`). Recommend a trailing `Switch` for consistency with the source rows. +- **Search-tool ↔ sources coupling — port the guards exactly or it render-loops.** From + `ActionsPopover/index.tsx`: the reconcile effect is gated by `if (searchToolId===null || + !sourcesInitialized) return;` (`:742-759`) and only toggles when inconsistent. Forced tools are a + single-element set (`:299-307`). Enabling a source auto-pins search; disabling the last source + unpins it (`:365-396`); `previouslyEnabledSourcesRef` (a plain ref) restores sources when search is + re-enabled (`:790-810`). Mirror the `sourcesInitialized`-equivalent guard and keep the "only toggle + if different" idempotency (`setSearchToolEnabled`, `:762-770`). **Ship source→filter selection + first, add the coupling as an isolated, jest-tested step.** +- **`useDeepResearchToggle` null→new-session preservation.** Replicate the ref guard exactly: reset to + false only when `previousId !== null && previousId !== chatSessionId`; **always** reset on `agentId` + change. A naive `[chatSessionId]` reset would drop the flag on the null→new-session transition + mid-send. (`web/src/hooks/useDeepResearchToggle.ts:31-44`.) +- **`allowed_tool_ids`: send the computed enabled list, never a bare `[]`.** `computeAllowedToolIds` + returns `null` when nothing is disabled (backend treats `null` = allow all); sending `[]` would + disable everything. Match web's `enabledToolIds` semantics. +- **Default agent (id 0) sources.** `knowledge_sources` is empty for id 0; web treats it as "all + accessible" using the connector list. Mobile `useConnectorSources` (GET `/manage/connector-status`, + any chat-accessible user) supplies that list; non-default agents use `agent.knowledge_sources`, + falling back to all if empty but a search tool is present (`ActionsPopover/index.tsx:199-210`). + **Federated connectors** (`GET /federated`, EE) are deferred — a documented Tier-2 gap for + federated-only sources. +- **`disabled_tool_ids` is shared with web.** A tool disabled on mobile PATCHes the same per-user + per-agent record web reads — intentional cross-client sync. PATCH sends the **full** array + (backend field is required). Optimistic update then invalidate; guard rapid-toggle races. +- **PII / cache.** `connectorSources` and `agentPreferences` are not chat content; safe to persist in + the MMKV Query cache. Do **not** persist `selectedSources`/`forcedToolId`/`deepResearch` (ephemeral, + live in provider state, never in Query). +- **`internal_search_filters` minimal shape.** Send `{ source_type: [...] }` only; all other + `BaseFilters` fields default null on the backend (`search/models.py:101-121`). Values are the + snake_case `DocumentSource` strings. +- **`FILE_READER_TOOL_ID` and MCP tools are always excluded** from the actions list + (`displayableTools`); MCP/OAuth rows, the action search box, and the admin "More Actions" link are + out of scope. +- **Testing hooks.** `tools.ts`/`sources.ts` are pure → jest unit tests (`computeAllowedToolIds` null + vs list, `hasSearchToolsAvailable`, `displayableTools` filtering, `buildInternalSearchFilters`). + `useDeepResearchToggle` reset matrix and the source-coupling reducer are the other high-value unit + targets. Import leaf components directly in tests (reanimated barrels crash jest — see + `mobile/CLAUDE.md`). diff --git a/docs/mobile-chat/input-bar-controls/04-implementation-plan.md b/docs/mobile-chat/input-bar-controls/04-implementation-plan.md new file mode 100644 index 0000000000..399f63ea4b --- /dev/null +++ b/docs/mobile-chat/input-bar-controls/04-implementation-plan.md @@ -0,0 +1,213 @@ +> Status: active · Task: input-bar-controls + +# Mobile Input-Bar Controls (ActionsPopover + Deep-Research Toggle) — Implementation Plan + +## Issues to Address + +The mobile chat composer can type, attach files, and send — but it cannot control **which tools the +agent uses**, **force a specific tool**, choose **which knowledge sources to search**, or turn on +**deep research**. Web has all of this in its input-bar toolbar. This change ports that toolbar to +mobile at **Tier-2 (Standard)** scope, web-parity (Approach B): a **deep-research toggle** and an +**anchored ActionsPopover** (force-a-tool + enable/disable tools + a source-selection sub-view). +Outcome: the mobile send request carries `deep_research`, `forced_tool_id`, `allowed_tool_ids`, and +`internal_search_filters`, and per-agent `disabled_tool_ids` persists server-side (shared with web). +Out of scope: MCP servers/tools, OAuth re-auth, the admin "More Actions" link, and the action search +box. + +## Important Notes + +- **No backend work, no migration.** All four send fields already exist on `SendMessageRequest` + (`backend/onyx/server/query_and_chat/models.py:110,111,115,117`). Per-agent `disabled_tool_ids` + already has a table (`assistant__user_specific_config`, `backend/onyx/db/models.py:4058`) and + endpoints `GET /user/assistant/preferences` + `PATCH /user/assistant/{id}/preferences` + (`backend/onyx/server/manage/users.py:1283,1300`, both `BASIC_ACCESS`). +- **The tool + source catalog is already on the wire.** `GET /persona` serves + `MinimalPersonaSnapshot` with `tools: list[ToolSnapshot]` + `knowledge_sources: + list[DocumentSource]` (`backend/onyx/server/features/persona/models.py:202,212`). Mobile's + `useAgents()` already calls it; `MinimalAgent` (`mobile/src/chat/agents.ts:15`) just omits the two + fields. → widen the type, **no new fetch for tools**. +- **`deep_research` is hardcoded `false`** today (`mobile/src/hooks/useChatController.ts:296`); + `allowed_tool_ids`/`forced_tool_id`/`internal_search_filters` are missing from `SendMessageBody` + (`mobile/src/api/chat/stream.ts`). +- **Mobile has no popover/Switch/SelectButton primitive.** Build on the proven Portal + reanimated + stack (`mobile/src/components/sidebar/Sidebar.tsx`; `` at `mobile/src/app/_layout.tsx:71`). + `mobile/src/components/ui/line-item-button.tsx` already has a `rightChildren` trailing slot (from + `content.tsx`) — no change needed to host the enable/disable control + chevron. Mobile `Button` + drops hover (`ButtonInteraction = rest|active`), so the `SelectButton` matrix + `foldable` must be + **state/press-driven, not hover-driven**. +- **Web-parity gotchas to port exactly** (from `03-detailed-design.md`): the search-tool↔source + reconcile is gated by `sourcesInitialized` and only toggles when inconsistent + (`web/.../ActionsPopover/index.tsx:742-770`) — port the guard or it render-loops; + `useDeepResearchToggle` preserves the flag on the null→new-session transition via a ref + (`web/src/hooks/useDeepResearchToggle.ts:31-44`); `computeAllowedToolIds` returns `null` (not `[]`) + when nothing is disabled; forced tools are a single-element selection. +- **The anchored popover on a keyboard-adjacent bottom bar is the highest risk** and needs an + on-device dev build to verify (agent can't). Fallback: swap the `Popover` container for the + `FilePickerSheet` bottom-sheet shell — the `ActionsPopover` content is renderer-agnostic. +- **Default agent (id 0)** has empty `knowledge_sources`; source list comes from a new + `useConnectorSources` (GET `/manage/connector-status`, any chat-accessible user). Federated + connectors (EE `/federated`) deferred. +- **Icons:** 8 SVGs must be added to `mobile/src/icons/` — `hourglass`, `globe`, `cpu`, `link`, + `server`, `plug`, `unplug`, `slash` (exact web path data below). +- **Standards** (`mobile/CLAUDE.md`): reuse `Text`/`Icon`/`Button`/`LineItemButton`; NativeWind + semantic tokens only; spacing class number = px; TanStack Query keyed by `serverUrl`, PII keys + MMKV-excluded (sources/prefs are safe to persist; selection state stays in provider, not Query); + import leaf components in jest (reanimated barrels crash the runner). + +## Implementation Strategy + +Ordered so each group is a coherent, independently-mergeable change (Phase 5 bundles into PRs). + +1. **Send-body plumbing + type widening (foundation).** Widen `SendMessageBody` + (`mobile/src/api/chat/stream.ts`) with `allowed_tool_ids?`, `forced_tool_id?`, + `internal_search_filters?` + a new `InternalSearchFilters` type. Widen `MinimalAgent` + (`mobile/src/chat/agents.ts`) with `tools` + `knowledge_sources`. Add `deep_research_enabled?` to + `WorkspaceSettings` (`mobile/src/api/settings.ts`). Extend `useChatController.submit` + (`mobile/src/hooks/useChatController.ts:228`) with a `toolOptions?: ChatToolOptions` arg; replace + the hardcoded `deep_research: false` (`:296`) and populate the three new fields in the body + (`:291`). Add the pure `mobile/src/chat/tools.ts` (`ToolSnapshot`, tool-id constants, predicates, + `computeAllowedToolIds`, `getIconForToolId`) and `mobile/src/chat/sources.ts` (`DocumentSource`, + `SOURCE_META`, `buildInternalSearchFilters`). + +2. **Icons.** Add the 8 `react-native-svg` icons to `mobile/src/icons/` following the + `sliders.tsx`/`types.ts` shape (default export, `IconProps`, `stroke="currentColor"`), using the + verbatim web path data (see Appendix). `link` uses viewBox `0 0 17 9` + `rotate(315deg)`; `slash` + renders two ``s. + +3. **UI primitives.** Port via `port-web-component-to-mobile`: + - `mobile/src/components/ui/select-button.tsx` + `select-button.styles.ts` — `SELECT_COLORS` + stateful literal matrix (`select-light`, `empty`/`selected`, `rest`/`active`/`disabled`) + + `resolveSelectState`, mirroring `button.styles.ts`; `foldable` = conditional label render. + - `mobile/src/components/ui/switch.tsx` — reanimated track (32×18) + thumb (14×14), `checked` + bg `action-link-05`. + - `mobile/src/components/ui/popover.tsx` — anchored panel: `measureInWindow` the anchor, `` + render, upward positioning + on-screen clamp + `maxHeight`/scroll, `Keyboard.dismiss()` on open, + re-measure on keyboard/rotation, outside-tap close. + +4. **State layer.** `mobile/src/api/chat/agentPreferences.ts` (GET/PATCH) + + `mobile/src/api/chat/connectors.ts` (connector-status). Hooks: + `mobile/src/hooks/useAgentPreferences.ts` (TanStack Query GET + a `useMutation` PATCH using the + canonical optimistic pattern — `onMutate`: cancel + snapshot + `setQueryData`; `onError`: restore + snapshot; `onSettled`: invalidate — plus a short debounce for rapid toggles), + `useConnectorSources.ts`, `useDeepResearchToggle.ts` (port with the ref-guarded resets), + `useForcedTools.ts` (single-force, agent-reset), `useSourceSelection.ts` (ephemeral, self-init to + all). Add query keys (`mobile/src/api/query-keys.ts`). `mobile/src/state/ComposerToolsProvider.tsx` + aggregates them + exposes `resolveToolOptions()`. + +5. **Deep-research control (first visible slice).** `mobile/src/components/chat/ToolbarControls.tsx` + renders the deep-research `SelectButton` (gated by `deep_research_enabled && + hasSearchToolsAvailable`). Wire `ComposerToolsProvider` into `ChatSurface` + (`mobile/src/components/chat/ChatSurface.tsx`) and render `ToolbarControls` in `InputBar`'s left + cluster (`mobile/src/components/chat/InputBar.tsx:119-127`); thread `resolveToolOptions()` into + `sendWithAttachments` → `submit`. Deep research works end-to-end at this step. + +6. **ActionsPopover — tools list + force + enable/disable.** + `mobile/src/components/chat/ActionsPopover.tsx` (composes `Popover`, owns `open`/`subView`) + + `ActionLineItem.tsx` (tap=force with the disable-clears-force guard; trailing `Switch` = + enable/disable; chevron for search drill-in). Add the Actions trigger + forced-tool pills to + `ToolbarControls`. Wire `allowed_tool_ids`/`forced_tool_id` through the provider. + +7. **Source sub-view + coupling.** `mobile/src/components/chat/SourceSwitchList.tsx` + + `SourceIcon.tsx`; in-place body swap in `ActionsPopover`. First wire plain source→filter selection + (`internal_search_filters`); then add the search-tool↔source coupling as an isolated step, porting + the `sourcesInitialized` guard + single-force + `previouslyEnabledSourcesRef` idempotency exactly. + +## Tests + +**Primary type: External-Dependency Unit / jest unit** (mobile uses `jest-expo`; no Onyx-container +E2E for mobile). Concentrate on the pure logic and the two loop-prone reducers — the anchored-popover +rendering is verified on-device, not in jest. + +- **Pure logic (`mobile/src/chat/__tests__/`):** `computeAllowedToolIds` (null when nothing disabled, + list when some disabled, never `[]`), `hasSearchToolsAvailable`, `displayableTools` (drops MCP / + non-`chat_selectable` / FileReader), `buildInternalSearchFilters` (`{source_type}` vs `null`). +- **`useDeepResearchToggle`:** the reset matrix — preserves on null→new-session, resets on real + session switch, always resets on agent change, toggles correctly. +- **Source-coupling reducer:** enabling a source pins search, disabling the last source unpins it, + enable-all/disable-all, and **no oscillation** once `initialized` (the guard). +- **`useAgentPreferences`:** optimistic set then PATCH then invalidate; sends the full `disabled_tool_ids` + array; failure rolls back. +- **`ToolbarControls` gating:** deep-research pill hidden when `deep_research_enabled` false or no + search tool; Actions trigger hidden when the agent has no displayable tools. +- **On-device gate (owner-run, not automated):** anchored popover opens upward without keyboard + collision / off-screen clipping across iOS + Android after `expo prebuild`; the fallback swap to the + bottom-sheet shell if unstable. + +## Appendix — exact SVG path data for the 8 new icons + +viewBox `0 0 16 16`, `stroke="currentColor"`, `strokeWidth={1.5}`, `fill="none"`, unless noted. + +- **hourglass:** `M8 8L4.44793 5.72667C4.06499 5.48159 3.83333 5.05828 3.83333 4.60364V1.83333H12.1667V4.60364C12.1667 5.05828 11.935 5.48159 11.5521 5.72667L8 8ZM8 8L11.5521 10.2733C11.935 10.5184 12.1667 10.9417 12.1667 11.3963V14.1667H3.83333V11.3963C3.83333 10.9417 4.06499 10.5184 4.44793 10.2733L8 8ZM13.5 14.1667H2.5M13.5 1.83333H2.5` +- **globe:** `M14.6667 8C14.6667 11.6819 11.6819 14.6667 8 14.6667M14.6667 8C14.6667 4.3181 11.6819 1.33333 8 1.33333M14.6667 8H1.33334M8 14.6667C4.31811 14.6667 1.33334 11.6819 1.33334 8M8 14.6667C9.66753 12.8411 10.6152 10.472 10.6667 8C10.6152 5.52802 9.66753 3.1589 8 1.33333M8 14.6667C6.33249 12.8411 5.38484 10.472 5.33334 8C5.38484 5.52802 6.33249 3.1589 8 1.33333M1.33334 8C1.33334 4.3181 4.31811 1.33333 8 1.33333` +- **cpu:** `M6.09091 1V2.90909M9.90909 1V2.90909M6.09091 13.0909V15M9.90909 13.0909V15M13.0909 6.09091H15M13.0909 9.27273H15M1 6.09091H2.90909M1 9.27273H2.90909M4.18182 2.90909H11.8182C12.5211 2.90909 13.0909 3.47891 13.0909 4.18182V11.8182C13.0909 12.5211 12.5211 13.0909 11.8182 13.0909H4.18182C3.47891 13.0909 2.90909 12.5211 2.90909 11.8182V4.18182C2.90909 3.47891 3.47891 2.90909 4.18182 2.90909ZM6 6H10V10H6V6Z` +- **link** (viewBox `0 0 17 9`, `transform={[{ rotate: "315deg" }]}` on ``): `M10.0833 0.75H12.0833C12.5211 0.75 12.9545 0.836219 13.3589 1.00373C13.7634 1.17125 14.1308 1.41678 14.4404 1.72631C14.7499 2.03584 14.9954 2.4033 15.1629 2.80772C15.3304 3.21214 15.4167 3.64559 15.4167 4.08333C15.4167 4.52107 15.3304 4.95453 15.1629 5.35894C14.9954 5.76336 14.7499 6.13083 14.4404 6.44036C14.1308 6.74988 13.7634 6.99542 13.3589 7.16293C12.9545 7.33045 12.5211 7.41667 12.0833 7.41667H10.0833M6.08333 7.41667H4.08333C3.64559 7.41667 3.21214 7.33045 2.80772 7.16293C2.4033 6.99542 2.03584 6.74988 1.72631 6.44036C1.10119 5.81523 0.75 4.96739 0.75 4.08333C0.75 3.19928 1.10119 2.35143 1.72631 1.72631C2.35143 1.10119 3.19928 0.75 4.08333 0.75H6.08333M5.41667 4.08333H10.75` +- **server:** `M4 4H4.00666M4 12H4.00666M2.66666 1.33334H13.3333C14.0697 1.33334 14.6667 1.9303 14.6667 2.66668V5.33334C14.6667 6.06972 14.0697 6.66668 13.3333 6.66668H2.66666C1.93028 6.66668 1.33333 6.06972 1.33333 5.33334V2.66668C1.33333 1.9303 1.93028 1.33334 2.66666 1.33334ZM2.66666 9.33334H13.3333C14.0697 9.33334 14.6667 9.9303 14.6667 10.6667V13.3333C14.6667 14.0697 14.0697 14.6667 13.3333 14.6667H2.66666C1.93028 14.6667 1.33333 14.0697 1.33333 13.3333V10.6667C1.33333 9.9303 1.93028 9.33334 2.66666 9.33334Z` +- **plug:** `M12 10.5H15M12 10.5V12.5M12 10.5V5.5M12 3.5H8.5C6.01472 3.5 4 5.51472 4 8M12 3.5V5.5M12 3.5V2M12 12.5H8.5C6.01472 12.5 4 10.4853 4 8M12 12.5V14M4 8H1M12 5.5H15` +- **unplug:** `M1 1L5.0778 5.0778M15 15L12 12M15 10.5H14M12 12.5H8.5C6.01472 12.5 4 10.4853 4 8M12 12.5V14M12 12.5V12M12 3.5H8.5C8.04537 3.5 7.60649 3.56742 7.1928 3.6928M12 3.5V5.5M12 3.5V2M12 5.5H15M12 5.5V8.5M4 8H1M4 8C4 6.88463 4.40579 5.86403 5.0778 5.0778M5.0778 5.0778L12 12` +- **slash** (two ``s): `M14.6667 8C14.6667 11.6819 11.6819 14.6667 7.99999 14.6667C4.3181 14.6667 1.33333 11.6819 1.33333 8C1.33333 4.3181 4.3181 1.33333 7.99999 1.33333C11.6819 1.33333 14.6667 4.3181 14.6667 8Z` and `M3.5 3.5L12.5 12.5` + +--- + +## Plan Challenge Results + +Run 2026-07-16. Web-verification searches executed for checks 3 & 4. + +### 1. Extendability & Scalability: PASS (minor note) +Tool/source lists are small and bounded (a handful of tools per agent; sources bounded by +connectors) — no scale concern. The three ported primitives (`Popover`/`Switch`/`SelectButton`) are +reusable infra for every future menu. **Note:** adding a *new* toolbar control later (MCP quick-toggle, +voice, tab-reading) touches `ToolbarControls.tsx` + `ActionsPopover.tsx` directly — Approach B has no +data-driven control registry (that was Approach C's seam, consciously not chosen). Acceptable: MCP is +an explicitly-scoped future PR, and the send-body is already extended through one `ChatToolOptions` +object, so new fields extend one struct. + +### 2. Fragility: CONCERN (identified + mitigated) +Three brittle points, all called out with mitigations: **(a)** the search-tool↔source coupling can +render-loop — mitigated by porting the `sourcesInitialized` guard + "only toggle if inconsistent" +idempotency and shipping plain source-selection *before* the coupling as an isolated, tested step; +**(b)** the anchored popover's keyboard/off-screen math — mitigated by the on-device gate + the +bottom-sheet-shell fallback (content is renderer-agnostic); **(c)** the `useDeepResearchToggle` +null→new-session preservation — mitigated by porting the ref guard verbatim + a unit test on the reset +matrix. No hidden single points of failure; the feature degrades gracefully (controls hidden when +their gates fail; all four send fields optional). + +### 3. Industry Standard: VERIFIED +Searched RN anchored-popover + keyboard practices and the TanStack optimistic-update pattern. +Confirmed standard: an anchored popover **measures the trigger → renders through a Portal near root → +auto-positions to stay in the viewport → is keyboard-aware** (exactly the `popover.tsx` design). And +the per-agent-prefs write uses the **canonical `onMutate`(cancel+snapshot+`setQueryData`) / +`onError`(rollback) / `onSettled`(invalidate) + debounce** optimistic pattern. The one divergence — +hand-rolling the popover instead of adopting `react-native-popover-view` — is justified: we need +pixel-parity with Opal/Radix and NativeWind-token styling, and mobile already hand-rolls its overlays +(`Sidebar`, `FilePickerSheet`); a pre-styled third-party popover would fight the token system. +Sources: [RN popover components 2026](https://dev.to/eira-wexford/top-5-react-native-popover-components-for-developers-2026-1ge4), +[react-native-popover-view](https://www.npmjs.com/package/react-native-popover-view), +[Margelo keyboard deep-dive](https://blog.margelo.com/deep-dive-in-keyboard-handling), +[TanStack optimistic updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates). + +### 4. Fact Check: PASS +Every factual claim is code-verified with file:line: backend accepts all four send fields +(`models.py:110,111,115,117`); the prefs table + endpoints exist (`db/models.py:4058`, +`manage/users.py:1283,1300`); `/persona` already serves `tools`+`knowledge_sources` +(`persona/models.py:202,212`); `deep_research_enabled` exists (`settings/models.py:50`); mobile +`MinimalAgent` omits both fields (`mobile/src/chat/agents.ts:15`); `deep_research` is hardcoded false +(`useChatController.ts:296`). The two "industry-standard" claims are now web-verified (check 3). No +unverified claims remain. + +### 5. Maintainability: PASS +Follows existing mobile conventions exactly — primitives in `components/ui/`, chat components in +`components/chat/`, pure logic in `chat/`, hooks in `hooks/`, TanStack Query keyed by `serverUrl`, +`port-web-component-to-mobile` for the ports. Each of the ~18 new files is small and +single-responsibility. 18 files is a large surface, but it's split across 5 review-sized PRs and each +file's web counterpart is cited, so a newcomer can follow the mapping in <15 min. + +### 6. Patch vs. Fix: PROPER FIX +This is a net-new capability, not a symptom workaround. It fixes the root cause ("mobile can't control +tools/sources/deep-research") by adding the real controls and wiring the **real** backend fields — +`deep_research` moves from a hardcoded `false` to the actual toggle value; `allowed_tool_ids`/ +`forced_tool_id`/`internal_search_filters` are populated from real UI state; `disabled_tool_ids` +persists to the same server record web uses. No errors suppressed, no timeouts bumped, no fake loading +states. **No user decision required.** + +**Verdict: all six checks clear (one identified-and-mitigated fragility concern, no blocking +failures, no patch). Plan is execution-ready.** diff --git a/docs/mobile-chat/input-bar-controls/05-pr-roadmap.md b/docs/mobile-chat/input-bar-controls/05-pr-roadmap.md new file mode 100644 index 0000000000..b7ad1823e8 --- /dev/null +++ b/docs/mobile-chat/input-bar-controls/05-pr-roadmap.md @@ -0,0 +1,191 @@ +> Status: active · Task: input-bar-controls · Source plan: 04-implementation-plan.md + +# Mobile Input-Bar Controls (ActionsPopover + Deep-Research Toggle) — PR Roadmap + +Approach **B — Web-Parity-First**. Four review-sized PRs, ordered so the **deep-research toggle works +end-to-end at PR 2** (the walking skeleton) and the tools menu + sources layer on after. Each PR +leaves `main` releasable; new controls are only visible when their gates pass, so partial merges are +inert without a feature flag. + +> **PR 1 merges the former "plumbing" and "primitives" PRs** into one foundation (owner decision at +> GATE 3, 2026-07-16). It is a no-runtime-surface PR (types, pure logic, primitives, icons — nothing +> wired into chat), so it stays coherent, but it lands **above** the ~500-700 band (~950-1,150 LOC). + +## Overview + +| PR | Title | Est. LOC | Depends on | Key deliverable | +|----|-------|----------|------------|-----------------| +| 1 | `feat(mobile): tool-options foundation — send-body, contracts, primitives, icons` | ~950-1,150 | — | Send-body/type widening + pure `tools.ts`/`sources.ts` + `SelectButton`/`Switch` + 8 icons. No chat wiring. | +| 2 | `feat(mobile): deep-research toggle` | ~450-550 | PR 1 | **Walking skeleton** — deep research works end-to-end via the input-bar pill. | +| 3 | `feat(mobile): actions popover — tools, force + enable/disable` | ~650-750 | PR 2 | Anchored `Popover` + `ActionsPopover` tool list; `forced_tool_id` + server-persisted `allowed_tool_ids`. | +| 4 | `feat(mobile): actions popover — source selection + search coupling` | ~550-650 | PR 3 | Source sub-view + search↔source coupling; `internal_search_filters`. | + +## Sequence + +``` +PR1 foundation (send-body + pure contracts + SelectButton/Switch + icons) + │ + ▼ +PR2 deep-research toggle (walking skeleton) ─► PR3 ActionsPopover (tools) ─► PR4 sources + coupling +``` + +PR 1 is the shared foundation (no runtime surface). PR 2 is the thin end-to-end slice that proves the +composer→provider→submit shape. PR 3 introduces the anchored popover (the on-device gate) with its +first real consumer. PR 4 completes Tier-2. + +--- + +## PR 1 — `feat(mobile): tool-options foundation — send-body, contracts, primitives, icons` + +- **Goal:** Land the entire no-runtime-surface foundation — wire types, pure logic, reusable + primitives, and icons — so PR 2-4 only add state + wiring. Nothing is visible in the app yet. +- **Scope (in):** + - **Send-body + types:** widen `SendMessageBody` with `allowed_tool_ids?`/`forced_tool_id?`/ + `internal_search_filters?` + the `InternalSearchFilters` type; widen `MinimalAgent` with + `tools`/`knowledge_sources`; add `deep_research_enabled?` to `WorkspaceSettings`. + - **Submit threading:** `submit(text, files?, onAccepted?, toolOptions?)`; replace hardcoded + `deep_research: false` and populate the three new body fields (no-op when `toolOptions` absent). + - **Pure modules:** `chat/tools.ts` (`ToolSnapshot`, tool-id constants, predicates, + `computeAllowedToolIds`, `getIconForToolId`) + `chat/sources.ts` (`DocumentSource`, `SOURCE_META`, + `buildInternalSearchFilters`). + - **Primitives:** `select-button.tsx` + `select-button.styles.ts` (`SELECT_COLORS` literal matrix + + `resolveSelectState`, `select-light`, `foldable` via conditional label render — no hover); + `switch.tsx` (reanimated track/thumb). + - **Icons:** the 8 SVGs (`hourglass`, `globe`, `cpu`, `link`, `server`, `plug`, `unplug`, `slash`) + with the verbatim path data from `04`'s appendix. +- **Out of scope:** the anchored `Popover` (PR 3, with its consumer); any hooks, provider, or chat + wiring; any network calls beyond the existing send path. +- **Files:** + | File | New/Modified | This PR's slice | + |------|--------------|-----------------| + | `mobile/src/api/chat/stream.ts` | modified | send-body fields + `InternalSearchFilters` | + | `mobile/src/chat/agents.ts` | modified | widen `MinimalAgent` | + | `mobile/src/api/settings.ts` | modified | `deep_research_enabled` | + | `mobile/src/hooks/useChatController.ts` | modified | `submit` `toolOptions` arg + body build | + | `mobile/src/chat/tools.ts` | new | tool contract + predicates | + | `mobile/src/chat/sources.ts` | new | source contract + filter builder | + | `mobile/src/components/ui/select-button.tsx` + `.styles.ts` | new | pill primitive | + | `mobile/src/components/ui/switch.tsx` | new | toggle primitive | + | `mobile/src/icons/{hourglass,globe,cpu,link,server,plug,unplug,slash}.tsx` | new | 8 icons | + | `mobile/src/chat/__tests__/tools.test.ts` | new | pure-logic tests | + | `mobile/src/components/ui/__tests__/select-button.test.tsx` | new | state/matrix render | +- **Est. size:** ~950-1,150 LOC. **Above the band by owner choice (merged plumbing + primitives).** + It stays coherent because nothing here is wired into the running app — it's all foundation. If it + feels too large in review, the natural re-split is back into "plumbing/contracts" vs + "primitives/icons". +- **Depends on:** — +- **Feature-flag state:** N/A — additive/optional fields; `deep_research` still effectively `false` + (no UI sets it); primitives unused. Backwards compatible. +- **Tests on merge:** jest unit — `computeAllowedToolIds` (null vs list, never `[]`), + `hasSearchToolsAvailable`, `displayableTools` filtering, `buildInternalSearchFilters`; a controller + test asserting `submit` threads `toolOptions`; `SelectButton` empty/selected/disabled cell + resolution; `Switch` toggle; icons render at default size. +- **Drift checkpoint:** re-confirm the backend `SendMessageRequest` field names/types + (`models.py:110-117`), that `/persona` still serves `tools`+`knowledge_sources`, and that + `button.styles.ts`'s matrix shape (mirrored by `SELECT_COLORS`) is unchanged. Use + `port-web-component-to-mobile` for `SelectButton`/`Switch`. + +## PR 2 — `feat(mobile): deep-research toggle` (walking skeleton) + +- **Goal:** Prove the composer→provider→submit path end-to-end with the simplest control. +- **Scope (in):** + - `useDeepResearchToggle` (port with the ref-guarded null→new-session preservation + agent reset). + - `ComposerToolsProvider` (deep-research only for now; `resolveToolOptions()` returns + `deep_research`, the rest null). + - `ToolbarControls` rendering just the deep-research `SelectButton` (gated by + `deep_research_enabled && hasSearchToolsAvailable`). + - Wire `ComposerToolsProvider` into `ChatSurface`; render `ToolbarControls` in `InputBar`'s left + cluster; thread `resolveToolOptions()` into `sendWithAttachments`→`submit`. +- **Out of scope:** the Actions menu, popover, tools/sources state. +- **Files:** + | File | New/Modified | This PR's slice | + |------|--------------|-----------------| + | `mobile/src/hooks/useDeepResearchToggle.ts` | new | ephemeral toggle + resets | + | `mobile/src/state/ComposerToolsProvider.tsx` | new | provider (deep-research slice) | + | `mobile/src/components/chat/ToolbarControls.tsx` | new | deep-research pill only | + | `mobile/src/components/chat/InputBar.tsx` | modified | render `ToolbarControls` | + | `mobile/src/components/chat/ChatSurface.tsx` | modified | provider + thread options | + | `mobile/src/hooks/__tests__/useDeepResearchToggle.test.ts` | new | reset matrix | +- **Est. size:** ~450-550 LOC. +- **Depends on:** PR 1 (submit/plumbing, `SelectButton`, hourglass icon). +- **Feature-flag state:** N/A — gated by the `deep_research_enabled` admin setting; invisible when off. +- **Tests on merge:** jest — the reset matrix (preserve on null→new-session, reset on real switch + + agent change); `ToolbarControls` gating (pill hidden without the setting or a search tool). Manual: + toggle on → send → request carries `deep_research: true`. +- **Drift checkpoint:** confirm the `ChatSurface`/`InputBar` seams from PR 1 are unchanged and that + `sessionId`/`agentId` are available where the provider mounts. + +## PR 3 — `feat(mobile): actions popover — tools, force + enable/disable` + +- **Goal:** The anchored Actions menu with tool forcing + server-persisted enable/disable. +- **Scope (in):** + - `popover.tsx` (anchored: `measureInWindow`, Portal, upward + clamp + `maxHeight`/scroll, + `Keyboard.dismiss()` on open, re-measure on keyboard/rotation). + - `ActionsPopover.tsx` (composes `Popover`, owns `open`/`subView`) + `ActionLineItem.tsx` (tap=force + with disable-clears-force guard; trailing `Switch` enable/disable; search-drill-in chevron). + - `useForcedTools` + `useAgentPreferences` (+ `api/chat/agentPreferences.ts`, optimistic + `onMutate`/`onError`/`onSettled` + debounce). + - Extend `ComposerToolsProvider` + `ToolbarControls` (Actions trigger + forced-tool pills); wire + `forced_tool_id` + `allowed_tool_ids`. +- **Out of scope:** the source sub-view + coupling (PR 4); MCP/OAuth rows; action search box. +- **Files:** + | File | New/Modified | This PR's slice | + |------|--------------|-----------------| + | `mobile/src/components/ui/popover.tsx` | new | anchored popover primitive | + | `mobile/src/components/chat/ActionsPopover.tsx` | new | menu shell + primary list | + | `mobile/src/components/chat/ActionLineItem.tsx` | new | tool row (force + enable/disable) | + | `mobile/src/hooks/useForcedTools.ts` | new | single-force state | + | `mobile/src/hooks/useAgentPreferences.ts` | new | GET + optimistic PATCH | + | `mobile/src/api/chat/agentPreferences.ts` | new | prefs API wrappers | + | `mobile/src/api/query-keys.ts` | modified | `agentPreferences` key | + | `mobile/src/state/ComposerToolsProvider.tsx` | modified | force + disabled slices | + | `mobile/src/components/chat/ToolbarControls.tsx` | modified | Actions trigger + forced pills | +- **Est. size:** ~650-750 LOC. Near the top of the band and **can't split further** — the anchored + `Popover` has no other consumer, so building it apart from `ActionsPopover` would leave an + untestable PR; force + enable/disable share the same row and provider. +- **Depends on:** PR 2. +- **Feature-flag state:** N/A — the Actions trigger only renders when the agent has displayable tools. +- **Tests on merge:** jest — `useForcedTools` single-force + agent reset; `useAgentPreferences` + optimistic set → PATCH full array → invalidate → rollback on error; `ActionLineItem` force + + disable-clears-force. **On-device gate (owner-run):** anchored popover opens upward without keyboard + collision / off-screen clipping on iOS + Android after `expo prebuild`; fallback = swap the popover + container for the `FilePickerSheet` bottom-sheet shell. +- **Drift checkpoint:** confirm the prefs endpoints (`GET /user/assistant/preferences`, + `PATCH /user/assistant/{id}/preferences`) are unchanged; decide the final no-hover enable/disable + affordance (default: trailing `Switch`) before coding the row. + +## PR 4 — `feat(mobile): actions popover — source selection + search coupling` + +- **Goal:** Complete Tier-2 with the source sub-view and the web-faithful search↔source coupling. +- **Scope (in):** + - `SourceSwitchList.tsx` (back + Enable-All/Disable-All + `Switch` rows) + `SourceIcon.tsx`; + in-place body swap in `ActionsPopover`. + - `useConnectorSources` (+ `api/chat/connectors.ts`, GET `/manage/connector-status`) + + `useSourceSelection` (ephemeral, self-init to all). + - Wire `internal_search_filters`; then add the search-tool↔source coupling as an isolated commit + (port the `sourcesInitialized` guard + single-force + `previouslyEnabledSourcesRef` idempotency). + - Populate `SOURCE_META`; default-agent (id 0) uses the connector list. +- **Out of scope:** federated connectors (EE `/federated`) — documented gap. +- **Files:** + | File | New/Modified | This PR's slice | + |------|--------------|-----------------| + | `mobile/src/components/chat/SourceSwitchList.tsx` | new | source sub-view | + | `mobile/src/components/chat/SourceIcon.tsx` | new | source glyph | + | `mobile/src/hooks/useConnectorSources.ts` | new | connector-status query | + | `mobile/src/api/chat/connectors.ts` | new | connector-status wrapper | + | `mobile/src/hooks/useSourceSelection.ts` | new | selection + coupling | + | `mobile/src/api/query-keys.ts` | modified | `connectorSources` key | + | `mobile/src/chat/sources.ts` | modified | fill `SOURCE_META` | + | `mobile/src/components/chat/ActionsPopover.tsx` | modified | mount sub-view + chevron | + | `mobile/src/state/ComposerToolsProvider.tsx` | modified | source slice + filters | + | `mobile/src/hooks/__tests__/useSourceSelection.test.ts` | new | coupling reducer | +- **Est. size:** ~550-650 LOC. +- **Depends on:** PR 3. +- **Feature-flag state:** N/A. +- **Tests on merge:** jest — `buildInternalSearchFilters` (`{source_type}` vs null); the coupling + reducer (enable source→pin search, disable-last→unpin, enable-all/disable-all, **no oscillation** + once initialized). Manual: pick sources → send → request carries + `internal_search_filters.source_type`. +- **Drift checkpoint:** confirm `/manage/connector-status` is reachable by a chat-accessible user and + its `BasicCCPairInfo{source}` shape; re-read the web coupling logic + (`ActionsPopover/index.tsx:742-810`) immediately before porting it, since it is the loop-prone part. diff --git a/mobile/src/api/chat/stream.ts b/mobile/src/api/chat/stream.ts index af9c9f9b01..aa2062a933 100644 --- a/mobile/src/api/chat/stream.ts +++ b/mobile/src/api/chat/stream.ts @@ -6,6 +6,7 @@ import { getBaseUrl } from "@/api/config"; import { getToken } from "@/api/auth/tokenStore"; import { createNdjsonBuffer } from "@/chat/ndjson"; import { FileDescriptor } from "@/chat/interfaces"; +import { InternalSearchFilters } from "@/chat/sources"; import { MessageResponseIDInfo, Packet, @@ -22,6 +23,18 @@ export interface SendMessageBody { file_descriptors: FileDescriptor[]; deep_research: boolean; origin: string; + // Toolbar-driven; omitted/null = backend defaults (allow all tools, force none, no source filter). + allowed_tool_ids?: number[] | null; + forced_tool_id?: number | null; + internal_search_filters?: InternalSearchFilters | null; +} + +// The toolbar-resolved send options threaded through submit(); mapped onto SendMessageBody. +export interface ChatToolOptions { + deepResearch: boolean; + allowedToolIds: number[] | null; + forcedToolId: number | null; + internalSearchFilters: InternalSearchFilters | null; } // status lets the resume caller stay silent on the expected "nothing to resume" (404). diff --git a/mobile/src/api/settings.ts b/mobile/src/api/settings.ts index c464c88217..b4242f57c0 100644 --- a/mobile/src/api/settings.ts +++ b/mobile/src/api/settings.ts @@ -9,11 +9,14 @@ export interface WorkspaceSettings { disable_default_assistant: boolean; // admin hard cap for uploads (MB); null = no cap user_file_max_upload_size_mb: number | null; + // admin gate for the deep-research toggle; absent → treated as enabled (matches web's `?? true`). + deep_research_enabled: boolean; } interface WorkspaceSettingsResponse { disable_default_assistant?: boolean | null; user_file_max_upload_size_mb?: number | null; + deep_research_enabled?: boolean | null; } export function useWorkspaceSettings() { @@ -30,6 +33,7 @@ export function useWorkspaceSettings() { disable_default_assistant: query.data?.disable_default_assistant ?? false, user_file_max_upload_size_mb: query.data?.user_file_max_upload_size_mb ?? null, + deep_research_enabled: query.data?.deep_research_enabled ?? true, }; return { ...query, settings }; diff --git a/mobile/src/chat/__tests__/agents.test.ts b/mobile/src/chat/__tests__/agents.test.ts index 268660debd..df560930d4 100644 --- a/mobile/src/chat/__tests__/agents.test.ts +++ b/mobile/src/chat/__tests__/agents.test.ts @@ -25,6 +25,8 @@ function agent( builtin_persona: false, display_priority: null, labels: [], + tools: [], + knowledge_sources: [], ...overrides, }; } diff --git a/mobile/src/chat/__tests__/tools.test.ts b/mobile/src/chat/__tests__/tools.test.ts new file mode 100644 index 0000000000..00308ba60a --- /dev/null +++ b/mobile/src/chat/__tests__/tools.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@jest/globals"; + +import { buildInternalSearchFilters } from "@/chat/sources"; +import { + computeAllowedToolIds, + displayableTools, + FILE_READER_TOOL_ID, + getIconForToolId, + hasSearchToolsAvailable, + KNOWLEDGE_GRAPH_TOOL_ID, + SEARCH_TOOL_ID, + WEB_SEARCH_TOOL_ID, + type ToolSnapshot, +} from "@/chat/tools"; +import SvgCpu from "@/icons/cpu"; +import SvgSearch from "@/icons/search"; +import SvgServer from "@/icons/server"; + +function tool(over: Partial & { id: number }): ToolSnapshot { + return { + id: over.id, + name: over.name ?? `tool-${over.id}`, + display_name: over.display_name ?? `Tool ${over.id}`, + description: over.description ?? "", + in_code_tool_id: over.in_code_tool_id ?? null, + mcp_server_id: over.mcp_server_id ?? null, + chat_selectable: over.chat_selectable ?? true, + }; +} + +describe("computeAllowedToolIds", () => { + const tools = [tool({ id: 1 }), tool({ id: 2 }), tool({ id: 3 })]; + + it("returns null when nothing is disabled (backend allows all)", () => { + expect(computeAllowedToolIds(tools, [])).toBeNull(); + }); + + it("returns the enabled ids when some tools are disabled", () => { + expect(computeAllowedToolIds(tools, [2])).toEqual([1, 3]); + }); + + it("returns [] when the user explicitly disables every tool", () => { + expect(computeAllowedToolIds(tools, [1, 2, 3])).toEqual([]); + }); +}); + +describe("hasSearchToolsAvailable", () => { + it("is true when a search or web-search tool is present", () => { + expect( + hasSearchToolsAvailable([ + tool({ id: 1, in_code_tool_id: SEARCH_TOOL_ID }), + ]), + ).toBe(true); + expect( + hasSearchToolsAvailable([ + tool({ id: 1, in_code_tool_id: WEB_SEARCH_TOOL_ID }), + ]), + ).toBe(true); + }); + + it("is false when the agent has no search tools", () => { + expect( + hasSearchToolsAvailable([tool({ id: 1, in_code_tool_id: "PythonTool" })]), + ).toBe(false); + expect(hasSearchToolsAvailable([])).toBe(false); + }); +}); + +describe("displayableTools", () => { + it("drops MCP tools, non-selectable tools, and the File Reader", () => { + const list = [ + tool({ id: 1, in_code_tool_id: SEARCH_TOOL_ID }), + tool({ id: 2, mcp_server_id: 9 }), + tool({ id: 3, chat_selectable: false }), + tool({ id: 4, in_code_tool_id: FILE_READER_TOOL_ID }), + ]; + expect(displayableTools(list).map((t) => t.id)).toEqual([1]); + }); +}); + +describe("getIconForToolId", () => { + it("maps known in-code tools to their web-parity icons", () => { + expect(getIconForToolId(SEARCH_TOOL_ID)).toBe(SvgSearch); + expect(getIconForToolId(KNOWLEDGE_GRAPH_TOOL_ID)).toBe(SvgServer); + }); + + it("falls back to the cpu glyph for custom/unknown tools (matches web)", () => { + expect(getIconForToolId(null)).toBe(SvgCpu); + expect(getIconForToolId("SomeCustomTool")).toBe(SvgCpu); + }); +}); + +describe("buildInternalSearchFilters", () => { + it("is null when no sources are selected", () => { + expect(buildInternalSearchFilters([])).toBeNull(); + }); + + it("wraps the selected sources in source_type", () => { + expect(buildInternalSearchFilters(["web", "confluence"])).toEqual({ + source_type: ["web", "confluence"], + }); + }); +}); diff --git a/mobile/src/chat/agents.ts b/mobile/src/chat/agents.ts index 54009f234f..e1f6ebe47d 100644 --- a/mobile/src/chat/agents.ts +++ b/mobile/src/chat/agents.ts @@ -1,3 +1,6 @@ +import type { DocumentSource } from "@/chat/sources"; +import type { ToolSnapshot } from "@/chat/tools"; + export const DEFAULT_AGENT_ID = 0; // Backend StarterMessage is exactly {name, message} — no description. @@ -12,6 +15,8 @@ export interface AgentLabel { } // Selection subset of MinimalPersonaSnapshot; rich fields added when a slice needs them. +// `tools` + `knowledge_sources` already ride the /persona wire (persona/models.py:202,212); +// the input-bar controls consume them. export interface MinimalAgent { id: number; name: string; @@ -25,6 +30,8 @@ export interface MinimalAgent { builtin_persona: boolean; display_priority: number | null; labels: AgentLabel[]; + tools: ToolSnapshot[]; + knowledge_sources: DocumentSource[]; } // Explicit `pinned_assistants` (in order, unknown ids dropped), or — when null/undefined — diff --git a/mobile/src/chat/sources.ts b/mobile/src/chat/sources.ts new file mode 100644 index 0000000000..88f4d31a5c --- /dev/null +++ b/mobile/src/chat/sources.ts @@ -0,0 +1,47 @@ +// Pure source contract + the internal_search_filters builder. Mirrors backend DocumentSource +// (snake_case wire values) and BaseFilters (backend/onyx/context/search/models.py) — at Tier-2 the +// only field the source sub-view sets is `source_type`. No React — jest-unit-testable. +import type { IconFunctionComponent } from "@/icons/types"; +import SvgGlobe from "@/icons/globe"; + +// The snake_case wire value, e.g. "web", "google_drive", "confluence". +export type DocumentSource = string; + +// Tier-2 subset of BaseFilters; all other fields default null on the backend, so we omit them. +export interface InternalSearchFilters { + source_type: DocumentSource[] | null; +} + +export interface SourceMeta { + icon: IconFunctionComponent; + displayName: string; +} + +// "google_drive" → "Google Drive": the label for a source not found in SOURCE_META. +function humanizeSource(source: DocumentSource): string { + return source + .split("_") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +// Per-connector logos + labels; a source absent here falls back to a generic icon + humanized name. +export const SOURCE_META: Partial> = {}; + +export function getSourceMeta(source: DocumentSource): SourceMeta { + return ( + SOURCE_META[source] ?? { + icon: SvgGlobe, + displayName: humanizeSource(source), + } + ); +} + +// null when nothing is selected → the backend applies no source filter. +export function buildInternalSearchFilters( + selectedSources: DocumentSource[], +): InternalSearchFilters | null { + if (selectedSources.length === 0) return null; + return { source_type: selectedSources }; +} diff --git a/mobile/src/chat/tools.ts b/mobile/src/chat/tools.ts new file mode 100644 index 0000000000..20aa30bbc0 --- /dev/null +++ b/mobile/src/chat/tools.ts @@ -0,0 +1,90 @@ +// Pure tool contract + selection logic. Mirrors the Tier-2 subset of web's ToolSnapshot +// (web/src/lib/tools/interfaces.ts) and the in-code tool ids in +// web/src/app/app/components/tools/constants.ts. No React — jest-unit-testable. +import type { IconFunctionComponent } from "@/icons/types"; +import SvgSearch from "@/icons/search"; +import SvgGlobe from "@/icons/globe"; +import SvgImage from "@/icons/image"; +import SvgTerminalSmall from "@/icons/terminal-small"; +import SvgLink from "@/icons/link"; +import SvgCpu from "@/icons/cpu"; +import SvgServer from "@/icons/server"; + +export interface ToolSnapshot { + id: number; + name: string; + display_name: string; + description: string; + // Matches an in-code id below; null for custom (OpenAPI) tools. + in_code_tool_id: string | null; + // Set → this is an MCP tool, excluded from the Tier-2 actions list. + mcp_server_id: number | null; + // false → the tool is never offered in the chat actions menu. + chat_selectable: boolean; +} + +// In-code tool identifiers (backend `in_code_tool_id`). +export const SEARCH_TOOL_ID = "SearchTool"; +export const WEB_SEARCH_TOOL_ID = "WebSearchTool"; +export const IMAGE_GENERATION_TOOL_ID = "ImageGenerationTool"; +export const PYTHON_TOOL_ID = "PythonTool"; +export const OPEN_URL_TOOL_ID = "OpenURLTool"; +export const CODING_AGENT_TOOL_ID = "CodingAgentTool"; +export const KNOWLEDGE_GRAPH_TOOL_ID = "KnowledgeGraphTool"; +// Always hidden from the actions list (it isn't a user-selectable action). +export const FILE_READER_TOOL_ID = "FileReaderTool"; + +const TOOL_ICONS: Record = { + [SEARCH_TOOL_ID]: SvgSearch, + [WEB_SEARCH_TOOL_ID]: SvgGlobe, + [IMAGE_GENERATION_TOOL_ID]: SvgImage, + [PYTHON_TOOL_ID]: SvgTerminalSmall, + [OPEN_URL_TOOL_ID]: SvgLink, + [CODING_AGENT_TOOL_ID]: SvgCpu, + [KNOWLEDGE_GRAPH_TOOL_ID]: SvgServer, +}; + +// Falls back to the cpu glyph for custom/unknown tools, matching web's getIconForAction catch-all +// (web/src/app/app/services/actionUtils.ts). +export function getIconForToolId( + inCodeToolId: string | null, +): IconFunctionComponent { + if (inCodeToolId && inCodeToolId in TOOL_ICONS) { + return TOOL_ICONS[inCodeToolId]; + } + return SvgCpu; +} + +export function isSearchTool(tool: ToolSnapshot): boolean { + return tool.in_code_tool_id === SEARCH_TOOL_ID; +} + +export function isWebSearchTool(tool: ToolSnapshot): boolean { + return tool.in_code_tool_id === WEB_SEARCH_TOOL_ID; +} + +// Deep research (and the source sub-view) only apply when the agent can search. +export function hasSearchToolsAvailable(tools: ToolSnapshot[]): boolean { + return tools.some((t) => isSearchTool(t) || isWebSearchTool(t)); +} + +// The tools shown in the actions menu: selectable, non-MCP, and not the File Reader. +export function displayableTools(tools: ToolSnapshot[]): ToolSnapshot[] { + return tools.filter( + (t) => + t.chat_selectable && + t.mcp_server_id == null && + t.in_code_tool_id !== FILE_READER_TOOL_ID, + ); +} + +// null when nothing is disabled → backend allows every tool. Otherwise the enabled ids (which may +// be [] if the user disabled all). Never send a bare [] just because no prefs exist. +export function computeAllowedToolIds( + tools: ToolSnapshot[], + disabledToolIds: number[], +): number[] | null { + if (disabledToolIds.length === 0) return null; + const disabled = new Set(disabledToolIds); + return tools.map((t) => t.id).filter((id) => !disabled.has(id)); +} diff --git a/mobile/src/components/avatars/__tests__/AgentAvatar.test.tsx b/mobile/src/components/avatars/__tests__/AgentAvatar.test.tsx index 4eb5ead214..b49e251daf 100644 --- a/mobile/src/components/avatars/__tests__/AgentAvatar.test.tsx +++ b/mobile/src/components/avatars/__tests__/AgentAvatar.test.tsx @@ -32,6 +32,8 @@ function agent( builtin_persona: false, display_priority: null, labels: [], + tools: [], + knowledge_sources: [], ...overrides, }; } diff --git a/mobile/src/components/ui/__tests__/select-button.test.tsx b/mobile/src/components/ui/__tests__/select-button.test.tsx new file mode 100644 index 0000000000..bbab8880a2 --- /dev/null +++ b/mobile/src/components/ui/__tests__/select-button.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react-native"; + +import { SelectButton } from "@/components/ui/select-button"; +import SvgHourglass from "@/icons/hourglass"; + +describe("SelectButton", () => { + it("shows the label when not foldable", () => { + render( + + Deep Research + , + ); + expect(screen.getByText("Deep Research")).toBeTruthy(); + }); + + it("hides the label when folded (icon-only)", () => { + render( + + Deep Research + , + ); + expect(screen.queryByText("Deep Research")).toBeNull(); + }); + + it("fires onPress when tapped", () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByLabelText("Deep Research")); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it("does not fire onPress when disabled", () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByLabelText("Deep Research")); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/mobile/src/components/ui/select-button.styles.ts b/mobile/src/components/ui/select-button.styles.ts new file mode 100644 index 0000000000..5a0d2d38f3 --- /dev/null +++ b/mobile/src/components/ui/select-button.styles.ts @@ -0,0 +1,69 @@ +// Stateful pill color matrix, mirroring Opal `select-light` +// (web/lib/opal/src/core/interactive/stateful/styles.css:228-316). Classes are literal token +// strings so NativeWind's compiler sees them. Touch has no hover, so the web hover cells are +// dropped and the matrix is rest/active/disabled; `active` = pressed. `fg` (label) and `icon` are +// kept separate because the empty state tints them differently, exactly as web does. +export type SelectState = "empty" | "selected"; + +export type SelectVariant = "select-light"; + +export type SelectColorState = "rest" | "active" | "disabled"; + +interface SelectColorCell { + bg: string; + fg: string; + icon: string; +} + +type SelectColorMatrix = Record< + SelectVariant, + Record> +>; + +export const SELECT_COLORS: SelectColorMatrix = { + "select-light": { + empty: { + rest: { + bg: "bg-transparent", + fg: "text-text-04", + icon: "text-text-03", + }, + active: { + bg: "bg-background-neutral-00", + fg: "text-text-05", + icon: "text-text-05", + }, + disabled: { + bg: "bg-transparent", + fg: "text-text-01", + icon: "text-text-01", + }, + }, + selected: { + rest: { + bg: "bg-transparent", + fg: "text-action-link-05", + icon: "text-action-link-05", + }, + active: { + bg: "bg-background-tint-00", + fg: "text-action-link-05", + icon: "text-action-link-05", + }, + disabled: { + bg: "bg-transparent", + fg: "text-action-link-03", + icon: "text-action-link-03", + }, + }, + }, +}; + +export function resolveSelectState( + disabled: boolean, + pressed: boolean, +): SelectColorState { + if (disabled) return "disabled"; + if (pressed) return "active"; + return "rest"; +} diff --git a/mobile/src/components/ui/select-button.tsx b/mobile/src/components/ui/select-button.tsx new file mode 100644 index 0000000000..65abb44040 --- /dev/null +++ b/mobile/src/components/ui/select-button.tsx @@ -0,0 +1,86 @@ +// Pill primitive, RN port of Opal SelectButton +// (web/lib/opal/src/components/buttons/select-button/). Shared by the deep-research toggle and the +// forced-tool chips. Web's `foldable` collapses the label to icon-only via a CSS :hover animation; +// touch has no hover, so mobile folds by simply not rendering the label — which is exactly what the +// deep-research use needs (icon-only when off, label when on: `foldable={!on}`). +import { Pressable, View } from "react-native"; + +import { cn } from "@/lib/utils"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import type { IconFunctionComponent } from "@/icons/types"; +import { + resolveSelectState, + SELECT_COLORS, + type SelectState, + type SelectVariant, +} from "@/components/ui/select-button.styles"; + +interface SelectButtonProps { + icon?: IconFunctionComponent; + children?: string; + state?: SelectState; + variant?: SelectVariant; + // When true, the label is hidden (icon-only). Pass `foldable={!on}` for the deep-research pattern. + foldable?: boolean; + disabled?: boolean; + onPress?: () => void; + // Required when folded / icon-only so a screen reader has something to announce. + accessibilityLabel?: string; +} + +function SelectButton({ + icon, + children, + state = "empty", + variant = "select-light", + foldable = false, + disabled = false, + onPress, + accessibilityLabel, +}: SelectButtonProps) { + const showLabel = children != null && !foldable; + + return ( + + {({ pressed }) => { + const colors = + SELECT_COLORS[variant][state][resolveSelectState(disabled, pressed)]; + return ( + + {icon ? ( + + + + ) : null} + + {showLabel ? ( + + {children} + + ) : null} + + ); + }} + + ); +} + +export { SelectButton, type SelectButtonProps }; diff --git a/mobile/src/components/ui/switch.tsx b/mobile/src/components/ui/switch.tsx new file mode 100644 index 0000000000..1b05450745 --- /dev/null +++ b/mobile/src/components/ui/switch.tsx @@ -0,0 +1,77 @@ +// Toggle primitive, RN port of Opal Switch (web/lib/opal/src/components/inputs/switch/). Track +// 32×18, thumb 14×14, reanimated thumb translate. RN's native Switch can't match the token look, so +// this is hand-built from a Pressable track + an Animated thumb. +import { useEffect } from "react"; +import { Pressable } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +import { cn } from "@/lib/utils"; + +const TRACK_WIDTH = 32; +const TRACK_HEIGHT = 18; +const THUMB_SIZE = 14; +const TRACK_PADDING = 2; +// Inner track width minus the thumb: 32 − 2·2 − 14 = 14. +const THUMB_TRAVEL = TRACK_WIDTH - TRACK_PADDING * 2 - THUMB_SIZE; + +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + accessibilityLabel?: string; +} + +function Switch({ + checked, + onCheckedChange, + disabled = false, + accessibilityLabel, +}: SwitchProps) { + const offset = useSharedValue(checked ? THUMB_TRAVEL : 0); + + useEffect(() => { + offset.value = withTiming(checked ? THUMB_TRAVEL : 0, { duration: 150 }); + }, [checked, offset]); + + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: offset.value }], + })); + + const trackColor = disabled + ? checked + ? "bg-action-link-03" + : "bg-background-neutral-04" + : checked + ? "bg-action-link-05" + : "bg-background-tint-03"; + const thumbColor = disabled + ? "bg-background-neutral-03" + : "bg-background-neutral-light-00"; + + return ( + onCheckedChange(!checked)} + accessibilityRole="switch" + accessibilityState={{ checked, disabled }} + accessibilityLabel={accessibilityLabel} + className={cn("justify-center rounded-full", trackColor)} + style={{ + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + paddingHorizontal: TRACK_PADDING, + }} + > + + + ); +} + +export { Switch, type SwitchProps }; diff --git a/mobile/src/hooks/__tests__/useChatController.test.tsx b/mobile/src/hooks/__tests__/useChatController.test.tsx index 55454bacdf..b3052be20a 100644 --- a/mobile/src/hooks/__tests__/useChatController.test.tsx +++ b/mobile/src/hooks/__tests__/useChatController.test.tsx @@ -233,6 +233,61 @@ describe("useChatController", () => { expect(result.current.messages[0]!.files).toEqual(files); }); + it("threads toolOptions onto the send body (deep research / tools / sources)", async () => { + useChatSessionStore.getState().ensureSession("s-tools"); + streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()])); + + const { result } = renderHook(() => useChatController("s-tools"), { + wrapper, + }); + await act(async () => { + await result.current.submit("hi", [], undefined, { + deepResearch: true, + allowedToolIds: [1, 3], + forcedToolId: 3, + internalSearchFilters: { source_type: ["web"] }, + }); + }); + + await waitFor(() => expect(result.current.chatState).toBe("input")); + + const body = streamMock.mock.calls[0]![0] as unknown as { + deep_research: boolean; + allowed_tool_ids: number[] | null; + forced_tool_id: number | null; + internal_search_filters: { source_type: string[] | null } | null; + }; + expect(body.deep_research).toBe(true); + expect(body.allowed_tool_ids).toEqual([1, 3]); + expect(body.forced_tool_id).toBe(3); + expect(body.internal_search_filters).toEqual({ source_type: ["web"] }); + }); + + it("defaults the tool fields when no toolOptions are passed (backwards compatible)", async () => { + useChatSessionStore.getState().ensureSession("s-defaults"); + streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()])); + + const { result } = renderHook(() => useChatController("s-defaults"), { + wrapper, + }); + await act(async () => { + await result.current.submit("hi"); + }); + + await waitFor(() => expect(result.current.chatState).toBe("input")); + + const body = streamMock.mock.calls[0]![0] as unknown as { + deep_research: boolean; + allowed_tool_ids: number[] | null; + forced_tool_id: number | null; + internal_search_filters: unknown; + }; + expect(body.deep_research).toBe(false); + expect(body.allowed_tool_ids).toBeNull(); + expect(body.forced_tool_id).toBeNull(); + expect(body.internal_search_filters).toBeNull(); + }); + it("creates a session on the first message of a new chat", async () => { createSessionMock.mockResolvedValue("new-session"); streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()])); diff --git a/mobile/src/hooks/useChatController.ts b/mobile/src/hooks/useChatController.ts index 0bd4494fcf..30d0a05670 100644 --- a/mobile/src/hooks/useChatController.ts +++ b/mobile/src/hooks/useChatController.ts @@ -12,6 +12,7 @@ import { stopChatSession, } from "@/api/chat/sessions"; import { + ChatToolOptions, isMessageIdInfo, isPacket, isStreamError, @@ -169,10 +170,13 @@ export interface ChatController { chatState: ChatState; // `onAccepted` fires once, past every early return and before the session-create await, so the // caller can clear the draft optimistically yet only on a committed send. + // `toolOptions` carries the toolbar controls (deep research / forced + allowed tools / sources); + // absent = backend defaults. submit: ( text: string, files?: FileDescriptor[], onAccepted?: () => void, + toolOptions?: ChatToolOptions, ) => void; stop: () => void; isHydrating: boolean; @@ -230,6 +234,7 @@ export function useChatController( overrideText: string, files?: FileDescriptor[], onAccepted?: () => void, + toolOptions?: ChatToolOptions, ) => { const text = overrideText.trim(); if (!text) return; @@ -293,8 +298,11 @@ export function useChatController( chat_session_id: activeId, parent_message_id: parentMessageId, file_descriptors: fileDescriptors, - deep_research: false, + deep_research: toolOptions?.deepResearch ?? false, origin: "mobile", + allowed_tool_ids: toolOptions?.allowedToolIds ?? null, + forced_tool_id: toolOptions?.forcedToolId ?? null, + internal_search_filters: toolOptions?.internalSearchFilters ?? null, }; if (sessionId == null) { diff --git a/mobile/src/icons/cpu.tsx b/mobile/src/icons/cpu.tsx new file mode 100644 index 0000000000..6e6fc036a5 --- /dev/null +++ b/mobile/src/icons/cpu.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgCpu = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgCpu; diff --git a/mobile/src/icons/globe.tsx b/mobile/src/icons/globe.tsx new file mode 100644 index 0000000000..4ecf1e8dc4 --- /dev/null +++ b/mobile/src/icons/globe.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgGlobe = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgGlobe; diff --git a/mobile/src/icons/hourglass.tsx b/mobile/src/icons/hourglass.tsx new file mode 100644 index 0000000000..58ed917ecf --- /dev/null +++ b/mobile/src/icons/hourglass.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgHourglass = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgHourglass; diff --git a/mobile/src/icons/link.tsx b/mobile/src/icons/link.tsx new file mode 100644 index 0000000000..c23d006c31 --- /dev/null +++ b/mobile/src/icons/link.tsx @@ -0,0 +1,26 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +// The path is a horizontal chain-link; web rotates the whole element 315° to read diagonal. +// `style` is pulled out so a caller-supplied style merges after the rotation rather than clobbering it. +const SvgLink = ({ size = 16, style, ...props }: IconProps) => ( + + + +); + +export default SvgLink; diff --git a/mobile/src/icons/plug.tsx b/mobile/src/icons/plug.tsx new file mode 100644 index 0000000000..f6a107babc --- /dev/null +++ b/mobile/src/icons/plug.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgPlug = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgPlug; diff --git a/mobile/src/icons/server.tsx b/mobile/src/icons/server.tsx new file mode 100644 index 0000000000..3def02b2e6 --- /dev/null +++ b/mobile/src/icons/server.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgServer = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgServer; diff --git a/mobile/src/icons/slash.tsx b/mobile/src/icons/slash.tsx new file mode 100644 index 0000000000..99f10b7d63 --- /dev/null +++ b/mobile/src/icons/slash.tsx @@ -0,0 +1,29 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgSlash = ({ size = 16, ...props }: IconProps) => ( + + + + +); + +export default SvgSlash; diff --git a/mobile/src/icons/unplug.tsx b/mobile/src/icons/unplug.tsx new file mode 100644 index 0000000000..be6760c61e --- /dev/null +++ b/mobile/src/icons/unplug.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgUnplug = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgUnplug;