Compare commits

...

17 Commits

Author SHA1 Message Date
Nik
c11d5fb62a fix(chat): conditional fade overlay and opacity for non-preferred panels
- Only show bottom gradient on non-preferred panels that actually
  overflow the preferred panel height cap
- Always dim non-preferred panels at 50% opacity (not just when capped)
- Add preferredIdx !== -1 guard and responses.find() for safer lookups
2026-04-02 12:06:40 -07:00
Nik
005c27975f fix(chat): address review comments on multi-model panels
- Use responses.find() instead of responses[modelIndex] array indexing
- Guard preferredIdx === -1 to prevent NaN in carousel transform
- Remove unused SvgEyeClosed import
- Remove unused isHighlighted field from MultiModelResponse
- Remove unused modelIndex prop from MultiModelPanel
2026-04-02 12:06:40 -07:00
Nik
c7c689f6cc fix(chat): use pixel values for carousel centering transform
translateX(calc(50% - ...)) used 50% of the track's own width, not
the container's. Use measured trackContainerW for correct centering.
2026-04-02 12:06:40 -07:00
Nik
c9f124b109 docs(chat): add JSDoc to MultiModelResponseView and MultiModelPanel 2026-04-02 12:06:40 -07:00
Nik
f6a821b66d feat(chat): add multi-model response panels
MultiModelResponseView renders 2-3 LLM responses side-by-side with
generation and selection modes. MultiModelPanel wraps AgentMessage for
each model's output. Adds hideFooter prop to AgentMessage for
non-preferred panel styling.
2026-04-02 12:06:40 -07:00
Nik
7272a1b4c4 refactor(LLMPopover): use ModelListContent for model list rendering
LLMPopover now delegates search, grouping, and model list rendering
to ModelListContent, eliminating duplicated code. Keeps popover
wrapper, trigger button, and temperature slider.
2026-04-02 12:06:24 -07:00
Nik
503fab60dc refactor(ModelListContent): replace raw HTML with library components
Replace raw <button> and wrapper <div>s with LineItem and Section
from the component library.
2026-04-02 11:12:57 -07:00
Nik
4b174712cc refactor(ModelListContent): replace legacy accordion with Collapsible
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.
2026-04-02 10:37:36 -07:00
Nik
327b0ff4a1 chore(hooks): remove useMultiModelChat unit test
Hook logic is simple enough to not warrant a standalone test file.
2026-04-02 09:43:43 -07:00
Nik
5a8b6f0217 fix(chat): anchor popover above clicked pill via virtualRef
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.
2026-04-02 03:20:43 -07:00
Nik
9b2f8c8016 fix(chat): refactor ModelSelector separators and click zones
- 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
2026-04-02 02:56:22 -07:00
Nik
ec5eb75722 fix(chat): address review comments on model selector and hook
- 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)
2026-04-02 02:55:44 -07:00
Nik
3ca304d228 fix(chat): move X icon inside SelectButton, use size md
Match Figma mock: rightIcon={SvgX} puts the remove icon inside the
pill. Size md makes pills larger. Clicking the pill removes the model.
2026-04-02 02:17:06 -07:00
Nik
a5c982fb75 fix(chat): anchor popover to pills, fix separator when at max models
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.
2026-04-02 02:10:21 -07:00
Nik
2d3c838707 refactor(chat): extract ModelListContent, drop accordion for flat section 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.
2026-04-02 02:02:10 -07:00
Nik
d936efeffd refactor(chat): reuse opal components in ModelSelector
Replace hand-rolled ModelPill with opal SelectButton, custom ModelItem
with LineItem, raw Radix AccordionPrimitive with shared Accordion, and
reuse buildLlmOptions/groupLlmOptions from LLMPopover.
2026-04-02 02:02:10 -07:00
Nik
e1e7693870 feat(chat): add multi-model selector and chat hook
ModelSelector component with add/remove/replace semantics for up to 3
concurrent LLMs. useMultiModelChat hook manages model selection state
and streaming coordination.
2026-04-02 02:02:10 -07:00
8 changed files with 1195 additions and 288 deletions

View File

