Files
openchamber/packages/ui/src/components/chat/ModelControls.tsx
T
Bohdan Triapitsyn efe9ad27b7 feat: add text truncation hook and marquee support (#217)
Introduce a hook to detect text truncation for dynamic UI updates
Replace static marquee spans with a reusable marquee component in files and labels
Enable copying of terminal selection on mouse up and touch end
2026-01-26 00:24:40 +02:00

2715 lines
134 KiB
TypeScript

import React from 'react';
import type { ComponentType } from 'react';
import {
RiAiAgentLine,
RiArrowDownSLine,
RiArrowRightSLine,
RiBrainAi3Line,
RiCheckLine,
RiCheckboxCircleLine,
RiCloseCircleLine,
RiFileImageLine,
RiFileMusicLine,
RiFilePdfLine,
RiFileVideoLine,
RiPencilAiLine,
RiQuestionLine,
RiSearchLine,
RiStarFill,
RiStarLine,
RiText,
RiTimeLine,
RiToolsLine,
} from '@remixicon/react';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import type { ModelMetadata } from '@/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { getAgentColor } from '@/lib/agentColors';
import { useDeviceInfo } from '@/lib/device';
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
type PermissionAction = 'allow' | 'ask' | 'deny';
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) {
return null;
}
const rules: PermissionRule[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const candidate = entry as Partial<PermissionRule>;
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
continue;
}
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
continue;
}
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
}
return rules;
};
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
for (let i = rules.length - 1; i >= 0; i -= 1) {
const rule = rules[i];
if (rule.permission === permission && rule.pattern === '*') {
return rule.action;
}
}
for (let i = rules.length - 1; i >= 0; i -= 1) {
const rule = rules[i];
if (rule.permission === '*' && rule.pattern === '*') {
return rule.action;
}
}
return undefined;
};
const buildPermissionActionMap = (ruleset: unknown, permission: string): Record<string, PermissionAction | undefined> | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
const map: Record<string, PermissionAction | undefined> = {};
for (const rule of rules) {
if (rule.permission !== permission) {
continue;
}
map[rule.pattern] = rule.action;
}
return Object.keys(map).length > 0 ? map : undefined;
};
interface CapabilityDefinition {
key: 'tool_call' | 'reasoning';
icon: IconComponent;
label: string;
isActive: (metadata?: ModelMetadata) => boolean;
}
const CAPABILITY_DEFINITIONS: CapabilityDefinition[] = [
{
key: 'tool_call',
icon: RiToolsLine,
label: 'Tool calling',
isActive: (metadata) => metadata?.tool_call === true,
},
{
key: 'reasoning',
icon: RiBrainAi3Line,
label: 'Reasoning',
isActive: (metadata) => metadata?.reasoning === true,
},
];
interface ModalityIconDefinition {
icon: IconComponent;
label: string;
}
type ModalityIcon = {
key: string;
icon: IconComponent;
label: string;
};
type ModelApplyResult = 'applied' | 'provider-missing' | 'model-missing';
const MODALITY_ICON_MAP: Record<string, ModalityIconDefinition> = {
text: { icon: RiText, label: 'Text' },
image: { icon: RiFileImageLine, label: 'Image' },
video: { icon: RiFileVideoLine, label: 'Video' },
audio: { icon: RiFileMusicLine, label: 'Audio' },
pdf: { icon: RiFilePdfLine, label: 'PDF' },
};
const normalizeModality = (value: string) => value.trim().toLowerCase();
const getModalityIcons = (metadata: ModelMetadata | undefined, direction: 'input' | 'output'): ModalityIcon[] => {
const modalityList = direction === 'input' ? metadata?.modalities?.input : metadata?.modalities?.output;
if (!Array.isArray(modalityList) || modalityList.length === 0) {
return [];
}
const uniqueValues = Array.from(new Set(modalityList.map((item) => normalizeModality(item))));
return uniqueValues
.map((modality) => {
const definition = MODALITY_ICON_MAP[modality];
if (!definition) {
return null;
}
return {
key: modality,
icon: definition.icon,
label: definition.label,
} satisfies ModalityIcon;
})
.filter((entry): entry is ModalityIcon => Boolean(entry));
};
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
minimumFractionDigits: 0,
});
const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 4,
minimumFractionDigits: 2,
});
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;
};
const formatCost = (value?: number | null) => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '—';
}
return CURRENCY_FORMATTER.format(value);
};
const getCapabilityIcons = (metadata?: ModelMetadata) => {
return CAPABILITY_DEFINITIONS.filter((definition) => definition.isActive(metadata)).map((definition) => ({
key: definition.key,
icon: definition.icon,
label: definition.label,
}));
};
const formatKnowledge = (knowledge?: string) => {
if (!knowledge) {
return '—';
}
const match = knowledge.match(/^(\d{4})-(\d{2})$/);
if (match) {
const year = Number.parseInt(match[1], 10);
const monthIndex = Number.parseInt(match[2], 10) - 1;
const knowledgeDate = new Date(Date.UTC(year, monthIndex, 1));
if (!Number.isNaN(knowledgeDate.getTime())) {
return new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }).format(knowledgeDate);
}
}
return knowledge;
};
const formatDate = (value?: string) => {
if (!value) {
return '—';
}
const parsedDate = new Date(value);
if (Number.isNaN(parsedDate.getTime())) {
return value;
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(parsedDate);
};
interface ModelControlsProps {
className?: string;
}
export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const {
providers,
currentProviderId,
currentModelId,
currentVariant,
currentAgentName,
settingsDefaultVariant,
setProvider,
setModel,
setCurrentVariant,
getCurrentModelVariants,
setAgent,
getCurrentProvider,
getModelMetadata,
getCurrentAgent,
getVisibleAgents,
} = useConfigStore();
// Use visible agents (excludes hidden internal agents)
const agents = getVisibleAgents();
const {
currentSessionId,
messages,
saveSessionAgentSelection,
saveAgentModelForSession,
getAgentModelForSession,
saveAgentModelVariantForSession,
getAgentModelVariantForSession,
analyzeAndSaveExternalSessionChoices,
getSessionAgentEditMode,
setSessionAgentEditMode,
} = useSessionStore();
const contextHydrated = useContextStore((state) => state.hasHydrated);
const sessionSavedAgentName = useContextStore((state) =>
currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null
);
const stickySessionAgentRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!currentSessionId) {
stickySessionAgentRef.current = null;
return;
}
if (sessionSavedAgentName) {
stickySessionAgentRef.current = sessionSavedAgentName;
}
}, [currentSessionId, sessionSavedAgentName]);
const stickySessionAgentName = currentSessionId ? stickySessionAgentRef.current : null;
// Prefer per-session selection over global config to avoid flicker during server-driven mode switches.
const uiAgentName = currentSessionId
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
: currentAgentName;
const { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore();
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile } = useDeviceInfo();
const isDesktopRuntime = useIsDesktopRuntime();
const isVSCodeRuntime = useIsVSCodeRuntime();
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
const isCompact = isMobile;
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | 'variant' | null>(null);
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
const [mobileModelQuery, setMobileModelQuery] = React.useState('');
const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []);
const closeMobileTooltip = React.useCallback(() => setMobileTooltipOpen(null), []);
const longPressTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const [expandedMobileProviders, setExpandedMobileProviders] = React.useState<Set<string>>(() => {
const initial = new Set<string>();
if (currentProviderId) {
initial.add(currentProviderId);
}
return initial;
});
const [mobileEditOptionsOpen, setMobileEditOptionsOpen] = React.useState(false);
// Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen;
const [desktopEditOptionsOpen, setDesktopEditOptionsOpen] = React.useState(false);
const desktopEditOptionsId = React.useId();
const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
React.useEffect(() => {
if (activeMobilePanel === 'model') {
setExpandedMobileProviders(() => {
const initial = new Set<string>();
if (currentProviderId) {
initial.add(currentProviderId);
}
return initial;
});
}
}, [activeMobilePanel, currentProviderId]);
React.useEffect(() => {
if (activeMobilePanel !== 'agent') {
setMobileEditOptionsOpen(false);
}
if (activeMobilePanel !== 'model') {
setMobileModelQuery('');
}
}, [activeMobilePanel]);
const prevAgentMenuOpenRef = React.useRef(agentMenuOpen);
React.useEffect(() => {
const wasOpen = prevAgentMenuOpenRef.current;
prevAgentMenuOpenRef.current = agentMenuOpen;
if (!agentMenuOpen) {
setDesktopEditOptionsOpen(false);
setDesktopModelQuery('');
setModelSelectedIndex(0);
// Restore focus to chat input when model selector closes
if (wasOpen && !isCompact) {
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
}
}
}, [agentMenuOpen, isCompact]);
// Reset selected index when search query changes
React.useEffect(() => {
setModelSelectedIndex(0);
}, [desktopModelQuery]);
const currentAgent = getCurrentAgent?.();
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
if (!currentAgent) {
return 'deny';
}
const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask';
return action;
}, [currentAgent]);
const agentWebfetchPermission = React.useMemo(() => {
if (!currentAgent) {
return undefined;
}
return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch');
}, [currentAgent]);
const agentBashPermission = React.useMemo<BashPermissionSetting | undefined>(() => {
if (!currentAgent) {
return undefined;
}
const map = buildPermissionActionMap(currentAgent.permission, 'bash');
return map ? (map as BashPermissionSetting) : undefined;
}, [currentAgent]);
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
agentDefaultEditMode,
webfetchPermission: agentWebfetchPermission,
bashPermission: agentBashPermission,
}), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]);
const { cascadeDefaultMode, modeAvailability, autoApproveAvailable } = permissionUiState;
const selectionContextReady = Boolean(currentSessionId && uiAgentName);
const sessionMode = selectionContextReady && currentSessionId && uiAgentName
? getSessionAgentEditMode(currentSessionId, uiAgentName, cascadeDefaultMode)
: cascadeDefaultMode;
const editModeShortLabels: Record<EditPermissionMode, string> = {
ask: 'Ask before edits',
allow: 'Approve edit tools',
full: 'Approve every tool',
deny: 'Editing disabled',
};
const isModeDisabled = React.useCallback((mode: EditPermissionMode) => {
return !modeAvailability[mode];
}, [modeAvailability]);
const effectiveEditMode = React.useMemo(() => {
if (!selectionContextReady) {
return cascadeDefaultMode;
}
if (isModeDisabled(sessionMode) && sessionMode !== cascadeDefaultMode) {
return cascadeDefaultMode;
}
return sessionMode;
}, [cascadeDefaultMode, isModeDisabled, selectionContextReady, sessionMode]);
const editPermissionOptions: Array<{ mode: EditPermissionMode; label: string; disabled: boolean }> = [
{ mode: 'ask', label: editModeShortLabels.ask, disabled: isModeDisabled('ask') },
{ mode: 'allow', label: editModeShortLabels.allow, disabled: isModeDisabled('allow') },
{ mode: 'full', label: editModeShortLabels.full, disabled: isModeDisabled('full') },
];
const activeEditModeColors = React.useMemo(() => getEditModeColors(effectiveEditMode), [effectiveEditMode]);
const editToggleDisabled = !selectionContextReady || !autoApproveAvailable;
React.useEffect(() => {
if (editToggleDisabled) {
setMobileEditOptionsOpen(false);
setDesktopEditOptionsOpen(false);
}
}, [editToggleDisabled]);
const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3';
const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode];
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
const combinedClassName = cn(iconClass, 'flex-shrink-0');
const modeColors = getEditModeColors(mode);
const iconColor = modeColors ? modeColors.text : 'var(--foreground)';
const iconStyle = { color: iconColor };
if (mode === 'full') {
return <RiPencilAiLine className={combinedClassName} style={iconStyle} />;
}
if (mode === 'allow') {
return <RiCheckboxCircleLine className={combinedClassName} style={iconStyle} />;
}
if (mode === 'deny') {
return <RiCloseCircleLine className={combinedClassName} style={iconStyle} />;
}
return <RiQuestionLine className={combinedClassName} style={iconStyle} />;
}, [editToggleIconClass]);
const handleEditPermissionSelect = React.useCallback((mode: EditPermissionMode) => {
if (editToggleDisabled || !currentSessionId || !currentAgentName || isModeDisabled(mode)) {
return;
}
setSessionAgentEditMode(currentSessionId, currentAgentName, mode, cascadeDefaultMode);
setAgentMenuOpen(false);
setMobileEditOptionsOpen(false);
setDesktopEditOptionsOpen(false);
}, [cascadeDefaultMode, editToggleDisabled, currentSessionId, currentAgentName, setSessionAgentEditMode, setAgentMenuOpen, setDesktopEditOptionsOpen, isModeDisabled]);
const currentProvider = getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
const currentMetadata =
currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined;
const currentCapabilityIcons = getCapabilityIcons(currentMetadata);
const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
const availableVariants = React.useMemo(() => {
const variantKey = `${currentProviderId}/${currentModelId}`;
if (!variantKey) {
return [];
}
return getCurrentModelVariants();
}, [getCurrentModelVariants, currentProviderId, currentModelId]);
const hasVariants = availableVariants.length > 0;
const costRows = [
{ label: 'Input', value: formatCost(currentMetadata?.cost?.input) },
{ label: 'Output', value: formatCost(currentMetadata?.cost?.output) },
{ label: 'Cache read', value: formatCost(currentMetadata?.cost?.cache_read) },
{ label: 'Cache write', value: formatCost(currentMetadata?.cost?.cache_write) },
];
const limitRows = [
{ label: 'Context', value: formatTokens(currentMetadata?.limit?.context) },
{ label: 'Output', value: formatTokens(currentMetadata?.limit?.output) },
];
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
const currentSessionMessageCount = currentSessionId ? (messages.get(currentSessionId)?.length ?? -1) : -1;
const sessionInitializationRef = React.useRef<{
sessionId: string;
resolved: boolean;
inFlight: boolean;
} | null>(null);
// If we have an explicit per-session agent selection (eg. server-injected mode switch),
// treat the session as resolved and don't run inference/fallback that could cause flicker.
React.useEffect(() => {
if (!currentSessionId) {
return;
}
const refState = sessionInitializationRef.current;
if (!refState || refState.sessionId !== currentSessionId) {
return;
}
if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) {
refState.resolved = true;
refState.inFlight = false;
}
}, [agents, currentSessionId, sessionSavedAgentName]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
if (!providerId || !modelId) {
return 'model-missing';
}
const provider = providers.find(p => p.id === providerId);
if (!provider) {
return 'provider-missing';
}
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const modelExists = providerModels.find((m: ProviderModel) => m.id === modelId);
if (!modelExists) {
return 'model-missing';
}
setProvider(providerId);
setModel(modelId);
if (currentSessionId && agentName) {
saveAgentModelForSession(currentSessionId, agentName, providerId, modelId);
}
return 'applied';
},
[providers, setProvider, setModel, currentSessionId, saveAgentModelForSession],
);
React.useEffect(() => {
if (!currentSessionId) {
sessionInitializationRef.current = null;
return;
}
if (!contextHydrated || providers.length === 0 || agents.length === 0) {
return;
}
if (!sessionInitializationRef.current || sessionInitializationRef.current.sessionId !== currentSessionId) {
sessionInitializationRef.current = { sessionId: currentSessionId, resolved: false, inFlight: false };
}
const state = sessionInitializationRef.current;
if (!state || state.resolved || state.inFlight) {
return;
}
let isCancelled = false;
const finalize = () => {
if (isCancelled) {
return;
}
const refState = sessionInitializationRef.current;
if (refState && refState.sessionId === currentSessionId) {
refState.resolved = true;
refState.inFlight = false;
}
};
const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => {
const savedAgentName = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
if (savedModel) {
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
if (result === 'applied') {
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
} else {
return 'resolved';
}
}
for (const agent of agents) {
const selection = getAgentModelForSession(currentSessionId, agent.name);
if (!selection) {
continue;
}
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
}
return 'continue';
};
const applyFallbackAgent = () => {
if (agents.length === 0) {
return;
}
const existingSelection = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null;
// If we already have a valid agent selected (often from server-injected mode switch),
// don't override it with a fallback.
const preferred =
(currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null) ||
currentAgentName;
if (preferred && agents.some((agent) => agent.name === preferred)) {
if (currentAgentName !== preferred) {
setAgent(preferred);
}
return;
}
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
const fallbackAgent = agents.find(agent => agent.name === 'build') || primaryAgents[0] || agents[0];
if (!fallbackAgent) {
return;
}
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, fallbackAgent.name);
}
if (currentAgentName !== fallbackAgent.name) {
setAgent(fallbackAgent.name);
}
if (fallbackAgent.model?.providerID && fallbackAgent.model?.modelID) {
tryApplyModelSelection(fallbackAgent.model.providerID, fallbackAgent.model.modelID, fallbackAgent.name);
}
};
const resolveSessionPreferences = async () => {
try {
const savedOutcome = applySavedSelections();
if (savedOutcome === 'resolved') {
finalize();
return;
}
if (savedOutcome === 'waiting') {
return;
}
if (currentSessionMessageCount === -1) {
return;
}
if (currentSessionMessageCount > 0) {
state.inFlight = true;
try {
const discoveredChoices = await analyzeAndSaveExternalSessionChoices(currentSessionId, agents);
if (isCancelled) {
return;
}
if (discoveredChoices.size > 0) {
let latestAgent: string | null = null;
let latestTimestamp = -Infinity;
for (const [agentName, choice] of discoveredChoices) {
if (choice.timestamp > latestTimestamp) {
latestTimestamp = choice.timestamp;
latestAgent = agentName;
}
}
if (latestAgent) {
// If server/user already selected an agent for this session, don't override
// with heuristic inference mid-stream.
const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (latestSaved && latestSaved !== latestAgent) {
finalize();
return;
}
if (!latestSaved) {
saveSessionAgentSelection(currentSessionId, latestAgent);
}
if (currentAgentName !== latestAgent) {
setAgent(latestAgent);
}
const latestChoice = discoveredChoices.get(latestAgent);
if (latestChoice) {
const applyResult = tryApplyModelSelection(
latestChoice.providerId,
latestChoice.modelId,
latestAgent,
);
if (applyResult === 'applied') {
finalize();
return;
}
if (applyResult === 'provider-missing') {
return;
}
} else {
finalize();
return;
}
}
}
} catch (error) {
if (!isCancelled) {
console.error('[ModelControls] Error resolving session from messages:', error);
}
} finally {
const refState = sessionInitializationRef.current;
if (!isCancelled && refState && refState.sessionId === currentSessionId) {
refState.inFlight = false;
}
}
}
if (isCancelled) {
return;
}
applyFallbackAgent();
finalize();
} catch (error) {
if (!isCancelled) {
console.error('[ModelControls] Error in session switch:', error);
}
}
};
resolveSessionPreferences();
return () => {
isCancelled = true;
};
}, [
currentSessionId,
currentSessionMessageCount,
agents,
currentAgentName,
getAgentModelForSession,
setAgent,
tryApplyModelSelection,
analyzeAndSaveExternalSessionChoices,
saveSessionAgentSelection,
contextHydrated,
providers,
sessionSavedAgentName,
]);
React.useEffect(() => {
if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) {
return;
}
const preferredAgent = sessionSavedAgentName || currentAgentName;
if (!preferredAgent) {
return;
}
const preferredSelection = getAgentModelForSession(currentSessionId, preferredAgent);
if (!preferredSelection) {
return;
}
const provider = providers.find(p => p.id === preferredSelection.providerId);
if (!provider) {
return;
}
const modelExists = Array.isArray(provider.models)
? provider.models.some((m: ProviderModel) => m.id === preferredSelection.modelId)
: false;
if (!modelExists) {
return;
}
const providerMatches = currentProviderId === preferredSelection.providerId;
const modelMatches = currentModelId === preferredSelection.modelId;
if (providerMatches && modelMatches) {
return;
}
if (preferredAgent !== currentAgentName) {
setAgent(preferredAgent);
}
tryApplyModelSelection(preferredSelection.providerId, preferredSelection.modelId, preferredAgent);
}, [
contextHydrated,
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
providers,
agents,
getAgentModelForSession,
tryApplyModelSelection,
setAgent,
sessionSavedAgentName,
]);
React.useEffect(() => {
if (!contextHydrated) {
return;
}
const handleAgentSwitch = async () => {
try {
if (currentAgentName !== prevAgentNameRef.current) {
prevAgentNameRef.current = currentAgentName;
if (currentAgentName && currentSessionId) {
await new Promise(resolve => setTimeout(resolve, 50));
const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
if (persistedChoice) {
const result = tryApplyModelSelection(
persistedChoice.providerId,
persistedChoice.modelId,
currentAgentName,
);
if (result === 'applied' || result === 'provider-missing') {
return;
}
}
const agent = agents.find(a => a.name === currentAgentName);
if (agent?.model?.providerID && agent?.model?.modelID) {
const result = tryApplyModelSelection(
agent.model.providerID,
agent.model.modelID,
currentAgentName,
);
if (result === 'provider-missing') {
return;
}
}
}
}
} catch (error) {
console.error('[ModelControls] Agent change error:', error);
}
};
handleAgentSwitch();
}, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, agents, contextHydrated]);
React.useEffect(() => {
if (!contextHydrated || !currentAgentName) {
setCurrentVariant(undefined);
return;
}
if (!currentProviderId || !currentModelId) {
setCurrentVariant(undefined);
return;
}
if (availableVariants.length === 0) {
setCurrentVariant(undefined);
return;
}
if (currentVariant && !availableVariants.includes(currentVariant)) {
setCurrentVariant(undefined);
return;
}
// Draft state (no session yet): seed from settings default, but don't override
// user selection while drafting.
if (!currentSessionId) {
if (!currentVariant) {
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(desired);
}
return;
}
const savedVariant = getAgentModelVariantForSession(
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
);
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
? savedVariant
: undefined;
setCurrentVariant(resolvedSaved);
}, [
availableVariants,
contextHydrated,
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
currentVariant,
getAgentModelVariantForSession,
setCurrentVariant,
settingsDefaultVariant,
]);
const handleVariantSelect = React.useCallback((variant: string | undefined) => {
setCurrentVariant(variant);
if (currentSessionId && currentAgentName && currentProviderId && currentModelId) {
saveAgentModelVariantForSession(
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
variant,
);
}
}, [
currentAgentName,
currentModelId,
currentProviderId,
currentSessionId,
saveAgentModelVariantForSession,
setCurrentVariant,
]);
const handleAgentChange = (agentName: string) => {
try {
setAgent(agentName);
setAgentMenuOpen(false);
if (currentSessionId) {
saveSessionAgentSelection(currentSessionId, agentName);
}
if (isCompact) {
closeMobilePanel();
}
} catch (error) {
console.error('[ModelControls] Handle agent change error:', error);
}
};
const handleProviderAndModelChange = (providerId: string, modelId: string) => {
try {
const result = tryApplyModelSelection(providerId, modelId, currentAgentName || undefined);
if (result !== 'applied') {
if (result === 'provider-missing') {
console.error('[ModelControls] Provider not available for selection:', providerId);
} else if (result === 'model-missing') {
console.error('[ModelControls] Model not available for selection:', { providerId, modelId });
}
return;
}
// Add to recent models on successful selection
addRecentModel(providerId, modelId);
setAgentMenuOpen(false);
if (isCompact) {
closeMobilePanel();
}
// Restore focus to chat input after model selection
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
} catch (error) {
console.error('[ModelControls] Handle model change error:', error);
}
};
const getModelDisplayName = (model: ProviderModel | undefined) => {
const name = (typeof model?.name === 'string' ? model.name : (typeof model?.id === 'string' ? model.id : ''));
if (name.length > 40) {
return name.substring(0, 37) + '...';
}
return name;
};
const getProviderDisplayName = () => {
const provider = providers.find(p => p.id === currentProviderId);
return provider?.name || currentProviderId;
};
const getCurrentModelDisplayName = () => {
if (!currentProviderId || !currentModelId) return 'Not selected';
if (models.length === 0) return 'Not selected';
const currentModel = models.find((m: ProviderModel) => m.id === currentModelId);
return getModelDisplayName(currentModel);
};
const currentModelDisplayName = getCurrentModelDisplayName();
const modelLabelRef = React.useRef<HTMLSpanElement>(null);
const isModelLabelTruncated = useIsTextTruncated(modelLabelRef, [currentModelDisplayName, isCompact]);
const getAgentDisplayName = () => {
if (!uiAgentName) {
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find(agent => agent.name === 'build');
const defaultAgent = buildAgent || primaryAgents[0];
return defaultAgent ? capitalizeAgentName(defaultAgent.name) : 'Select Agent';
}
const agent = agents.find(a => a.name === uiAgentName);
return agent ? capitalizeAgentName(agent.name) : capitalizeAgentName(uiAgentName);
};
const capitalizeAgentName = (name: string) => {
return name.charAt(0).toUpperCase() + name.slice(1);
};
const renderIconBadge = (IconComp: IconComponent, label: string, key: string) => (
<span
key={key}
className="flex h-5 w-5 items-center justify-center rounded-xl bg-muted/60 text-muted-foreground"
title={label}
aria-label={label}
role="img"
>
<IconComp className="h-3.5 w-3.5" />
</span>
);
const toggleMobileProviderExpansion = React.useCallback((providerId: string) => {
setExpandedMobileProviders((prev) => {
const next = new Set(prev);
if (next.has(providerId)) {
next.delete(providerId);
} else {
next.add(providerId);
}
return next;
});
}, []);
const handleLongPressStart = React.useCallback((type: 'model' | 'agent') => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
longPressTimerRef.current = setTimeout(() => {
setMobileTooltipOpen(type);
}, 500);
}, []);
const handleLongPressEnd = React.useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
}, []);
React.useEffect(() => {
return () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
};
}, []);
const renderMobileModelTooltip = () => {
if (!isCompact || mobileTooltipOpen !== 'model') return null;
return (
<MobileOverlayPanel
open={true}
onClose={closeMobileTooltip}
title={currentMetadata?.name || getCurrentModelDisplayName()}
>
<div className="flex flex-col gap-1.5">
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-0.5">Provider</div>
<div className="typography-meta text-foreground font-medium">{getProviderDisplayName()}</div>
</div>
{}
{currentCapabilityIcons.length > 0 && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Capabilities</div>
<div className="flex flex-wrap gap-1.5">
{currentCapabilityIcons.map(({ key, icon, label }) => (
<div key={key} className="flex items-center gap-1.5">
{renderIconBadge(icon, label, `cap-${key}`)}
<span className="typography-meta text-foreground">{label}</span>
</div>
))}
</div>
</div>
)}
{}
{(inputModalityIcons.length > 0 || outputModalityIcons.length > 0) && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Modalities</div>
<div className="flex flex-col gap-1">
{inputModalityIcons.length > 0 && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground/80 w-12">Input</span>
<div className="flex gap-1">
{inputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} input`, `input-${key}`))}
</div>
</div>
)}
{outputModalityIcons.length > 0 && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground/80 w-12">Output</span>
<div className="flex gap-1">
{outputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} output`, `output-${key}`))}
</div>
</div>
)}
</div>
</div>
)}
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Limits</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Context</span>
<span className="typography-meta font-medium text-foreground">{formatTokens(currentMetadata?.limit?.context)}</span>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Output</span>
<span className="typography-meta font-medium text-foreground">{formatTokens(currentMetadata?.limit?.output)}</span>
</div>
</div>
</div>
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Metadata</div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Knowledge</span>
<span className="typography-meta font-medium text-foreground">{formatKnowledge(currentMetadata?.knowledge)}</span>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Release</span>
<span className="typography-meta font-medium text-foreground">{formatDate(currentMetadata?.release_date)}</span>
</div>
</div>
</div>
</div>
</MobileOverlayPanel>
);
};
const renderMobileAgentTooltip = () => {
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
const rules = asPermissionRuleset(currentAgent.permission) ?? [];
const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
if (hasCustom) {
return { mode: 'ask', label: 'Custom' };
}
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
return { mode: 'ask', label: 'Ask' };
};
const editPermissionSummary = summarizePermission('edit');
const bashPermissionSummary = summarizePermission('bash');
const webfetchPermissionSummary = summarizePermission('webfetch');
return (
<MobileOverlayPanel
open={true}
onClose={closeMobileTooltip}
title={capitalizeAgentName(currentAgent.name)}
>
<div className="flex flex-col gap-1.5">
{}
{currentAgent.description && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-meta text-foreground">{currentAgent.description}</div>
</div>
)}
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-0.5">Mode</div>
<div className="typography-meta text-foreground font-medium">
{currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
</div>
</div>
{}
{(hasModelConfig || hasTemperatureOrTopP) && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Model</div>
{hasModelConfig && (
<div className="typography-meta text-foreground font-medium mb-1">
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
</div>
)}
{hasTemperatureOrTopP && (
<div className="flex flex-col gap-0.5">
{currentAgent.temperature !== undefined && (
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Temperature</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.temperature}</span>
</div>
)}
{currentAgent.topP !== undefined && (
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Top P</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.topP}</span>
</div>
)}
</div>
)}
</div>
)}
{}
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="typography-micro text-muted-foreground mb-1">Permissions</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Edit</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
{editPermissionSummary.label}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Bash</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
{bashPermissionSummary.label}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">WebFetch</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground">
{webfetchPermissionSummary.label}
</span>
</div>
</div>
</div>
</div>
{}
{hasCustomPrompt && (
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-2 py-1.5">
<div className="flex items-center justify-between">
<span className="typography-meta text-muted-foreground/80">Custom Prompt</span>
<RiCheckboxCircleLine className="h-4 w-4 text-foreground" />
</div>
</div>
)}
</div>
</MobileOverlayPanel>
);
};
const renderMobileModelPanel = () => {
if (!isCompact) return null;
const normalizedQuery = mobileModelQuery.trim();
const filteredProviders = providers
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matchesProvider = normalizedQuery.length === 0
? true
: fuzzyMatch(provider.name, normalizedQuery) || fuzzyMatch(provider.id, normalizedQuery);
const matchingModels = normalizedQuery.length === 0
? providerModels
: providerModels.filter((model: ProviderModel) => {
const name = getModelDisplayName(model);
const id = typeof model.id === 'string' ? model.id : '';
return fuzzyMatch(name, normalizedQuery) || fuzzyMatch(id, normalizedQuery);
});
return { provider, providerModels: matchingModels, matchesProvider };
})
.filter(({ matchesProvider, providerModels }) => matchesProvider || providerModels.length > 0);
return (
<MobileOverlayPanel
open={activeMobilePanel === 'model'}
onClose={closeMobilePanel}
title="Select model"
>
<div className="flex flex-col gap-2">
<div>
<div className="relative">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={mobileModelQuery}
onChange={(event) => setMobileModelQuery(event.target.value)}
placeholder="Search providers or models"
className="pl-7 h-9 rounded-xl border-border/40 bg-background/95 typography-meta"
/>
{mobileModelQuery && (
<button
type="button"
onClick={() => setMobileModelQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<RiCloseCircleLine className="h-4 w-4" />
</button>
)}
</div>
</div>
{filteredProviders.length === 0 && (
<div className="px-3 py-8 text-center typography-meta text-muted-foreground">
No providers or models match your search.
</div>
)}
{/* Favorites Section for Mobile */}
{!mobileModelQuery && favoriteModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95 overflow-hidden">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-primary" />
Favorites
</div>
<div className="flex flex-col border-t border-border/30">
{favoriteModelsList.map(({ model, providerID, modelID }) => {
const isSelected = providerID === currentProviderId && modelID === currentModelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<button
key={`fav-mobile-${providerID}-${modelID}`}
type="button"
onClick={() => handleProviderAndModelChange(providerID, modelID)}
className={cn(
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
'first:rounded-t-xl last:rounded-b-xl transition-colors',
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-muted'
)}
>
<div className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta font-medium text-foreground truncate">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="typography-micro text-muted-foreground whitespace-nowrap">
{metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Recent Section for Mobile */}
{!mobileModelQuery && recentModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95 overflow-hidden">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiTimeLine className="h-3 w-3 inline-block mr-1.5" />
Recent
</div>
<div className="flex flex-col border-t border-border/30">
{recentModelsList.map(({ model, providerID, modelID }) => {
const isSelected = providerID === currentProviderId && modelID === currentModelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<button
key={`recent-mobile-${providerID}-${modelID}`}
type="button"
onClick={() => handleProviderAndModelChange(providerID, modelID)}
className={cn(
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
'first:rounded-t-xl last:rounded-b-xl transition-colors',
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-muted'
)}
>
<div className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta font-medium text-foreground truncate">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="typography-micro text-muted-foreground whitespace-nowrap">
{metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
)}
{filteredProviders.map(({ provider, providerModels }) => {
if (providerModels.length === 0 && !normalizedQuery.length) {
return null;
}
const isActiveProvider = provider.id === currentProviderId;
const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0;
return (
<div key={provider.id} className="rounded-xl border border-border/40 bg-background/95 overflow-hidden">
<button
type="button"
onClick={() => toggleMobileProviderExpansion(provider.id)}
className="flex w-full items-center justify-between gap-1.5 px-2 py-1.5 text-left"
aria-expanded={isExpanded}
>
<div className="flex items-center gap-2">
<ProviderLogo
providerId={provider.id}
className="h-3.5 w-3.5"
/>
<span className="typography-meta font-medium text-foreground">
{provider.name}
</span>
{isActiveProvider && (
<span className="typography-micro text-primary/80">Current</span>
)}
</div>
{isExpanded ? (
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-3 w-3 text-muted-foreground" />
)}
</button>
{isExpanded && providerModels.length > 0 && (
<div className="flex flex-col border-t border-border/30">
{providerModels.map((model: ProviderModel) => {
const isSelected = isActiveProvider && model.id === currentModelId;
const metadata = getModelMetadata(provider.id, model.id!);
const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3);
const inputIcons = getModalityIcons(metadata, 'input');
return (
<div
key={model.id}
className={cn(
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 last:border-b-0',
'rounded-lg transition-colors',
!isSelected && 'hover:bg-muted',
isSelected
? 'bg-primary/15 text-primary'
: ''
)}
>
<button
type="button"
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
className={cn(
'flex flex-1 min-w-0 items-start gap-2 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary'
)}
>
<div className="flex min-w-0 flex-col">
<span className="typography-meta font-medium text-foreground">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 text-right">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
{metadata?.limit?.context && metadata?.limit?.output ? <span></span> : null}
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
</div>
)}
{(capabilityIcons.length > 0 || inputIcons.length > 0) && (
<div className="flex items-center justify-end gap-1">
{[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
<span
key={`meta-${provider.id}-${model.id}-${key}`}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={label}
aria-label={label}
>
<IconComponent className="h-3 w-3" />
</span>
))}
</div>
)}
</div>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, model.id as string);
}}
className={cn(
"model-favorite-button flex h-5 w-5 items-center justify-center hover:text-yellow-600 flex-shrink-0",
isFavoriteModel(provider.id as string, model.id as string)
? "text-yellow-500"
: "text-muted-foreground"
)}
aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"}
>
{isFavoriteModel(provider.id as string, model.id as string) ? (
<RiStarFill className="h-4 w-4" />
) : (
<RiStarLine className="h-4 w-4" />
)}
</button>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</MobileOverlayPanel>
);
};
const renderMobileVariantPanel = () => {
if (!isCompact || !hasVariants) return null;
const isDefault = !currentVariant;
const handleSelect = (variant: string | undefined) => {
handleVariantSelect(variant);
closeMobilePanel();
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
};
return (
<MobileOverlayPanel
open={activeMobilePanel === 'variant'}
onClose={closeMobilePanel}
title="Thinking"
>
<div className="flex flex-col gap-1.5">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
isDefault ? 'border-primary/30 bg-primary/10' : 'border-border/40'
)}
onClick={() => handleSelect(undefined)}
>
<span className="typography-meta font-medium text-foreground">Default</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
{availableVariants.map((variant) => {
const selected = currentVariant === variant;
const label = variant.charAt(0).toUpperCase() + variant.slice(1);
return (
<button
key={variant}
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
selected ? 'border-primary/30 bg-primary/10' : 'border-border/40'
)}
onClick={() => handleSelect(variant)}
>
<span className="typography-meta font-medium text-foreground">{label}</span>
{selected && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
);
})}
</div>
</MobileOverlayPanel>
);
};
const renderMobileAgentPanel = () => {
if (!isCompact) return null;
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
return (
<MobileOverlayPanel
open={activeMobilePanel === 'agent'}
onClose={closeMobilePanel}
title="Select agent"
>
<div className="flex flex-col gap-1.5">
{primaryAgents.map((agent) => {
const isSelected = agent.name === uiAgentName;
const agentColor = getAgentColor(agent.name);
return (
<button
key={agent.name}
type="button"
className={cn(
'flex w-full flex-col gap-1 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary agent-list-item',
agentColor.class,
isSelected ? 'active' : 'border-border/40'
)}
onClick={() => handleAgentChange(agent.name)}
>
<div className="flex items-center gap-1.5">
<div className={cn('h-2 w-2 rounded-full', agentColor.class)} />
<span
className="typography-meta font-medium text-foreground"
style={isSelected ? { color: `var(${agentColor.var})` } : undefined}
>
{capitalizeAgentName(agent.name)}
</span>
</div>
{agent.description && (
<span className="typography-micro text-muted-foreground">
{agent.description}
</span>
)}
</button>
);
})}
<div className="rounded-xl border border-border/40 bg-sidebar/30">
<button
type="button"
disabled={editToggleDisabled}
onClick={() => {
if (!editToggleDisabled) {
setMobileEditOptionsOpen((previous) => !previous);
}
}}
className={cn(
'flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left bg-transparent',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary rounded-t-xl',
editToggleDisabled ? 'cursor-not-allowed opacity-60' : undefined
)}
>
<div className="flex flex-col text-left">
<span
className="typography-meta font-medium text-foreground"
style={{
color: activeEditModeColors ? activeEditModeColors.text : 'var(--foreground)',
mixBlendMode: 'normal',
}}
>
{editPermissionMenuLabel}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'flex items-center justify-center p-1',
!activeEditModeColors && 'text-muted-foreground'
)}
style={
activeEditModeColors
? {
color: activeEditModeColors.text,
}
: undefined
}
>
{renderEditModeIcon(effectiveEditMode, editToggleIconClass)}
</span>
<RiArrowDownSLine
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
mobileEditOptionsOpen ? 'rotate-180' : ''
)}
/>
</div>
</button>
{mobileEditOptionsOpen && !editToggleDisabled && (
<div className="border-t border-border/40 bg-transparent px-2 py-1.5">
<div className="flex flex-col gap-1.5">
{editPermissionOptions.map((option) => {
const isSelected = option.mode === effectiveEditMode;
const optionColors = getEditModeColors(option.mode);
return (
<button
key={option.mode}
type="button"
disabled={option.disabled}
onClick={() => handleEditPermissionSelect(option.mode)}
className={cn(
'flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left',
option.disabled ? 'cursor-not-allowed opacity-50' : 'focus:bg-transparent hover:bg-transparent',
isSelected ? 'bg-primary/10' : undefined
)}
style={isSelected && optionColors ? { backgroundColor: optionColors.background ?? undefined } : undefined}
>
{renderEditModeIcon(option.mode, editToggleIconClass)}
<div className="flex flex-col">
<span
className="typography-meta font-medium"
style={{
color: optionColors
? optionColors.text
: 'var(--foreground)',
}}
>
{option.label}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</MobileOverlayPanel>
);
};
const renderModelTooltipContent = () => (
<TooltipContent align="start" sideOffset={8} className="max-w-[320px]">
{currentMetadata ? (
<div className="flex min-w-[240px] flex-col gap-3">
<div className="flex flex-col gap-0.5">
<span className="typography-micro font-semibold text-foreground">
{currentMetadata.name || getCurrentModelDisplayName()}
</span>
<span className="typography-meta text-muted-foreground">{getProviderDisplayName()}</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Capabilities</span>
<div className="flex flex-wrap items-center gap-1.5">
{currentCapabilityIcons.length > 0 ? (
currentCapabilityIcons.map(({ key, icon, label }) =>
renderIconBadge(icon, label, `cap-${key}`)
)
) : (
<span className="typography-meta text-muted-foreground"></span>
)}
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Modalities</span>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Input</span>
<div className="flex items-center gap-1.5">
{inputModalityIcons.length > 0
? inputModalityIcons.map(({ key, icon, label }) =>
renderIconBadge(icon, `${label} input`, `input-${key}`)
)
: <span className="typography-meta text-muted-foreground"></span>}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Output</span>
<div className="flex items-center gap-1.5">
{outputModalityIcons.length > 0
? outputModalityIcons.map(({ key, icon, label }) =>
renderIconBadge(icon, `${label} output`, `output-${key}`)
)
: <span className="typography-meta text-muted-foreground"></span>}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Cost ($/1M tokens)</span>
{costRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">{row.label}</span>
<span className="typography-meta font-medium text-foreground">{row.value}</span>
</div>
))}
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Limits</span>
{limitRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">{row.label}</span>
<span className="typography-meta font-medium text-foreground">{row.value}</span>
</div>
))}
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Metadata</span>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Knowledge</span>
<span className="typography-meta font-medium text-foreground">{formatKnowledge(currentMetadata.knowledge)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="typography-meta font-medium text-muted-foreground/80">Release</span>
<span className="typography-meta font-medium text-foreground">{formatDate(currentMetadata.release_date)}</span>
</div>
</div>
</div>
) : (
<div className="min-w-[200px] typography-meta text-muted-foreground">Model metadata unavailable.</div>
)}
</TooltipContent>
);
// Helper to render a single model row in the flat dropdown
const renderModelRow = (
model: ProviderModel,
providerID: string,
modelID: string,
keyPrefix: string,
flatIndex: number,
isHighlighted: boolean
) => {
const metadata = getModelMetadata(providerID, modelID);
const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({
...icon,
id: `cap-${icon.key}`,
}));
const modalityIcons = [
...getModalityIcons(metadata, 'input'),
...getModalityIcons(metadata, 'output'),
];
const uniqueModalityIcons = Array.from(
new Map(modalityIcons.map((icon) => [icon.key, icon])).values()
).map((icon) => ({ ...icon, id: `mod-${icon.key}` }));
const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons];
const contextTokens = formatTokens(metadata?.limit?.context);
const isSelected = currentProviderId === providerID && currentModelId === modelID;
const isFavorite = isFavoriteModel(providerID, modelID);
return (
<div
key={`${keyPrefix}-${providerID}-${modelID}`}
ref={(el) => { modelItemRefs.current[flatIndex] = el; }}
className={cn(
"typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer",
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => handleProviderAndModelChange(providerID, modelID)}
onMouseEnter={() => setModelSelectedIndex(flatIndex)}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
{metadata?.limit?.context ? (
<span className="typography-micro text-muted-foreground flex-shrink-0">
{contextTokens}
</span>
) : null}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{indicatorIcons.length > 0 && (
<div className={cn("items-center gap-0.5", isHighlighted ? "flex" : "hidden group-hover:flex")}>
{indicatorIcons.map(({ id, icon: Icon, label }) => (
<span
key={id}
className="flex h-3.5 w-3.5 items-center justify-center text-muted-foreground"
aria-label={label}
role="img"
title={label}
>
<Icon className="h-2.5 w-2.5" />
</span>
))}
</div>
)}
{isSelected && (
<RiCheckLine className="h-4 w-4 text-primary" />
)}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className={cn(
"model-favorite-button flex h-4 w-4 items-center justify-center hover:text-yellow-600",
isFavorite ? "text-yellow-500" : "text-muted-foreground"
)}
aria-label={isFavorite ? "Unfavorite" : "Favorite"}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{isFavorite ? (
<RiStarFill className="h-3.5 w-3.5" />
) : (
<RiStarLine className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
);
};
// Filter models based on search query (fuzzy match)
const filterByQuery = (modelName: string, providerName: string, query: string) => {
if (!query.trim()) return true;
return (
fuzzyMatch(modelName, query) ||
fuzzyMatch(providerName, query)
);
};
const renderModelSelector = () => {
// Filter favorites
const filteredFavorites = 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, desktopModelQuery);
});
// Filter recents
const filteredRecents = 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, desktopModelQuery);
});
// Filter providers and their models
const filteredProviders = providers
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const filteredModels = providerModels.filter((model: ProviderModel) => {
const modelName = getModelDisplayName(model);
return filterByQuery(modelName, provider.name || provider.id || '', desktopModelQuery);
});
return { ...provider, models: filteredModels };
})
.filter((provider) => provider.models.length > 0);
const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0;
// Build flat list for keyboard navigation
type FlatModelItem = { model: ProviderModel; 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 as ProviderModel[]).forEach((model) => {
flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' });
});
});
const totalItems = flatModelList.length;
// Handle keyboard navigation
const handleModelKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
setModelSelectedIndex((prev) => (prev + 1) % Math.max(1, totalItems));
// Scroll into view
setTimeout(() => {
const nextIndex = (modelSelectedIndex + 1) % Math.max(1, totalItems);
modelItemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
setModelSelectedIndex((prev) => (prev - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems));
// Scroll into view
setTimeout(() => {
const prevIndex = (modelSelectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems);
modelItemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const selectedItem = flatModelList[modelSelectedIndex];
if (selectedItem) {
handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID);
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setAgentMenuOpen(false);
}
};
// Build index mapping for rendering
let currentFlatIndex = 0;
return (
<Tooltip delayDuration={1000}>
{!isCompact ? (
<DropdownMenu open={agentMenuOpen} onOpenChange={setAgentMenuOpen}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
'model-controls__model-trigger flex items-center gap-1.5 cursor-pointer hover:opacity-70 min-w-0',
buttonHeight
)}
>
{currentProviderId ? (
<>
<ProviderLogo
providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')}
/>
<RiPencilAiLine className={cn(controlIconSize, 'text-primary/60 hidden')} />
</>
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
ref={modelLabelRef}
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label overflow-hidden',
controlTextSize,
'font-medium whitespace-nowrap text-foreground min-w-0',
'max-w-[260px]'
)}
>
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="end" alignOffset={-40}>
{/* 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
type="text"
placeholder="Search models"
value={desktopModelQuery}
onChange={(e) => setDesktopModelQuery(e.target.value)}
onKeyDown={handleModelKeyDown}
className="pl-8 h-8 typography-meta"
autoFocus
/>
</div>
</div>
{/* Scrollable content */}
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<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 && (
<>
<DropdownMenuLabel 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
</DropdownMenuLabel>
{filteredFavorites.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx);
})}
</>
)}
{/* Recents Section */}
{filteredRecents.length > 0 && (
<>
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel 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
</DropdownMenuLabel>
{filteredRecents.map(({ model, providerID, modelID }) => {
const idx = currentFlatIndex++;
return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx);
})}
</>
)}
{/* Separator before providers */}
{(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && (
<DropdownMenuSeparator />
)}
{/* All Providers - Flat List */}
{filteredProviders.map((provider, index) => (
<React.Fragment key={provider.id}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel 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}
</DropdownMenuLabel>
{(provider.models as ProviderModel[]).map((model: ProviderModel) => {
const idx = currentFlatIndex++;
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === 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>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
onClick={() => setActiveMobilePanel('model')}
onTouchStart={() => handleLongPressStart('model')}
onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd}
className={cn(
'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none',
'cursor-pointer hover:opacity-70',
buttonHeight
)}
>
{currentProviderId ? (
<ProviderLogo
providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')}
/>
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
ref={modelLabelRef}
className={cn(
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)}
>
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</button>
)}
{renderModelTooltipContent()}
</Tooltip>
);
};
const renderAgentTooltipContent = () => {
if (!currentAgent) {
return (
<TooltipContent align="start" sideOffset={8} className="max-w-[320px]">
<div className="min-w-[200px] typography-meta text-muted-foreground">No agent selected.</div>
</TooltipContent>
);
}
const hasCustomPrompt = Boolean(currentAgent.prompt && currentAgent.prompt.trim().length > 0);
const hasModelConfig = currentAgent.model?.providerID && currentAgent.model?.modelID;
const hasTemperatureOrTopP = currentAgent.temperature !== undefined || currentAgent.topP !== undefined;
const summarizePermission = (permissionName: string): { mode: EditPermissionMode; label: string } => {
const rules = asPermissionRuleset(currentAgent.permission) ?? [];
const hasCustom = rules.some((rule) => rule.permission === permissionName && rule.pattern !== '*');
const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask';
if (hasCustom) {
return { mode: 'ask', label: 'Custom' };
}
if (action === 'allow') return { mode: 'allow', label: 'Allow' };
if (action === 'deny') return { mode: 'deny', label: 'Deny' };
return { mode: 'ask', label: 'Ask' };
};
const editPermissionSummary = summarizePermission('edit');
const bashPermissionSummary = summarizePermission('bash');
const webfetchPermissionSummary = summarizePermission('webfetch');
return (
<TooltipContent align="start" sideOffset={8} className="max-w-[280px]">
<div className="flex min-w-[200px] flex-col gap-2.5">
<div className="flex flex-col gap-0.5">
<span className="typography-micro font-semibold text-foreground">
{capitalizeAgentName(currentAgent.name)}
</span>
{currentAgent.description && (
<span className="typography-meta text-muted-foreground">{currentAgent.description}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Mode</span>
<span className="typography-meta text-foreground">
{currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'}
</span>
</div>
{(hasModelConfig || hasTemperatureOrTopP) && (
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Model</span>
{hasModelConfig ? (
<span className="typography-meta text-foreground">
{currentAgent.model!.providerID} / {currentAgent.model!.modelID}
</span>
) : (
<span className="typography-meta text-muted-foreground"></span>
)}
{hasTemperatureOrTopP && (
<div className="flex flex-col gap-0.5 mt-0.5">
{currentAgent.temperature !== undefined && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Temperature</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.temperature}</span>
</div>
)}
{currentAgent.topP !== undefined && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Top P</span>
<span className="typography-meta font-medium text-foreground">{currentAgent.topP}</span>
</div>
)}
</div>
)}
</div>
)}
<div className="flex flex-col gap-1">
<span className="typography-meta font-semibold uppercase tracking-wide text-muted-foreground/90">Permissions</span>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Edit</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
{editPermissionSummary.label}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">Bash</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
{bashPermissionSummary.label}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span className="typography-meta text-muted-foreground/80 w-16">WebFetch</span>
<div className="flex items-center gap-1.5">
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')}
<span className="typography-meta font-medium text-foreground w-12">
{webfetchPermissionSummary.label}
</span>
</div>
</div>
</div>
{hasCustomPrompt && (
<div className="flex items-center justify-between gap-3">
<span className="typography-meta text-muted-foreground/80">Custom Prompt</span>
<RiCheckboxCircleLine className="h-4 w-4 text-foreground" />
</div>
)}
</div>
</TooltipContent>
);
};
const renderVariantSelector = () => {
if (!hasVariants) {
return null;
}
const displayVariant = currentVariant ?? 'Default';
const isDefault = !currentVariant;
const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]';
if (isCompact) {
return (
<button
type="button"
onClick={() => setActiveMobilePanel('variant')}
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
'cursor-pointer hover:opacity-70',
)}
>
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span className={cn(
'model-controls__variant-label',
controlTextSize,
'font-medium truncate min-w-0',
isMobile && 'max-w-[60px]',
colorClass
)}>
{displayVariant}
</span>
</button>
);
}
return (
<Tooltip delayDuration={800}>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity cursor-pointer hover:opacity-70 min-w-0',
buttonHeight,
)}
>
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span
className={cn(
'model-controls__variant-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[180px]' : undefined,
colorClass,
)}
>
{displayVariant}
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">Thinking</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">Default</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
{availableVariants.length > 0 && <DropdownMenuSeparator />}
{availableVariants.map((variant) => {
const selected = currentVariant === variant;
const label = variant.charAt(0).toUpperCase() + variant.slice(1);
return (
<DropdownMenuItem
key={variant}
className="typography-meta"
onSelect={() => handleVariantSelect(variant)}
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">{label}</span>
{selected && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">
<p className="typography-meta">Thinking: {displayVariant}</p>
</TooltipContent>
</Tooltip>
);
};
const renderAgentSelector = () => {
if (!isCompact) {
return (
<div className="flex items-center gap-2 min-w-0">
<Tooltip delayDuration={1000}>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div className={cn(
'flex items-center gap-1.5 transition-opacity cursor-pointer hover:opacity-70 min-w-0',
buttonHeight
)}>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[220px]' : undefined
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))]">
{agents.filter(agent => isPrimaryMode(agent.mode)).map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
getAgentColor(agent.name).class
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="flex flex-col gap-1 px-1 py-0.5">
<button
type="button"
disabled={editToggleDisabled}
onClick={() => {
if (editToggleDisabled) {
return;
}
setDesktopEditOptionsOpen((previous) => !previous);
}}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left',
'focus:outline-none focus-visible:ring-0',
editToggleDisabled ? 'cursor-not-allowed opacity-60' : undefined
)}
aria-expanded={desktopEditOptionsOpen}
aria-controls={desktopEditOptionsId}
>
<div className="flex flex-col text-left">
<span
className="typography-meta font-medium text-foreground"
style={{
color: activeEditModeColors
? activeEditModeColors.text
: 'var(--foreground)',
}}
>
{editPermissionMenuLabel}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'flex items-center justify-center p-1',
!activeEditModeColors && 'text-muted-foreground'
)}
style={
activeEditModeColors
? {
color: activeEditModeColors.text,
}
: undefined
}
>
{renderEditModeIcon(effectiveEditMode)}
</span>
<RiArrowDownSLine
className={cn(
'h-3.5 w-3.5 transition-transform text-muted-foreground',
desktopEditOptionsOpen ? 'rotate-180' : ''
)}
/>
</div>
</button>
{desktopEditOptionsOpen && !editToggleDisabled && (
<div
id={desktopEditOptionsId}
className="flex flex-col gap-1 rounded-xl border border-border/40 px-2 py-2 bg-transparent"
role="group"
aria-label="Edit permission options"
>
{editPermissionOptions.map((option) => {
const isSelected = option.mode === effectiveEditMode;
const optionColors = getEditModeColors(option.mode);
return (
<button
key={option.mode}
type="button"
disabled={option.disabled}
onClick={() => handleEditPermissionSelect(option.mode)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left',
option.disabled ? 'cursor-not-allowed opacity-50' : 'focus:outline-none focus-visible:ring-0',
isSelected ? 'bg-primary/10' : undefined
)}
style={isSelected && optionColors ? { backgroundColor: optionColors.background ?? undefined } : undefined}
>
{renderEditModeIcon(option.mode, editToggleIconClass)}
<span
className="typography-meta font-medium"
style={{
color: optionColors ? optionColors.text : 'var(--foreground)',
}}
>
{option.label}
</span>
</button>
);
})}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
{renderAgentTooltipContent()}
</Tooltip>
</div>
);
}
return (
<button
type="button"
onClick={() => setActiveMobilePanel('agent')}
onTouchStart={() => handleLongPressStart('agent')}
onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd}
className={cn(
'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
'cursor-pointer hover:opacity-70',
)}
>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium truncate min-w-0',
isMobile && 'max-w-[60px]'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</button>
);
};
const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0',
// Only force full-width + truncation behaviors on true mobile layouts.
// VS Code also uses "compact" mode, but should keep its right-aligned inline sizing.
isMobile && 'w-full',
className,
);
return (
<>
<div className={inlineClassName}>
<div
className={cn(
'flex items-center min-w-0 flex-1 justify-end',
inlineGapClass,
isMobile && 'overflow-hidden'
)}
>
{renderVariantSelector()}
{renderModelSelector()}
{renderAgentSelector()}
</div>
</div>
{renderMobileModelPanel()}
{renderMobileVariantPanel()}
{renderMobileAgentPanel()}
{renderMobileModelTooltip()}
{renderMobileAgentTooltip()}
</>
);
};