Files
openchamber/packages/ui/src/components/multirun/ModelMultiSelect.tsx
T
Bohdan Triapitsyn 74511abfda Multi-account GitHub auth + UI polish (model logos, markdown, scroll behavior) (#219)
* feat: display provider logos for favorite/recent models

Show provider logo next to model name in favorites and recents
Render provider logos in ModelControls, ModelMultiSelect, and ModelSelector lists
Maintain zero-logo state for other sections to avoid clutter

* feat: render user message as markdown instead of plain text

Render agent mentions as markdown links in user text
Apply inside list style for chat content to fix list rendering
Rely on SimpleMarkdownRenderer for consistent rendering

* fix(openchamber): adjust layout and overscroll behavior

Enable overscroll-auto on overlay containers for smoother scrolling
Move page content to full-width wrapper and preserve section borders
Show AboutSettings inside its own bordered block when visible

* feat: integrate GitHub auth status store and UI

Introduce GitHubAuthStore to track connection status and polling
Show GitHub avatar in header when connected
Guard issue/pr dialogs behind GitHub auth status and show notices

* feat: add GitHub multi-account support

Add API and UI flow to activate a GitHub account
Show and switch between multiple GitHub accounts in header
Persist and normalize accounts list with current selection
2026-01-26 11:28:55 +02:00

547 lines
21 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 React from 'react';
import { RiAddLine, RiBrainAi3Line, RiCloseLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { cn } from '@/lib/utils';
import { isIMECompositionEvent } from '@/lib/ime';
import { useConfigStore } from '@/stores/useConfigStore';
import { useModelLists } from '@/hooks/useModelLists';
import type { ModelMetadata } from '@/types';
/** Chip height class - shared between chips and add button */
const CHIP_HEIGHT_CLASS = 'h-7';
/** UI-only type with instanceId for React keys and duplicate tracking */
export interface ModelSelectionWithId {
providerID: string;
modelID: string;
displayName?: string;
variant?: string;
instanceId: string;
}
/** Model selection without instanceId (for external use) */
export interface ModelSelection {
providerID: string;
modelID: string;
displayName?: string;
variant?: string;
}
// eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect
export const generateInstanceId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
minimumFractionDigits: 0,
});
const formatTokens = (value?: number | null) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
if (value === 0) {
return '0';
}
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
};
/**
* Model selection chip with remove button.
* Shows instance index (e.g., "(2)") when same model is selected multiple times.
*/
export const ModelChip: React.FC<{
model: ModelSelectionWithId;
instanceIndex: number;
totalSameModel: number;
onRemove: () => void;
}> = ({ model, instanceIndex, totalSameModel, onRemove }) => {
const displayName = model.displayName || `${model.providerID}/${model.modelID}`;
const label = totalSameModel > 1 ? `${displayName} (${instanceIndex})` : displayName;
return (
<div className={cn('flex items-center gap-1.5 px-2 rounded-md bg-accent/50 border border-border/30', CHIP_HEIGHT_CLASS)}>
<ProviderLogo providerId={model.providerID} className="h-3.5 w-3.5" />
<span className="typography-meta font-medium truncate max-w-[140px]">
{label}
</span>
<button
type="button"
onClick={onRemove}
className="text-muted-foreground hover:text-foreground ml-0.5"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
);
};
export interface ModelMultiSelectProps {
selectedModels: ModelSelectionWithId[];
onAdd: (model: ModelSelectionWithId) => void;
onRemove: (index: number) => void;
onUpdate?: (index: number, model: ModelSelectionWithId) => void;
/** Minimum models required (shows validation hint) */
minModels?: number;
/** Label for the add button */
addButtonLabel?: string;
/** Whether to show the selected chips */
showChips?: boolean;
/** Maximum models allowed */
maxModels?: number;
}
/**
* Model selector for multi-run (allows selecting same model multiple times).
*/
export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
selectedModels,
onAdd,
onRemove,
onUpdate,
minModels,
addButtonLabel = 'Add model',
showChips = true,
maxModels,
}) => {
const { providers, modelsMetadata } = useConfigStore();
const { favoriteModelsList, recentModelsList } = useModelLists();
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [availableHeight, setAvailableHeight] = React.useState<number | null>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
// Count occurrences of each model for display purposes
const modelCounts = React.useMemo(() => {
const counts = new Map<string, number>();
for (const m of selectedModels) {
const key = `${m.providerID}:${m.modelID}`;
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
}, [selectedModels]);
// Get instance index for a specific model selection
const getInstanceIndex = React.useCallback((model: ModelSelectionWithId): number => {
const sameModels = selectedModels.filter(
m => m.providerID === model.providerID && m.modelID === model.modelID
);
return sameModels.findIndex(m => m.instanceId === model.instanceId) + 1;
}, [selectedModels]);
const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => {
const key = `${provId}/${modId}`;
return modelsMetadata.get(key);
};
const getModelDisplayName = (model: Record<string, unknown>) => {
const name = model?.name || model?.id || '';
const nameStr = String(name);
if (nameStr.length > 40) {
return nameStr.substring(0, 37) + '...';
}
return nameStr;
};
// Filter helper
const filterByQuery = React.useCallback((modelName: string, providerName: string) => {
if (!searchQuery.trim()) return true;
const lowerQuery = searchQuery.toLowerCase();
return (
modelName.toLowerCase().includes(lowerQuery) ||
providerName.toLowerCase().includes(lowerQuery)
);
}, [searchQuery]);
// Filter favorites
const filteredFavorites = React.useMemo(() => {
return favoriteModelsList.filter(({ model, providerID }) => {
const provider = providers.find(p => p.id === providerID);
const providerName = provider?.name || providerID;
const modelName = getModelDisplayName(model);
return filterByQuery(modelName, providerName);
});
}, [favoriteModelsList, providers, filterByQuery]);
// Filter recents
const filteredRecents = React.useMemo(() => {
return recentModelsList.filter(({ model, providerID }) => {
const provider = providers.find(p => p.id === providerID);
const providerName = provider?.name || providerID;
const modelName = getModelDisplayName(model);
return filterByQuery(modelName, providerName);
});
}, [recentModelsList, providers, filterByQuery]);
// Filter providers
const filteredProviders = React.useMemo(() => {
return providers
.map((provider) => {
const models = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = models.filter((model) => {
const modelName = getModelDisplayName(model);
return filterByQuery(modelName, provider.name || provider.id || '');
});
return { ...provider, models: filteredModels };
})
.filter((provider) => provider.models.length > 0);
}, [providers, filterByQuery]);
const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0;
// Calculate available height when dropdown opens
React.useEffect(() => {
if (isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
// Space above trigger minus padding from top edge
const spaceAbove = rect.top - 100;
// Cap at 400px max, minimum 150px
setAvailableHeight(Math.max(150, Math.min(400, spaceAbove)));
}
}, [isOpen]);
// Focus search input when opened
React.useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
// Close dropdown when clicking outside
React.useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchQuery('');
setSelectedIndex(0);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Reset selection when search query changes
React.useEffect(() => {
setSelectedIndex(0);
}, [searchQuery]);
// Render a model row
const renderModelRow = (
model: Record<string, unknown>,
providerID: string,
modelID: string,
keyPrefix: string,
flatIndex: number,
isHighlighted: boolean
) => {
const key = `${providerID}:${modelID}`;
const selectionCount = modelCounts.get(key) || 0;
const metadata = getModelMetadata(providerID, modelID);
const contextTokens = formatTokens(metadata?.limit?.context);
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
return (
<button
key={`${keyPrefix}-${key}`}
ref={(el) => { itemRefs.current[flatIndex] = el; }}
type="button"
onClick={() => {
onAdd({
providerID,
modelID,
displayName: (model.name as string) || modelID,
instanceId: generateInstanceId(),
});
// Don't close dropdown - allow selecting multiple
}}
onMouseEnter={() => setSelectedIndex(flatIndex)}
className={cn(
'w-full text-left px-2 py-1.5 rounded-md typography-meta transition-colors flex items-center gap-2',
isHighlighted ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{showProviderLogo && (
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
{contextTokens && (
<span className="typography-micro text-muted-foreground flex-shrink-0">
{contextTokens}
</span>
)}
</div>
{selectionCount > 0 && (
<span className="typography-micro text-muted-foreground flex-shrink-0">
×{selectionCount}
</span>
)}
</button>
);
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5 items-center">
{/* Add model button (dropdown trigger) */}
<div className="relative" ref={dropdownRef}>
<Button
ref={triggerRef}
type="button"
variant="outline"
size="sm"
className={CHIP_HEIGHT_CLASS}
onClick={() => setIsOpen(!isOpen)}
>
<RiAddLine className="h-3.5 w-3.5 mr-1" />
{addButtonLabel}
</Button>
{isOpen && (() => {
// Build flat list for keyboard navigation
type FlatModelItem = { model: Record<string, unknown>; providerID: string; modelID: string; section: string };
const flatModelList: FlatModelItem[] = [];
filteredFavorites.forEach(({ model, providerID, modelID }) => {
flatModelList.push({ model, providerID, modelID, section: 'fav' });
});
filteredRecents.forEach(({ model, providerID, modelID }) => {
flatModelList.push({ model, providerID, modelID, section: 'recent' });
});
filteredProviders.forEach((provider) => {
provider.models.forEach((model) => {
flatModelList.push({ model, providerID: provider.id, modelID: model.id as string, section: 'provider' });
});
});
const totalItems = flatModelList.length;
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (isIMECompositionEvent(e)) {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems);
setSelectedIndex(nextIndex);
setTimeout(() => {
itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems);
setSelectedIndex(prevIndex);
setTimeout(() => {
itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const selectedItem = flatModelList[selectedIndex];
if (selectedItem) {
onAdd({
providerID: selectedItem.providerID,
modelID: selectedItem.modelID,
displayName: (selectedItem.model.name as string) || selectedItem.modelID,
instanceId: generateInstanceId(),
});
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setIsOpen(false);
setSearchQuery('');
setSelectedIndex(0);
}
};
let currentFlatIndex = 0;
return (
<div className="absolute bottom-full left-0 mb-1 z-50 border border-border/30 rounded-xl overflow-hidden bg-background shadow-lg w-[min(380px,calc(100vw-2rem))] flex flex-col">
{/* Search input */}
<div className="p-2 border-b border-border/40">
<div className="relative">
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search models"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 pl-8 typography-meta"
/>
</div>
</div>
{/* Models list */}
<ScrollableOverlay
outerClassName="flex-1"
style={{ maxHeight: availableHeight ? `${availableHeight}px` : '300px' }}
>
<div className="p-1">
{!hasResults && (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No models found
</div>
)}
{/* Favorites Section */}
{filteredFavorites.length > 0 && (
<>
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
<RiStarFill className="h-4 w-4 text-primary" />
Favorites
</div>
{filteredFavorites.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx);
})}
</>
)}
{/* Recents Section */}
{filteredRecents.length > 0 && (
<>
{filteredFavorites.length > 0 && <div className="h-px bg-border/40 my-1" />}
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
<RiTimeLine className="h-4 w-4" />
Recent
</div>
{filteredRecents.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx);
})}
</>
)}
{/* Separator before providers */}
{(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && (
<div className="h-px bg-border/40 my-1" />
)}
{/* All Providers - Flat List */}
{filteredProviders.map((provider, index) => (
<React.Fragment key={provider.id}>
{index > 0 && <div className="h-px bg-border/40 my-1" />}
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
<ProviderLogo
providerId={provider.id}
className="h-4 w-4 flex-shrink-0"
/>
{provider.name}
</div>
{provider.models.map((model) => {
const idx = currentFlatIndex++;
return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx);
})}
</React.Fragment>
))}
</div>
</ScrollableOverlay>
{/* Keyboard hints footer */}
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
↑↓ navigate Enter select Esc close
</div>
</div>
);
})()}
</div>
{/* Selected models */}
{showChips && selectedModels.length > 0 && (
<div className="flex flex-col gap-2 w-full">
{selectedModels.map((model, index) => {
const key = `${model.providerID}:${model.modelID}`;
const totalSameModel = modelCounts.get(key) || 1;
const instanceIndex = getInstanceIndex(model);
const provider = providers.find((p) => p.id === model.providerID);
const providerModel = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === model.modelID) as
| { variants?: Record<string, unknown> }
| undefined;
const variantKeys = providerModel?.variants ? Object.keys(providerModel.variants) : [];
const hasVariants = variantKeys.length > 0;
const DEFAULT_VARIANT_VALUE = '__default__';
const variantValue = model.variant ?? DEFAULT_VARIANT_VALUE;
return (
<div key={model.instanceId} className="flex items-center gap-2 min-w-0">
<ModelChip
model={model}
instanceIndex={instanceIndex}
totalSameModel={totalSameModel}
onRemove={() => onRemove(index)}
/>
{hasVariants && (
<Select
value={variantValue}
onValueChange={(value) => {
if (!onUpdate) return;
const nextVariant = value === DEFAULT_VARIANT_VALUE ? undefined : value;
onUpdate(index, { ...model, variant: nextVariant });
}}
>
<SelectTrigger size="chip" className="px-2 gap-1.5 rounded-md bg-accent/50 border-border/30 hover:bg-accent/60 typography-meta font-medium text-foreground">
<RiBrainAi3Line
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
variantValue === DEFAULT_VARIANT_VALUE ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'
)}
/>
<SelectValue placeholder="Thinking" />
</SelectTrigger>
<SelectContent fitContent>
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">
Default
</SelectItem>
{variantKeys.map((variant) => (
<SelectItem key={variant} value={variant} className="pr-2 [&>span:first-child]:hidden">
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
})}
</div>
)}
</div>
{/* Validation hint */}
{minModels !== undefined && selectedModels.length < minModels && (
<p className="typography-micro text-muted-foreground">
Select from {minModels} {maxModels !== undefined ? `to ${maxModels} models` : ''}.
</p>
)}
</div>
);
};