@@ -0,0 +1,126 @@
"use client";
import { useCallback } from "react";
import { Button } from "@opal/components";
import { Text } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import { SvgEyeOff, SvgX } from "@opal/icons";
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
import AgentMessage, {
AgentMessageProps,
} from "@/app/app/message/messageComponents/AgentMessage";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
export interface MultiModelPanelProps {
/** Provider name for icon lookup */
provider: string;
/** Model name for icon lookup and display */
modelName: string;
/** Display-friendly model name */
displayName: string;
/** Whether this panel is the preferred/selected response */
isPreferred: boolean;
/** Whether this panel is currently hidden */
isHidden: boolean;
/** Whether this is a non-preferred panel in selection mode (pushed off-screen) */
isNonPreferredInSelection: boolean;
/** Callback when user clicks this panel to select as preferred */
onSelect: () => void;
/** Callback to hide/show this panel */
onToggleVisibility: () => void;
/** Props to pass through to AgentMessage */
agentMessageProps: AgentMessageProps;
}
/**
* A single model's response panel within the multi-model view.
*
* Renders in two states:
* - **Hidden** — compact header strip only (provider icon + strikethrough name + show button).
* - **Visible** — full header plus `AgentMessage` body. Clicking anywhere on a
* visible non-preferred panel marks it as preferred.
*
* The `isNonPreferredInSelection` flag disables pointer events on the body and
* hides the footer so the panel acts as a passive comparison surface.
*/
export default function MultiModelPanel({
provider,
modelName,
displayName,
isPreferred,
isHidden,
isNonPreferredInSelection,
onSelect,
onToggleVisibility,
agentMessageProps,
}: MultiModelPanelProps) {
const ProviderIcon = getProviderIcon(provider, modelName);
const handlePanelClick = useCallback(() => {
if (!isHidden) onSelect();
}, [isHidden, onSelect]);
const header = (
<div
className={cn(
"rounded-12",
isPreferred ? "bg-background-tint-02" : "bg-background-tint-00"
)}
>
<ContentAction
sizePreset="main-ui"
variant="body"
paddingVariant="lg"
icon={ProviderIcon}
title={isHidden ? markdown(`~~${displayName}~~`) : displayName}
rightChildren={
<div className="flex items-center gap-1 px-2">
{isPreferred && (
<span className="text-action-link-05 shrink-0">
<Text font="secondary-body" color="inherit" nowrap>
Preferred Response
</Text>
</span>
)}
{!isPreferred && (
<Button
prominence="tertiary"
icon={isHidden ? SvgEyeOff : SvgX}
size="md"
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
tooltip={isHidden ? "Show response" : "Hide response"}
/>
)}
</div>
}
/>
</div>
);
// Hidden/collapsed panel — just the header row
if (isHidden) {
return header;
}
return (
<div
className={cn(
"flex flex-col gap-3 min-w-0 cursor-pointer rounded-16 transition-colors",
!isPreferred && "hover:bg-background-tint-02"
)}
onClick={handlePanelClick}
>
{header}
<div className={cn(isNonPreferredInSelection && "pointer-events-none")}>
<AgentMessage
{...agentMessageProps}
hideFooter={isNonPreferredInSelection}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,372 @@
"use client";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
import { Message } from "@/app/app/interfaces";
import { LlmManager } from "@/lib/hooks";
import { RegenerationFactory } from "@/app/app/message/messageComponents/AgentMessage";
import MultiModelPanel from "@/app/app/message/MultiModelPanel";
import { MultiModelResponse } from "@/app/app/message/interfaces";
import { cn } from "@/lib/utils";
export interface MultiModelResponseViewProps {
responses: MultiModelResponse[];
chatState: FullChatState;
llmManager: LlmManager | null;
onRegenerate?: RegenerationFactory;
parentMessage?: Message | null;
otherMessagesCanSwitchTo?: number[];
onMessageSelection?: (nodeId: number) => void;
/** Called whenever the set of hidden panel indices changes */
onHiddenPanelsChange?: (hidden: Set<number>) => void;
}
// How many pixels of a non-preferred panel are visible at the viewport edge
const PEEK_W = 64;
// Uniform panel width used in the selection-mode carousel
const SELECTION_PANEL_W = 400;
// Compact width for hidden panels in the carousel track
const HIDDEN_PANEL_W = 220;
// Generation-mode panel widths (from Figma)
const GEN_PANEL_W_2 = 640; // 2 panels side-by-side
const GEN_PANEL_W_3 = 436; // 3 panels side-by-side
// Gap between panels — matches CSS gap-6 (24px)
const PANEL_GAP = 24;
// Minimum panel width before horizontal scroll kicks in
const MIN_PANEL_W = 300;
/**
* Renders N model responses side-by-side with two layout modes:
*
* **Generation mode** — equal-width panels in a horizontally-scrollable row.
* Panel width is determined by the number of visible (non-hidden) panels.
*
* **Selection mode** — activated when the user clicks a panel to mark it as
* preferred. All panels (including hidden ones) sit in a fixed-width carousel
* track. A CSS `translateX` transform slides the track so the preferred panel
* is centered in the viewport; the other panels peek in from the edges through
* a mask gradient. Non-preferred visible panels are height-capped to the
* preferred panel's measured height, dimmed at 50% opacity, and receive a
* bottom fade-out overlay.
*
* Hidden panels render as a compact header-only strip at `HIDDEN_PANEL_W` in
* both modes and are excluded from layout width calculations.
*/
export default function MultiModelResponseView({
responses,
chatState,
llmManager,
onRegenerate,
parentMessage,
otherMessagesCanSwitchTo,
onMessageSelection,
onHiddenPanelsChange,
}: MultiModelResponseViewProps) {
const [preferredIndex, setPreferredIndex] = useState<number | null>(null);
const [hiddenPanels, setHiddenPanels] = useState<Set<number>>(new Set());
// Controls animation: false = panels at start position, true = panels at peek position
const [selectionEntered, setSelectionEntered] = useState(false);
// Measures the overflow-hidden carousel container for responsive preferred-panel sizing.
const [trackContainerW, setTrackContainerW] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const trackContainerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setTrackContainerW(entry?.contentRect.width ?? 0);
});
ro.observe(el);
setTrackContainerW(el.offsetWidth);
roRef.current = ro;
}, []);
// Measures the preferred panel's height to cap non-preferred panels in selection mode.
const [preferredPanelHeight, setPreferredPanelHeight] = useState<
number | null
>(null);
const preferredRoRef = useRef<ResizeObserver | null>(null);
// Tracks which non-preferred panels overflow the preferred height cap
const [overflowingPanels, setOverflowingPanels] = useState<Set<number>>(
new Set()
);
const preferredPanelRef = useCallback((el: HTMLDivElement | null) => {
if (preferredRoRef.current) {
preferredRoRef.current.disconnect();
preferredRoRef.current = null;
}
if (!el) {
setPreferredPanelHeight(null);
return;
}
const ro = new ResizeObserver(([entry]) => {
setPreferredPanelHeight(entry?.contentRect.height ?? 0);
});
ro.observe(el);
setPreferredPanelHeight(el.offsetHeight);
preferredRoRef.current = ro;
}, []);
const isGenerating = useMemo(
() => responses.some((r) => r.isGenerating),
[responses]
);
// Non-hidden responses — used for layout width decisions and selection-mode gating
const visibleResponses = useMemo(
() => responses.filter((r) => !hiddenPanels.has(r.modelIndex)),
[responses, hiddenPanels]
);
const toggleVisibility = useCallback(
(modelIndex: number) => {
setHiddenPanels((prev) => {
const next = new Set(prev);
if (next.has(modelIndex)) {
next.delete(modelIndex);
} else {
// Don't hide the last visible panel
const visibleCount = responses.length - next.size;
if (visibleCount <= 1) return prev;
next.add(modelIndex);
}
onHiddenPanelsChange?.(next);
return next;
});
},
[responses.length, onHiddenPanelsChange]
);
const handleSelectPreferred = useCallback(
(modelIndex: number) => {
if (isGenerating) return;
setPreferredIndex(modelIndex);
const response = responses.find((r) => r.modelIndex === modelIndex);
if (!response) return;
if (onMessageSelection) {
onMessageSelection(response.nodeId);
}
},
[isGenerating, responses, onMessageSelection]
);
// Clear preferred selection when generation starts
useEffect(() => {
if (isGenerating) {
setPreferredIndex(null);
}
}, [isGenerating]);
// Find preferred panel position — used for both the selection guard and carousel layout
const preferredIdx = responses.findIndex(
(r) => r.modelIndex === preferredIndex
);
// Selection mode when preferred is set, found in responses, not generating, and at least 2 visible panels
const showSelectionMode =
preferredIndex !== null &&
preferredIdx !== -1 &&
!isGenerating &&
visibleResponses.length > 1;
// Trigger the slide-out animation one frame after entering selection mode
useEffect(() => {
if (!showSelectionMode) {
setSelectionEntered(false);
return;
}
const raf = requestAnimationFrame(() => setSelectionEntered(true));
return () => cancelAnimationFrame(raf);
}, [showSelectionMode]);
// Build panel props — isHidden reflects actual hidden state
const buildPanelProps = useCallback(
(response: MultiModelResponse, isNonPreferred: boolean) => ({
provider: response.provider,
modelName: response.modelName,
displayName: response.displayName,
isPreferred: preferredIndex === response.modelIndex,
isHidden: hiddenPanels.has(response.modelIndex),
isNonPreferredInSelection: isNonPreferred,
onSelect: () => handleSelectPreferred(response.modelIndex),
onToggleVisibility: () => toggleVisibility(response.modelIndex),
agentMessageProps: {
rawPackets: response.packets,
packetCount: response.packetCount,
chatState,
nodeId: response.nodeId,
messageId: response.messageId,
currentFeedback: response.currentFeedback,
llmManager,
otherMessagesCanSwitchTo,
onMessageSelection,
onRegenerate,
parentMessage,
},
}),
[
preferredIndex,
hiddenPanels,
handleSelectPreferred,
toggleVisibility,
chatState,
llmManager,
otherMessagesCanSwitchTo,
onMessageSelection,
onRegenerate,
parentMessage,
]
);
if (showSelectionMode) {
// ── Selection Layout (transform-based carousel) ──
//
// All panels (including hidden) sit in the track at their original A/B/C positions.
// Hidden panels use HIDDEN_PANEL_W; non-preferred use SELECTION_PANEL_W;
// preferred uses dynamicPrefW (up to GEN_PANEL_W_2).
const n = responses.length;
const dynamicPrefW =
trackContainerW > 0
? Math.min(trackContainerW - 2 * (PEEK_W + PANEL_GAP), GEN_PANEL_W_2)
: GEN_PANEL_W_2;
const selectionWidths = responses.map((r, i) => {
if (hiddenPanels.has(r.modelIndex)) return HIDDEN_PANEL_W;
if (i === preferredIdx) return dynamicPrefW;
return SELECTION_PANEL_W;
});
const panelLeftEdges = selectionWidths.reduce<number[]>((acc, w, i) => {
acc.push(i === 0 ? 0 : acc[i - 1]! + selectionWidths[i - 1]! + PANEL_GAP);
return acc;
}, []);
const preferredCenterInTrack =
panelLeftEdges[preferredIdx]! + selectionWidths[preferredIdx]! / 2;
// Start position: hidden panels at HIDDEN_PANEL_W, visible at SELECTION_PANEL_W
const uniformTrackW =
responses.reduce(
(sum, r) =>
sum +
(hiddenPanels.has(r.modelIndex) ? HIDDEN_PANEL_W : SELECTION_PANEL_W),
0
) +
(n - 1) * PANEL_GAP;
const trackTransform = selectionEntered
? `translateX(${trackContainerW / 2 - preferredCenterInTrack}px)`
: `translateX(${(trackContainerW - uniformTrackW) / 2}px)`;
return (
<div
ref={trackContainerRef}
className="w-full overflow-hidden"
style={{
maskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
}}
>
<div
className="flex items-start"
style={{
gap: `${PANEL_GAP}px`,
transition: selectionEntered
? "transform 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
transform: trackTransform,
}}
>
{responses.map((r, i) => {
const isHidden = hiddenPanels.has(r.modelIndex);
const isPref = r.modelIndex === preferredIndex;
const isNonPref = !isHidden && !isPref;
const finalW = selectionWidths[i]!;
const startW = isHidden ? HIDDEN_PANEL_W : SELECTION_PANEL_W;
const capped = isNonPref && preferredPanelHeight != null;
const overflows = capped && overflowingPanels.has(r.modelIndex);
return (
<div
key={r.modelIndex}
ref={(el) => {
if (isPref) preferredPanelRef(el);
if (capped && el) {
const doesOverflow = el.scrollHeight > el.clientHeight;
setOverflowingPanels((prev) => {
const had = prev.has(r.modelIndex);
if (doesOverflow === had) return prev;
const next = new Set(prev);
if (doesOverflow) next.add(r.modelIndex);
else next.delete(r.modelIndex);
return next;
});
}
}}
style={{
width: `${selectionEntered ? finalW : startW}px`,
flexShrink: 0,
transition: selectionEntered
? "width 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
maxHeight: capped ? preferredPanelHeight : undefined,
overflow: capped ? "hidden" : undefined,
position: capped ? "relative" : undefined,
}}
>
<div className={cn(isNonPref && "opacity-50")}>
<MultiModelPanel {...buildPanelProps(r, isNonPref)} />
</div>
{overflows && (
<div
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
style={{
background:
"linear-gradient(to top, var(--background-tint-01) 0%, transparent 100%)",
}}
/>
)}
</div>
);
})}
</div>
</div>
);
}
// ── Generation Layout (equal panels side-by-side) ──
// Panel width based on number of visible (non-hidden) panels.
const panelWidth =
visibleResponses.length <= 2 ? GEN_PANEL_W_2 : GEN_PANEL_W_3;
return (
<div className="overflow-x-auto">
<div className="flex gap-6 items-start w-fit mx-auto">
{responses.map((r) => {
const isHidden = hiddenPanels.has(r.modelIndex);
return (
<div
key={r.modelIndex}
style={
isHidden
? {
width: HIDDEN_PANEL_W,
minWidth: HIDDEN_PANEL_W,
maxWidth: HIDDEN_PANEL_W,
flexShrink: 0,
overflow: "hidden" as const,
}
: { width: panelWidth, minWidth: MIN_PANEL_W }
}
>
<MultiModelPanel {...buildPanelProps(r, false)} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Packet } from "@/app/app/services/streamingModels";
import { FeedbackType } from "@/app/app/interfaces";
export interface MultiModelResponse {
modelIndex: number;
provider: string;
modelName: string;
displayName: string;
packets: Packet[];
packetCount: number;
nodeId: number;
messageId?: number;
currentFeedback?: FeedbackType | null;
isGenerating?: boolean;
}

View File

@@ -49,6 +49,8 @@ export interface AgentMessageProps {
parentMessage?: Message | null;
// Duration in seconds for processing this message (agent messages only)
processingDurationSeconds?: number;
/** Hide the feedback/toolbar footer (used in multi-model non-preferred panels) */
hideFooter?: boolean;
}
// TODO: Consider more robust comparisons:
@@ -76,7 +78,8 @@ function arePropsEqual(
prev.parentMessage?.messageId === next.parentMessage?.messageId &&
prev.llmManager?.isLoadingProviders ===
next.llmManager?.isLoadingProviders &&
prev.processingDurationSeconds === next.processingDurationSeconds
prev.processingDurationSeconds === next.processingDurationSeconds &&
prev.hideFooter === next.hideFooter
// Skip: chatState.regenerate, chatState.setPresentingDocument,
// most of llmManager, onMessageSelection (function/object props)
);
@@ -95,6 +98,7 @@ const AgentMessage = React.memo(function AgentMessage({
onRegenerate,
parentMessage,
processingDurationSeconds,
hideFooter,
}: AgentMessageProps) {
const markdownRef = useRef<HTMLDivElement>(null);
const finalAnswerRef = useRef<HTMLDivElement>(null);
@@ -326,7 +330,7 @@ const AgentMessage = React.memo(function AgentMessage({
</div>
{/* Feedback buttons - only show when streaming and rendering complete */}
{isComplete && (
{isComplete && !hideFooter && (
<MessageToolbar
nodeId={nodeId}
messageId={messageId}

View File

@@ -0,0 +1,192 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import {
MAX_MODELS,
SelectedModel,
} from "@/refresh-components/popovers/ModelSelector";
import { LLMOverride } from "@/app/app/services/lib";
import { LlmManager } from "@/lib/hooks";
import { buildLlmOptions } from "@/refresh-components/popovers/LLMPopover";
export interface UseMultiModelChatReturn {
/** Currently selected models for multi-model comparison. */
selectedModels: SelectedModel[];
/** Whether multi-model mode is active (>1 model selected). */
isMultiModelActive: boolean;
/** Add a model to the selection. */
addModel: (model: SelectedModel) => void;
/** Remove a model by index. */
removeModel: (index: number) => void;
/** Replace a model at a specific index with a new one. */
replaceModel: (index: number, model: SelectedModel) => void;
/** Clear all selected models. */
clearModels: () => void;
/** Build the LLMOverride[] array from selectedModels. */
buildLlmOverrides: () => LLMOverride[];
/**
* Restore multi-model selection from model version strings (e.g. from chat history).
* Matches against available llmOptions to reconstruct full SelectedModel objects.
*/
restoreFromModelNames: (modelNames: string[]) => void;
/**
* Switch to a single model by name (after user picks a preferred response).
* Matches against llmOptions to find the full SelectedModel.
*/
selectSingleModel: (modelName: string) => void;
}
export default function useMultiModelChat(
llmManager: LlmManager
): UseMultiModelChatReturn {
const [selectedModels, setSelectedModels] = useState<SelectedModel[]>([]);
const [defaultInitialized, setDefaultInitialized] = useState(false);
// Initialize with the default model from llmManager once providers load
const llmOptions = useMemo(
() =>
llmManager.llmProviders ? buildLlmOptions(llmManager.llmProviders) : [],
[llmManager.llmProviders]
);
useEffect(() => {
if (defaultInitialized) return;
if (llmOptions.length === 0) return;
const { currentLlm } = llmManager;
// Don't initialize if currentLlm hasn't loaded yet
if (!currentLlm.modelName) return;
const match = llmOptions.find(
(opt) =>
opt.provider === currentLlm.provider &&
opt.modelName === currentLlm.modelName
);
if (match) {
setSelectedModels([
{
name: match.name,
provider: match.provider,
modelName: match.modelName,
displayName: match.displayName,
},
]);
setDefaultInitialized(true);
}
}, [llmOptions, llmManager.currentLlm, defaultInitialized]);
const isMultiModelActive = selectedModels.length > 1;
const addModel = useCallback((model: SelectedModel) => {
setSelectedModels((prev) => {
if (prev.length >= MAX_MODELS) return prev;
if (
prev.some(
(m) =>
m.provider === model.provider && m.modelName === model.modelName
)
) {
return prev;
}
return [...prev, model];
});
}, []);
const removeModel = useCallback((index: number) => {
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
}, []);
const replaceModel = useCallback((index: number, model: SelectedModel) => {
setSelectedModels((prev) => {
// Don't replace with a model that's already selected elsewhere
if (
prev.some(
(m, i) =>
i !== index &&
m.provider === model.provider &&
m.modelName === model.modelName
)
) {
return prev;
}
const next = [...prev];
next[index] = model;
return next;
});
}, []);
const clearModels = useCallback(() => {
setSelectedModels([]);
}, []);
const restoreFromModelNames = useCallback(
(modelNames: string[]) => {
if (modelNames.length < 2 || llmOptions.length === 0) return;
const restored: SelectedModel[] = [];
for (const name of modelNames) {
// Try matching by modelName (raw version string like "claude-opus-4-6")
// or by displayName (friendly name like "Claude Opus 4.6")
const match = llmOptions.find(
(opt) =>
opt.modelName === name ||
opt.displayName === name ||
opt.name === name
);
if (match) {
restored.push({
name: match.name,
provider: match.provider,
modelName: match.modelName,
displayName: match.displayName,
});
}
}
if (restored.length >= 2) {
setSelectedModels(restored.slice(0, MAX_MODELS));
setDefaultInitialized(true);
}
},
[llmOptions]
);
const selectSingleModel = useCallback(
(modelName: string) => {
if (llmOptions.length === 0) return;
const match = llmOptions.find(
(opt) =>
opt.modelName === modelName ||
opt.displayName === modelName ||
opt.name === modelName
);
if (match) {
setSelectedModels([
{
name: match.name,
provider: match.provider,
modelName: match.modelName,
displayName: match.displayName,
},
]);
}
},
[llmOptions]
);
const buildLlmOverrides = useCallback((): LLMOverride[] => {
return selectedModels.map((m) => ({
model_provider: m.provider,
model_version: m.modelName,
display_name: m.displayName,
}));
}, [selectedModels]);
return {
selectedModels,
isMultiModelActive,
addModel,
removeModel,
replaceModel,
clearModels,
buildLlmOverrides,
restoreFromModelNames,
selectSingleModel,
};
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import Popover from "@/refresh-components/Popover";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import { structureValue } from "@/lib/llmConfig/utils";
import {
@@ -11,26 +11,12 @@ import {
import { LLMProviderDescriptor } from "@/interfaces/llm";
import { Slider } from "@/components/ui/slider";
import { useUser } from "@/providers/UserProvider";
import LineItem from "@/refresh-components/buttons/LineItem";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
SvgCheck,
SvgChevronDown,
SvgChevronRight,
SvgRefreshCw,
} from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { SvgRefreshCw } from "@opal/icons";
import { OpenButton } from "@opal/components";
import { Disabled } from "@opal/core";
import { LLMOption, LLMOptionGroup } from "./interfaces";
import ModelListContent from "./ModelListContent";
export interface LLMPopoverProps {
llmManager: LlmManager;
@@ -151,7 +137,6 @@ export default function LLMPopover({
const isLoadingProviders = llmManager.isLoadingProviders;
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { user } = useUser();
const [localTemperature, setLocalTemperature] = useState(
@@ -162,9 +147,7 @@ export default function LLMPopover({
setLocalTemperature(llmManager.temperature ?? 0.5);
}, [llmManager.temperature]);
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLDivElement>(null);
const handleGlobalTemperatureChange = useCallback((value: number[]) => {
const value_0 = value[0];
@@ -183,39 +166,28 @@ export default function LLMPopover({
[llmManager]
);
const llmOptions = useMemo(
() => buildLlmOptions(llmProviders, currentModelName),
[llmProviders, currentModelName]
const isSelected = useCallback(
(option: LLMOption) =>
option.modelName === llmManager.currentLlm.modelName &&
option.provider === llmManager.currentLlm.provider,
[llmManager.currentLlm.modelName, llmManager.currentLlm.provider]
);
// Filter options by vision capability (when images are uploaded) and search query
const filteredOptions = useMemo(() => {
let result = llmOptions;
if (requiresImageInput) {
result = result.filter((opt) => opt.supportsImageInput);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(opt) =>
opt.displayName.toLowerCase().includes(query) ||
opt.modelName.toLowerCase().includes(query) ||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
const handleSelectModel = useCallback(
(option: LLMOption) => {
llmManager.updateCurrentLlm({
modelName: option.modelName,
provider: option.provider,
name: option.name,
} as LlmDescriptor);
onSelect?.(
structureValue(option.name, option.provider, option.modelName)
);
}
return result;
}, [llmOptions, searchQuery, requiresImageInput]);
// Group options by provider using backend-provided display names and ordering
// For aggregator providers (bedrock, openrouter, vertex_ai), flatten to "Provider/Vendor" format
const groupedOptions = useMemo(
() => groupLlmOptions(filteredOptions),
[filteredOptions]
setOpen(false);
},
[llmManager, onSelect]
);
// Get display name for the model to show in the button
// Use currentModelName prop if provided (e.g., for regenerate showing the model used),
// otherwise fall back to the globally selected model
const currentLlmDisplayName = useMemo(() => {
// Only use currentModelName if it's a non-empty string
const currentModel =
@@ -235,122 +207,30 @@ export default function LLMPopover({
return currentModel;
}, [llmProviders, currentModelName, llmManager.currentLlm.modelName]);
// Determine which group the current model belongs to (for auto-expand)
const currentGroupKey = useMemo(() => {
const currentModel = llmManager.currentLlm.modelName;
const currentProvider = llmManager.currentLlm.provider;
// Match by both modelName AND provider to handle same model name across providers
const option = llmOptions.find(
(o) => o.modelName === currentModel && o.provider === currentProvider
);
if (!option) return "openai";
const provider = option.provider.toLowerCase();
const isAggregator = AGGREGATOR_PROVIDERS.has(provider);
if (isAggregator && option.vendor) {
return `${provider}/${option.vendor.toLowerCase()}`;
}
return provider;
}, [
llmOptions,
llmManager.currentLlm.modelName,
llmManager.currentLlm.provider,
]);
// Track expanded groups - initialize with current model's group
const [expandedGroups, setExpandedGroups] = useState<string[]>([
currentGroupKey,
]);
// Reset state when popover closes/opens
useEffect(() => {
if (!open) {
setSearchQuery("");
} else {
// Reset expanded groups to only show the selected model's group
setExpandedGroups([currentGroupKey]);
}
}, [open, currentGroupKey]);
// Auto-scroll to selected model when popover opens
useEffect(() => {
if (open) {
// Small delay to let accordion content render
const timer = setTimeout(() => {
selectedItemRef.current?.scrollIntoView({
behavior: "instant",
block: "center",
});
}, 50);
return () => clearTimeout(timer);
}
}, [open]);
const isSearching = searchQuery.trim().length > 0;
// Compute final expanded groups
const effectiveExpandedGroups = useMemo(() => {
if (isSearching) {
// Force expand all when searching
return groupedOptions.map((g) => g.key);
}
return expandedGroups;
}, [isSearching, groupedOptions, expandedGroups]);
// Handler for accordion changes
const handleAccordionChange = (value: string[]) => {
// Only update state when not searching (force-expanding)
if (!isSearching) {
setExpandedGroups(value);
}
};
const handleSelectModel = (option: LLMOption) => {
llmManager.updateCurrentLlm({
modelName: option.modelName,
provider: option.provider,
name: option.name,
} as LlmDescriptor);
onSelect?.(structureValue(option.name, option.provider, option.modelName));
setOpen(false);
};
const renderModelItem = (option: LLMOption) => {
const isSelected =
option.modelName === llmManager.currentLlm.modelName &&
option.provider === llmManager.currentLlm.provider;
const capabilities: string[] = [];
if (option.supportsReasoning) {
capabilities.push("Reasoning");
}
if (option.supportsImageInput) {
capabilities.push("Vision");
}
const description =
capabilities.length > 0 ? capabilities.join(", ") : undefined;
return (
<div
key={`${option.name}-${option.modelName}`}
ref={isSelected ? selectedItemRef : undefined}
>
<LineItem
selected={isSelected}
description={description}
onClick={() => handleSelectModel(option)}
rightChildren={
isSelected ? (
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
) : null
}
>
{option.displayName}
</LineItem>
const temperatureFooter = user?.preferences?.temperature_override_enabled ? (
<>
<div className="border-t border-border-02 mx-2" />
<div className="flex flex-col w-full py-2 gap-2">
<Slider
value={[localTemperature]}
max={llmManager.maxTemperature}
min={0}
step={0.01}
onValueChange={handleGlobalTemperatureChange}
onValueCommit={handleGlobalTemperatureCommit}
className="w-full"
/>
<div className="flex flex-row items-center justify-between">
<Text secondaryBody text03>
Temperature (creativity)
</Text>
<Text secondaryBody text03>
{localTemperature.toFixed(1)}
</Text>
</div>
</div>
);
};
</>
) : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -375,129 +255,16 @@ export default function LLMPopover({
</div>
<Popover.Content side="top" align="end" width="xl">
<Section gap={0.5}>
{/* Search Input */}
<InputTypeIn
ref={searchInputRef}
leftSearchIcon
variant="internal"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
/>
{/* Model List with Vendor Groups */}
<PopoverMenu scrollContainerRef={scrollContainerRef}>
{isLoadingProviders
? [
<div key="loading" className="flex items-center gap-2 py-3">
<SimpleLoader />
<Text secondaryBody text03>
Loading models...
</Text>
</div>,
]
: groupedOptions.length === 0
? [
<div key="empty" className="py-3">
<Text secondaryBody text03>
No models found
</Text>
</div>,
]
: groupedOptions.length === 1
? // Single provider - show models directly without accordion
[
<div
key="single-provider"
className="flex flex-col gap-1"
>
{groupedOptions[0]!.options.map(renderModelItem)}
</div>,
]
: // Multiple providers - show accordion with groups
[
<Accordion
key="accordion"
type="multiple"
value={effectiveExpandedGroups}
onValueChange={handleAccordionChange}
className="w-full flex flex-col"
>
{groupedOptions.map((group) => {
const isExpanded = effectiveExpandedGroups.includes(
group.key
);
return (
<AccordionItem
key={group.key}
value={group.key}
className="border-none pt-1"
>
{/* Group Header */}
<AccordionTrigger className="flex items-center rounded-08 hover:no-underline hover:bg-background-tint-02 group [&>svg]:hidden w-full py-1">
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center justify-center size-5 shrink-0">
<group.Icon size={16} />
</div>
<Text
secondaryBody
text03
nowrap
className="px-0.5"
>
{group.displayName}
</Text>
</div>
<div className="flex-1" />
<div className="flex items-center justify-center size-6 shrink-0">
{isExpanded ? (
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
) : (
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
)}
</div>
</AccordionTrigger>
{/* Model Items - full width highlight */}
<AccordionContent className="pb-0 pt-0">
<div className="flex flex-col gap-1">
{group.options.map(renderModelItem)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>,
]}
</PopoverMenu>
{/* Global Temperature Slider (shown if enabled in user prefs) */}
{user?.preferences?.temperature_override_enabled && (
<>
<div className="border-t border-border-02 mx-2" />
<div className="flex flex-col w-full py-2 gap-2">
<Slider
value={[localTemperature]}
max={llmManager.maxTemperature}
min={0}
step={0.01}
onValueChange={handleGlobalTemperatureChange}
onValueCommit={handleGlobalTemperatureCommit}
className="w-full"
/>
<div className="flex flex-row items-center justify-between">
<Text secondaryBody text03>
Temperature (creativity)
</Text>
<Text secondaryBody text03>
{localTemperature.toFixed(1)}
</Text>
</div>
</div>
</>
)}
</Section>
<ModelListContent
llmProviders={llmProviders}
currentModelName={currentModelName}
requiresImageInput={requiresImageInput}
isLoading={isLoadingProviders}
onSelect={handleSelectModel}
isSelected={isSelected}
scrollContainerRef={scrollContainerRef}
footer={temperatureFooter}
/>
</Popover.Content>
</Popover>
);

View File

@@ -0,0 +1,200 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import { PopoverMenu } from "@/refresh-components/Popover";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Text } from "@opal/components";
import { SvgCheck, SvgChevronDown, SvgChevronRight } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { LLMOption } from "./interfaces";
import { buildLlmOptions, groupLlmOptions } from "./LLMPopover";
import LineItem from "@/refresh-components/buttons/LineItem";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/refresh-components/Collapsible";
export interface ModelListContentProps {
llmProviders: LLMProviderDescriptor[] | undefined;
currentModelName?: string;
requiresImageInput?: boolean;
onSelect: (option: LLMOption) => void;
isSelected: (option: LLMOption) => boolean;
isDisabled?: (option: LLMOption) => boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
isLoading?: boolean;
footer?: React.ReactNode;
}
export default function ModelListContent({
llmProviders,
currentModelName,
requiresImageInput,
onSelect,
isSelected,
isDisabled,
scrollContainerRef: externalScrollRef,
isLoading,
footer,
}: ModelListContentProps) {
const [searchQuery, setSearchQuery] = useState("");
const internalScrollRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const llmOptions = useMemo(
() => buildLlmOptions(llmProviders, currentModelName),
[llmProviders, currentModelName]
);
const filteredOptions = useMemo(() => {
let result = llmOptions;
if (requiresImageInput) {
result = result.filter((opt) => opt.supportsImageInput);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(opt) =>
opt.displayName.toLowerCase().includes(query) ||
opt.modelName.toLowerCase().includes(query) ||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
);
}
return result;
}, [llmOptions, searchQuery, requiresImageInput]);
const groupedOptions = useMemo(
() => groupLlmOptions(filteredOptions),
[filteredOptions]
);
// Find which group contains a currently-selected model (for auto-expand)
const defaultGroupKey = useMemo(() => {
for (const group of groupedOptions) {
if (group.options.some((opt) => isSelected(opt))) {
return group.key;
}
}
return groupedOptions[0]?.key ?? "";
}, [groupedOptions, isSelected]);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set([defaultGroupKey])
);
// Reset expanded groups when default changes (e.g. popover re-opens)
useEffect(() => {
setExpandedGroups(new Set([defaultGroupKey]));
}, [defaultGroupKey]);
const isSearching = searchQuery.trim().length > 0;
const toggleGroup = (key: string) => {
if (isSearching) return;
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const isGroupOpen = (key: string) => isSearching || expandedGroups.has(key);
const renderModelItem = (option: LLMOption) => {
const selected = isSelected(option);
const disabled = isDisabled?.(option) ?? false;
const capabilities: string[] = [];
if (option.supportsReasoning) capabilities.push("Reasoning");
if (option.supportsImageInput) capabilities.push("Vision");
const description =
capabilities.length > 0 ? capabilities.join(", ") : undefined;
return (
<LineItem
key={`${option.provider}:${option.modelName}`}
selected={selected}
disabled={disabled}
description={description}
onClick={() => onSelect(option)}
rightChildren={
selected ? (
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
) : null
}
>
{option.displayName}
</LineItem>
);
};
return (
<Section gap={0.5}>
<InputTypeIn
leftSearchIcon
variant="internal"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
/>
<PopoverMenu scrollContainerRef={scrollContainerRef}>
{isLoading
? [
<Text key="loading" font="secondary-body" color="text-03">
Loading models...
</Text>,
]
: groupedOptions.length === 0
? [
<Text key="empty" font="secondary-body" color="text-03">
No models found
</Text>,
]
: groupedOptions.length === 1
? [
<Section key="single-provider" gap={0.25}>
{groupedOptions[0]!.options.map(renderModelItem)}
</Section>,
]
: groupedOptions.map((group) => {
const open = isGroupOpen(group.key);
return (
<Collapsible
key={group.key}
open={open}
onOpenChange={() => toggleGroup(group.key)}
>
<CollapsibleTrigger asChild>
<LineItem
muted
icon={group.Icon}
rightChildren={
open ? (
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
) : (
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
)
}
>
{group.displayName}
</LineItem>
</CollapsibleTrigger>
<CollapsibleContent>
<Section gap={0.25}>
{group.options.map(renderModelItem)}
</Section>
</CollapsibleContent>
</Collapsible>
);
})}
</PopoverMenu>
{footer}
</Section>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState, useMemo, useRef } from "react";
import Popover from "@/refresh-components/Popover";
import { LlmManager } from "@/lib/hooks";
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
import { Button, SelectButton, OpenButton } from "@opal/components";
import { SvgPlusCircle, SvgX } from "@opal/icons";
import { LLMOption } from "@/refresh-components/popovers/interfaces";
import ModelListContent from "@/refresh-components/popovers/ModelListContent";
import Separator from "@/refresh-components/Separator";
export const MAX_MODELS = 3;
export interface SelectedModel {
name: string;
provider: string;
modelName: string;
displayName: string;
}
export interface ModelSelectorProps {
llmManager: LlmManager;
selectedModels: SelectedModel[];
onAdd: (model: SelectedModel) => void;
onRemove: (index: number) => void;
onReplace: (index: number, model: SelectedModel) => void;
}
function modelKey(provider: string, modelName: string): string {
return `${provider}:${modelName}`;
}
export default function ModelSelector({
llmManager,
selectedModels,
onAdd,
onRemove,
onReplace,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
// null = add mode (via + button), number = replace mode (via pill click)
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
// Virtual anchor ref — points to the clicked pill so the popover positions above it
const anchorRef = useRef<HTMLElement | null>(null);
const isMultiModel = selectedModels.length > 1;
const atMax = selectedModels.length >= MAX_MODELS;
const selectedKeys = useMemo(
() => new Set(selectedModels.map((m) => modelKey(m.provider, m.modelName))),
[selectedModels]
);
const otherSelectedKeys = useMemo(() => {
if (replacingIndex === null) return new Set<string>();
return new Set(
selectedModels
.filter((_, i) => i !== replacingIndex)
.map((m) => modelKey(m.provider, m.modelName))
);
}, [selectedModels, replacingIndex]);
const replacingKey =
replacingIndex !== null
? (() => {
const m = selectedModels[replacingIndex];
return m ? modelKey(m.provider, m.modelName) : null;
})()
: null;
const isSelected = (option: LLMOption) => {
const key = modelKey(option.provider, option.modelName);
if (replacingIndex !== null) return key === replacingKey;
return selectedKeys.has(key);
};
const isDisabled = (option: LLMOption) => {
const key = modelKey(option.provider, option.modelName);
if (replacingIndex !== null) return otherSelectedKeys.has(key);
return !selectedKeys.has(key) && atMax;
};
const handleSelect = (option: LLMOption) => {
const model: SelectedModel = {
name: option.name,
provider: option.provider,
modelName: option.modelName,
displayName: option.displayName,
};
if (replacingIndex !== null) {
onReplace(replacingIndex, model);
setOpen(false);
setReplacingIndex(null);
return;
}
const key = modelKey(option.provider, option.modelName);
const existingIndex = selectedModels.findIndex(
(m) => modelKey(m.provider, m.modelName) === key
);
if (existingIndex >= 0) {
onRemove(existingIndex);
} else if (!atMax) {
onAdd(model);
}
};
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setReplacingIndex(null);
};
const handlePillClick = (index: number, element: HTMLElement) => {
anchorRef.current = element;
setReplacingIndex(index);
setOpen(true);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<div className="flex items-center justify-end gap-1 p-1">
{!atMax && (
<Button
prominence="tertiary"
icon={SvgPlusCircle}
size="sm"
tooltip="Add Model"
onClick={(e: React.MouseEvent) => {
anchorRef.current = e.currentTarget as HTMLElement;
setReplacingIndex(null);
setOpen(true);
}}
/>
)}
<Popover.Anchor
virtualRef={anchorRef as React.RefObject<HTMLElement>}
/>
{selectedModels.length > 0 && (
<>
{!atMax && (
<Separator
orientation="vertical"
paddingXRem={0.5}
paddingYRem={0.5}
/>
)}
<div className="flex items-center">
{selectedModels.map((model, index) => {
const ProviderIcon = getProviderIcon(
model.provider,
model.modelName
);
if (!isMultiModel) {
return (
<OpenButton
key={modelKey(model.provider, model.modelName)}
icon={ProviderIcon}
onClick={(e: React.MouseEvent) =>
handlePillClick(index, e.currentTarget as HTMLElement)
}
>
{model.displayName}
</OpenButton>
);
}
return (
<div
key={modelKey(model.provider, model.modelName)}
className="flex items-center"
>
{index > 0 && (
<Separator
orientation="vertical"
paddingXRem={0.5}
className="h-5"
/>
)}
<SelectButton
icon={ProviderIcon}
rightIcon={SvgX}
state="empty"
variant="select-tinted"
interaction="hover"
size="lg"
onClick={(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const btn = e.currentTarget as HTMLElement;
const icons = btn.querySelectorAll(
".interactive-foreground-icon"
);
const lastIcon = icons[icons.length - 1];
if (lastIcon && lastIcon.contains(target)) {
onRemove(index);
} else {
handlePillClick(index, btn);
}
}}
>
{model.displayName}
</SelectButton>
</div>
);
})}
</div>
</>
)}
</div>
<Popover.Content
side="top"
align="start"
width="lg"
avoidCollisions={false}
>
<ModelListContent
llmProviders={llmManager.llmProviders}
isLoading={llmManager.isLoadingProviders}
onSelect={handleSelect}
isSelected={isSelected}
isDisabled={isDisabled}
/>
</Popover.Content>
</Popover>
);
}