Files
fusion/packages/dashboard/app/components/CustomModelDropdown.tsx
gsxdsm cca4bde3a0 feat(FN-681): add multi-tab terminal support
- Implemented multi-tab support in TerminalModal component
- Each tab maintains independent WebSocket connection and xterm instance
- Tab management: create, switch, close tabs with keyboard shortcuts (Ctrl+Tab)
- Added Plus button to create new tabs
- Added close button on tabs (except last remaining tab)
- Updated keyboard shortcuts text in status bar
- Connection status indicator per tab
- Per-tab scrollback buffers preserved when switching tabs
- Auto-cleanup of all sessions when modal closes
- Added comprehensive tests for multi-tab functionality
2026-04-02 09:48:53 -07:00

553 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import type { ModelInfo } from "../api";
import { filterModels } from "../utils/modelFilter";
import { ProviderIcon } from "./ProviderIcon";
export interface CustomModelDropdownProps {
models: ModelInfo[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
id?: string;
label: string;
/** List of favorite provider names in preferred order */
favoriteProviders?: string[];
/** Called when user toggles a provider's favorite status */
onToggleFavorite?: (provider: string) => void;
/** List of favorited model identifiers in format "{provider}/{modelId}" */
favoriteModels?: string[];
/** Called when user toggles a model's favorite status */
onToggleModelFavorite?: (modelId: string) => void;
}
interface DropdownPosition {
top: number;
left: number;
width: number;
}
/**
* CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups.
*
* Interaction pattern:
* - Closed: Shows trigger button with current selection and provider icon
* - Open: Dropdown with search input at top, scrollable list of models grouped by provider with icons
* - Filtering: Real-time filtering using filterModels() utility
* - Keyboard: Arrow keys navigate, Enter selects, Escape closes, Tab moves focus
*
* The dropdown listbox is rendered in a portal so it can escape clipping/stacking
* contexts created by scrollable modal or board containers while still anchoring to
* the trigger button.
*/
export function CustomModelDropdown({
models,
value,
onChange,
placeholder = "Select a model…",
disabled = false,
id,
label,
favoriteProviders = [],
onToggleFavorite,
favoriteModels = [],
onToggleModelFavorite,
}: CustomModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Filter models based on local filter text
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
// Group filtered models by provider and sort by favorites
const modelsByProvider = useMemo(() => {
return filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
}, [filteredModels]);
// Build favorited model entries - models that are in the favoriteModels list and in filteredModels
const favoritedModelEntries = useMemo(() => {
const result: Array<{ model: ModelInfo; fullId: string }> = [];
for (const fullId of favoriteModels) {
const slashIdx = fullId.indexOf("/");
if (slashIdx === -1) continue;
const provider = fullId.slice(0, slashIdx);
const modelId = fullId.slice(slashIdx + 1);
const model = filteredModels.find((m) => m.provider === provider && m.id === modelId);
if (model) {
result.push({ model, fullId });
}
}
return result;
}, [favoriteModels, filteredModels]);
// Sort providers: favorites first (in order), then alphabetically
// Exclude providers that are already favorited as models (they appear at top as pinned rows)
const favoritedProviderSet = new Set(favoriteModels.map((fullId) => {
const idx = fullId.indexOf("/");
return idx !== -1 ? fullId.slice(0, idx) : fullId;
}));
const sortedProviderEntries = useMemo(() => {
const entries = Object.entries(modelsByProvider);
const favoritesSet = new Set(favoriteProviders);
return entries.sort(([a], [b]) => {
const aFavorite = favoritesSet.has(a);
const bFavorite = favoritesSet.has(b);
if (aFavorite && !bFavorite) return -1;
if (!aFavorite && bFavorite) return 1;
// Both favorites: sort by favoriteProviders order
if (aFavorite && bFavorite) {
const aIdx = favoriteProviders.indexOf(a);
const bIdx = favoriteProviders.indexOf(b);
if (aIdx !== bIdx) return aIdx - bIdx;
}
// Neither favorite: alphabetical
return a.localeCompare(b);
});
}, [modelsByProvider, favoriteProviders]);
// Get current provider from value
const currentProvider = useMemo(() => {
if (!value) return null;
const slashIdx = value.indexOf("/");
return slashIdx === -1 ? null : value.slice(0, slashIdx);
}, [value]);
// Build list of all selectable options (for keyboard navigation)
// Includes favorited models first (as pinned rows), then provider groups
const optionsList = useMemo(() => {
const options: Array<{ type: "default" | "provider" | "model" | "favorite"; value: string; label: string; provider?: string }> = [
{ type: "default", value: "", label: "Use default" },
];
// Add favorited models as pinned rows first
for (const { model, fullId } of favoritedModelEntries) {
options.push({
type: "favorite",
value: fullId,
label: model.name,
provider: model.provider,
});
}
sortedProviderEntries.forEach(([provider, providerModels]) => {
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
providerModels.forEach((m) => {
options.push({
type: "model",
value: `${m.provider}/${m.id}`,
label: m.name,
provider: m.provider,
});
});
});
return options;
}, [favoritedModelEntries, sortedProviderEntries]);
// Get current selection display text
const selectedDisplayText = useMemo(() => {
if (!value) return "Use default";
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
const modelId = value.slice(slashIdx + 1);
const model = models.find((m) => m.provider === provider && m.id === modelId);
return model?.name || value;
}, [value, models]);
// Find index of current value in options list
const currentValueIndex = useMemo(() => {
return optionsList.findIndex((opt) => opt.value === value);
}, [optionsList, value]);
// Estimated max height for dropdown (desktop default: 320px)
// Mobile uses 60-70vh via CSS, but we use 320px as the safe default estimate
const ESTIMATED_DROPDOWN_HEIGHT = 320;
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate space below and above the trigger
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
// Determine if we should open upward
// Open upward if: not enough space below AND enough space above
const openUpward = spaceBelow < ESTIMATED_DROPDOWN_HEIGHT && spaceAbove >= ESTIMATED_DROPDOWN_HEIGHT;
setDropdownPosition({
top: openUpward ? rect.top - ESTIMATED_DROPDOWN_HEIGHT - 4 : rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}, []);
useEffect(() => {
setPortalRoot(document.body);
}, []);
// Reset highlighted index when opening
useEffect(() => {
if (isOpen) {
const selectableIndex = optionsList.findIndex(
(opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
);
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
}
}, [isOpen, optionsList, currentValueIndex]);
// Focus search input and position dropdown when opening
useEffect(() => {
if (!isOpen) return;
updateDropdownPosition();
const rafId = requestAnimationFrame(() => searchInputRef.current?.focus());
return () => cancelAnimationFrame(rafId);
}, [isOpen, updateDropdownPosition]);
// Keep portaled dropdown anchored during viewport and container scrolling.
useEffect(() => {
if (!isOpen) return;
const handleReposition = () => updateDropdownPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen, updateDropdownPosition]);
// Click outside to close, treating both trigger container and portaled menu as inside.
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (e: MouseEvent) => {
const target = e.target as Node;
const clickedInsideTrigger = containerRef.current?.contains(target);
const clickedInsideDropdown = dropdownRef.current?.contains(target);
if (!clickedInsideTrigger && !clickedInsideDropdown) {
setIsOpen(false);
setLocalFilter("");
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [isOpen]);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
let nextIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex + i) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
nextIndex = idx;
break;
}
}
setHighlightedIndex(nextIndex);
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
let prevIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
prevIndex = idx;
break;
}
}
setHighlightedIndex(prevIndex);
}
break;
case "Enter":
e.preventDefault();
if (isOpen) {
const option = optionsList[highlightedIndex];
if (option && option.type !== "provider" && option.type !== "favorite") {
onChange(option.value);
setIsOpen(false);
setLocalFilter("");
}
} else {
setIsOpen(true);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setLocalFilter("");
break;
case "Tab":
if (isOpen) {
setIsOpen(false);
setLocalFilter("");
}
break;
}
},
[isOpen, highlightedIndex, optionsList, onChange]
);
const handleSelect = useCallback(
(optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
setLocalFilter("");
},
[onChange]
);
const handleClearFilter = useCallback(() => {
setLocalFilter("");
searchInputRef.current?.focus();
}, []);
const handleTriggerClick = useCallback(() => {
if (!disabled) {
setIsOpen((prev) => !prev);
}
}, [disabled]);
// Scroll highlighted option into view
useEffect(() => {
if (isOpen && listRef.current) {
const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") {
highlightedEl.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex, isOpen]);
const hasFilter = localFilter.length > 0;
const dropdownContent = isOpen && dropdownPosition ? (
<div
ref={dropdownRef}
className="model-combobox-dropdown model-combobox-dropdown--portal"
role="listbox"
data-testid="model-combobox-portal"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
}}
>
<div className="model-combobox-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{hasFilter && (
<button
type="button"
className="model-combobox-clear"
onClick={handleClearFilter}
aria-label="Clear filter"
>
×
</button>
)}
</div>
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</div>
<div ref={listRef} className="model-combobox-list">
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{/* Favorited models as pinned rows */}
{favoritedModelEntries.length > 0 && (
<>
<div className="model-combobox-divider" />
{favoritedModelEntries.map(({ model, fullId }, idx) => {
const optionIndex = idx + 1; // +1 for "Use default" at index 0
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === fullId;
return (
<div
key={fullId}
data-index={optionIndex}
className={`model-combobox-option model-combobox-option--favorite ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(fullId)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<ProviderIcon provider={model.provider} size="sm" />
<span className="model-combobox-option-text">{model.name}</span>
<span className="model-combobox-option-id">{model.id}</span>
{onToggleModelFavorite && (
<button
type="button"
className="model-combobox-option-favorite model-combobox-option-favorite--active"
onClick={(e) => {
e.stopPropagation();
onToggleModelFavorite(fullId);
}}
title="Remove from favorites"
aria-label={`Remove ${model.name} from favorites`}
>
</button>
)}
</div>
);
})}
<div className="model-combobox-divider" />
</>
)}
{sortedProviderEntries.map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
const isFavorite = favoriteProviders.includes(provider);
return (
<div key={provider} className="model-combobox-group">
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
<ProviderIcon provider={provider} size="sm" />
<span className="model-combobox-optgroup-text">{provider}</span>
{onToggleFavorite && (
<button
type="button"
className={`model-combobox-optgroup-favorite ${isFavorite ? "model-combobox-optgroup-favorite--active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(provider);
}}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? `Remove ${provider} from favorites` : `Add ${provider} to favorites`}
>
</button>
)}
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
const isFavorited = favoriteModels.includes(optionValue);
return (
<div
key={optionValue}
data-index={optionIndex}
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(optionValue)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
{onToggleModelFavorite && (
<button
type="button"
className={`model-combobox-option-favorite ${isFavorited ? "model-combobox-option-favorite--active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggleModelFavorite(optionValue);
}}
title={isFavorited ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorited ? `Remove ${m.name} from favorites` : `Add ${m.name} to favorites`}
>
{isFavorited ? "★" : "☆"}
</button>
)}
</div>
);
})}
</div>
);
})}
{filteredModels.length === 0 && hasFilter && (
<div className="model-combobox-no-results">No models match &apos;{localFilter}&apos;</div>
)}
</div>
</div>
) : null;
return (
<>
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
id={id}
className="model-combobox-trigger"
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label}
>
{currentProvider && (
<span className="model-combobox-trigger-icon">
<ProviderIcon provider={currentProvider} size="sm" />
</span>
)}
<span className="model-combobox-trigger-text">{selectedDisplayText || placeholder}</span>
<span className="model-combobox-trigger-arrow"></span>
</button>
</div>
{portalRoot && dropdownContent ? createPortal(dropdownContent, portalRoot) : null}
</>
);
}