Skip to content

feat(chat): add multi-model selector and chat hook#9854

Open
nmgarza5 wants to merge 12 commits intomainfrom
multi-model-4a-selector
Open

feat(chat): add multi-model selector and chat hook#9854
nmgarza5 wants to merge 12 commits intomainfrom
multi-model-4a-selector

Conversation

@nmgarza5
Copy link
Copy Markdown
Contributor

@nmgarza5 nmgarza5 commented Apr 2, 2026

Description

Adds ModelSelector component and useMultiModelChat hook for multi-model chat support.

  • ModelSelector.tsx — popover-based model picker using opal SelectButton/OpenButton, reuses LLMPopover patterns (accordion grouping, LineItem rendering, search filtering)
  • useMultiModelChat.ts — hook managing selected model state (add/remove/replace up to 3 models)
  • useMultiModelChat.test.tsx — unit tests for the hook

Stacked on #9648 (PR3: frontend types).

How Has This Been Tested?

2026-04-02 11 24 39

Additional Options

  • [Optional] Please cherry-pick this PR to the latest release version.
  • [Optional] Override Linear Check

@nmgarza5 nmgarza5 requested a review from a team as a code owner April 2, 2026 08:44
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 2, 2026

Greptile Summary

This PR adds the ModelSelector component and useMultiModelChat hook to support multi-model (up to 3) parallel comparison in chat. It also refactors LLMPopover to share a new ModelListContent component that handles search, grouping, and accordion rendering.

All five issues flagged in prior review rounds have been resolved: the orphaned separator is now correctly gated on !atMax; restoreFromModelNames enforces the MAX_MODELS cap via .slice(0, MAX_MODELS); the useEffect dependency array drops the broad llmManager reference; buildLlmOverrides uses m.provider (the provider type) rather than m.name (the display name); and isLoading is now forwarded to ModelListContent.

Remaining findings:

  • ModelSelector detects X-icon clicks by querying the internal .interactive-foreground-icon CSS class on SelectButton — if the opal design system renames or restructures this class, the remove action silently falls back to opening the replace-popover (P2).
  • isSelected and isDisabled in ModelSelector are not wrapped in useCallback, causing an extra useMemo recomputation in ModelListContent on each render; wrapping them with their actual deps stabilises the references (P2).

Confidence Score: 5/5

  • Safe to merge — all prior P0/P1 issues are resolved and only minor P2 style/robustness suggestions remain.
  • All five previously flagged issues (orphaned separator, MAX_MODELS cap bypass, redundant dep, wrong model_provider field, missing isLoading prop) have been fixed. The two remaining findings are style/fragility concerns that do not affect current runtime correctness.
  • ModelSelector.tsx — X-icon detection and missing useCallback wrapping.

Important Files Changed

Filename Overview
web/src/hooks/useMultiModelChat.ts New hook managing multi-model selection state (add/remove/replace up to 3 models). All previously flagged issues (MAX_MODELS cap in restoreFromModelNames, redundant llmManager dep, model_provider field) are resolved.
web/src/refresh-components/popovers/LLMPopover.tsx Refactored to delegate model list rendering to the new ModelListContent component; loading state is now correctly forwarded. Logic and API surface unchanged.
web/src/refresh-components/popovers/ModelListContent.tsx New shared component encapsulating search, grouping, and accordion-style model list rendering. Uses Collapsible instead of Accordion (equivalent multi-expand behaviour). isSelected prop creates unstable references when callers don't use useCallback, but impact is limited to extra useMemo recomputation.
web/src/refresh-components/popovers/ModelSelector.tsx New multi-model picker component. Two issues: (1) X-icon detection relies on the internal .interactive-foreground-icon CSS class from SelectButton — fragile if the opal design system renames it; (2) isSelected/isDisabled lack useCallback, causing unnecessary useMemo recomputation downstream.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[useMultiModelChat hook] -->|selectedModels, add/remove/replace| B[ModelSelector component]
    B -->|open popover| C[ModelListContent component]
    C -->|buildLlmOptions| D[LLMPopover.buildLlmOptions]
    C -->|groupLlmOptions| E[LLMPopover.groupLlmOptions]
    B -->|replacingIndex=null → onAdd| A
    B -->|replacingIndex=N → onReplace| A
    B -->|onRemove| A
    A -->|buildLlmOverrides| F[LLMOverride array sent to backend]
    G[LLMPopover] -->|isLoading, isSelected, footer| C
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: web/src/refresh-components/popovers/ModelSelector.tsx
Line: 1003-1015

Comment:
**Fragile DOM class sniffing for X-icon detection**

The click handler distinguishes an X-remove click from an open-popover click by querying the internal CSS class `.interactive-foreground-icon` from `SelectButton`. This couples the component to an undocumented implementation detail of `@opal/components`.

If `SelectButton` renames or restructures that class, `lastIcon` will be `undefined`, the `contains` guard will always be `false`, and **all** clicks on a model pill (including on the X icon) will silently open the replace-popover instead of removing the model — with no compile-time error.

Consider requesting an `onRightIconClick` callback prop from the design system, or restructuring so the X uses a separate focusable element that calls `onRemove` via its own `onClick` and `e.stopPropagation()`:

```tsx
<div className="relative">
  <SelectButton
    icon={ProviderIcon}
    state="empty"
    variant="select-tinted"
    interaction="hover"
    size="lg"
    onClick={(e: React.MouseEvent) => handlePillClick(index, e.currentTarget as HTMLElement)}
  >
    {model.displayName}
  </SelectButton>
  <button
    className="absolute right-1 top-1/2 -translate-y-1/2"
    onClick={(e) => { e.stopPropagation(); onRemove(index); }}
    aria-label="Remove model"
  >
    <SvgX className="h-4 w-4" />
  </button>
</div>
```

If `.interactive-foreground-icon` is a documented, stable part of the opal design system API, feel free to dismiss this — just worth confirming.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/refresh-components/popovers/ModelSelector.tsx
Line: 885-895

Comment:
**`isSelected`/`isDisabled` not wrapped in `useCallback`**

Both functions are recreated on every render and passed as props to `ModelListContent`, which uses `isSelected` as a `useMemo` dependency (`[groupedOptions, isSelected]`). A new function reference on each render causes `defaultGroupKey` to recompute every time `ModelSelector` re-renders (e.g. when `open` or `replacingIndex` state changes), even when the logical selection hasn't changed.

Wrapping both in `useCallback` with their actual dependencies (`replacingIndex`, `replacingKey`, `selectedKeys`, `otherSelectedKeys`, `atMax`) would stabilise the references and make `ModelListContent`'s memoisation effective.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (9): Last reviewed commit: "refactor(LLMPopover): use ModelListConte..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Confidence score: 4/5

  • This PR looks generally safe to merge, but there is a policy-compliance risk rather than a clear runtime regression risk.
  • The most significant issue is in web/src/refresh-components/popovers/ModelSelector.tsx: it imports accordion primitives from @/components/ui/accordion, which conflicts with frontend standards that forbid using components from web/src/components in this area.
  • Because the reported problem is a standards violation (severity 7/10, confidence 8/10) and not a confirmed user-facing bug, the merge risk stays moderate-low if addressed soon.
  • Pay close attention to web/src/refresh-components/popovers/ModelSelector.tsx - replace forbidden accordion imports with the approved Opal/allowed alternative to avoid architecture drift.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/src/refresh-components/popovers/ModelSelector.tsx">

<violation number="1" location="web/src/refresh-components/popovers/ModelSelector.tsx:28">
P1: Custom agent: **Frontend standards**

Frontend standards forbid using components from `web/src/components`, but this file imports the accordion primitives from `@/components/ui/accordion`. Replace them with an Opal or refresh-component equivalent instead of the legacy components.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Base automatically changed from multi-model-3-fe-types to main April 2, 2026 09:00
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

Preview Deployment

Status Preview Commit Updated
https://onyx-preview-o3giqarar-danswer.vercel.app 7272a1b 2026-04-02 19:09:40 UTC

nmgarza5 added 3 commits April 2, 2026 02:02
ModelSelector component with add/remove/replace semantics for up to 3
concurrent LLMs. useMultiModelChat hook manages model selection state
and streaming coordination.
Replace hand-rolled ModelPill with opal SelectButton, custom ModelItem
with LineItem, raw Radix AccordionPrimitive with shared Accordion, and
reuse buildLlmOptions/groupLlmOptions from LLMPopover.
…tion headers

ModelSelector was reimplementing LLMPopover internals (search, accordion,
item rendering). Extract shared ModelListContent component that uses the
ResourcePopover pattern — flat section headers with provider icon + separator
line instead of collapsible accordions. ModelSelector now just handles
selection logic and pill rendering.
@nmgarza5 nmgarza5 force-pushed the multi-model-4a-selector branch from f7c1f8b to 2d3c838 Compare April 2, 2026 09:02
nmgarza5 added 2 commits April 2, 2026 02:10
Popover flew to upper-left at 3 models because no Trigger existed.
Wrap pills in Popover.Anchor so content positions correctly. Leading
separator now only shown when + button is visible.
Match Figma mock: rightIcon={SvgX} puts the remove icon inside the
pill. Size md makes pills larger. Clicking the pill removes the model.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/src/refresh-components/popovers/ModelSelector.tsx">

<violation number="1" location="web/src/refresh-components/popovers/ModelSelector.tsx:169">
P2: Clicking a model pill in multi‑model mode now removes it immediately, so the replace flow (via the popover) is no longer reachable for multi‑model selections. Use the existing `handlePillClick` to open the popover for replacement, consistent with the single‑model path.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

🖼️ Visual Regression Report

Project Changed Added Removed Unchanged Report
admin 8 0 0 157 View Report
exclusive 0 0 0 8 ✅ No changes

nmgarza5 added 3 commits April 2, 2026 02:55
- Fix model_provider mapping: use m.provider instead of m.name
- Pass isLoading to ModelListContent to show loading state
- Cap restoreFromModelNames at MAX_MODELS
- Remove redundant llmManager from useEffect deps
- Use Separator component instead of raw divs
- Add two-click-zone on SelectButton (main area→replace, X→remove)
- Replace raw div separators with Separator component
- Change size from md to lg per design mock
- Add two-click-zone: main area opens replace popover, X icon removes
Replace static Popover.Anchor with virtualRef so the model list
positions above whichever button was clicked (+ button or pill).
Also add avoidCollisions={false} to prevent Radix from flipping
the popover to the bottom.
nmgarza5 added 3 commits April 2, 2026 09:43
Hook logic is simple enough to not warrant a standalone test file.
Switch from @/components/ui/accordion (legacy) to
@/refresh-components/Collapsible (Radix wrapper). Matches the visual
layout of the existing LLMPopover: collapsible groups with provider
icons, chevron indicators, auto-expand selected group, force-expand
all on search.
Replace raw <button> and wrapper <div>s with LineItem and Section
from the component library.
Comment on lines +13 to +17
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/refresh-components/Collapsible";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i just realized we have this web/src/refresh-components/popovers/LLMPopover.tsx

going to try and consolidate really quick

LLMPopover now delegates search, grouping, and model list rendering
to ModelListContent, eliminating duplicated code. Keeps popover
wrapper, trigger button, and temperature slider.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant