Files
openchamber/packages/ui/src/components/chat/ModelControls.tsx
T
Bohdan Triapitsyn bb72c0fb0c feat: vscode extension (#59)
* feat: add initial VS Code extension plan and implementation tasks

* feat(vscode): added initial version of an Openchamber VSCode extension

* feat(vscode): enhance VS Code extension with theme integration and session management

* feat(vscode): implement connection status handling and overlay in VSCode layout

* feat: move extension to secondary sidebar

* chore: upgrade @opencode-ai/sdk to 1.0.150

* vscode: editor bridge, file picker, click-to-open in tool parts

* vscode: layout session lifecycle, theme sync, typography overrides

* ui: compact mode for vscode, model search, autocomplete width fixes

* perf: scroll force flag, raf placeholder, git polling backoff

* ui: tool output styling, markdown code block fix, gitignore

* refactor: update typography handling for VSCode runtime, remove unused styles

* docs: update README with VS Code extension details and add extension image

* docs: update changelog with new features and performance improvements
2025-12-13 16:34:17 +02:00

1965 lines
99 KiB
TypeScript

import React from 'react';
import type { ComponentType } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Input } from '@/components/ui/input';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiCheckboxCircleLine, RiCloseCircleLine, RiFileImageLine, RiFileMusicLine, RiFilePdfLine, RiFileVideoLine, RiPencilAiLine, RiQuestionLine, RiSearchLine, RiText, RiToolsLine } from '@remixicon/react';
import type { ModelMetadata } from '@/types';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useContextStore } from '@/stores/contextStore';
import { useDeviceInfo } from '@/lib/device';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { cn } from '@/lib/utils';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { getAgentColor } from '@/lib/agentColors';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
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,
agents,
currentProviderId,
currentModelId,
currentAgentName,
setProvider,
setModel,
setAgent,
getCurrentProvider,
getModelMetadata,
getCurrentAgent,
} = useConfigStore();
const {
currentSessionId,
messages,
saveSessionAgentSelection,
getSessionAgentSelection,
saveAgentModelForSession,
getAgentModelForSession,
analyzeAndSaveExternalSessionChoices,
getSessionAgentEditMode,
setSessionAgentEditMode,
} = useSessionStore();
const contextHydrated = useContextStore((state) => state.hasHydrated);
const { isMobile } = useDeviceInfo();
const isVSCodeRuntime = useIsVSCodeRuntime();
const isCompact = isMobile || isVSCodeRuntime;
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | 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);
const [agentMenuOpen, setAgentMenuOpen] = React.useState(false);
const [desktopEditOptionsOpen, setDesktopEditOptionsOpen] = React.useState(false);
const desktopEditOptionsId = React.useId();
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]);
React.useEffect(() => {
if (!agentMenuOpen) {
setDesktopEditOptionsOpen(false);
}
}, [agentMenuOpen]);
const currentAgent = getCurrentAgent?.();
const agentPermissionRaw = currentAgent?.permission?.edit;
let agentDefaultEditMode: EditPermissionMode = 'ask';
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
agentDefaultEditMode = agentPermissionRaw;
}
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
if (!currentAgent || !editToolConfigured) {
agentDefaultEditMode = 'deny';
}
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
agentDefaultEditMode,
webfetchPermission: agentWebfetchPermission,
bashPermission: agentBashPermission,
}), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]);
const { cascadeDefaultMode, modeAvailability, autoApproveAvailable } = permissionUiState;
const selectionContextReady = Boolean(currentSessionId && currentAgentName);
const sessionMode = selectionContextReady && currentSessionId && currentAgentName
? getSessionAgentEditMode(currentSessionId, currentAgentName, 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 buttonHeight = isCompact ? 'h-9' : 'h-8';
const editToggleIconClass = isCompact ? 'h-5 w-5' : 'h-4 w-4';
const controlIconSize = isCompact ? 'h-5 w-5' : 'h-4 w-4';
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = isCompact ? 'gap-x-2' : '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 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);
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 = getSessionAgentSelection(currentSessionId);
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);
}
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 primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
const fallbackAgent = agents.find(agent => agent.name === 'build') || primaryAgents[0] || agents[0];
if (!fallbackAgent) {
return;
}
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) {
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;
}
}
}
applyFallbackAgent();
finalize();
} catch (error) {
if (!isCancelled) {
console.error('[ModelControls] Error in session switch:', error);
}
}
};
resolveSessionPreferences();
return () => {
isCancelled = true;
};
}, [
currentSessionId,
currentSessionMessageCount,
agents,
currentAgentName,
getSessionAgentSelection,
getAgentModelForSession,
setAgent,
tryApplyModelSelection,
analyzeAndSaveExternalSessionChoices,
saveSessionAgentSelection,
contextHydrated,
providers,
]);
React.useEffect(() => {
if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) {
return;
}
const savedAgentName = getSessionAgentSelection(currentSessionId);
const preferredAgent = savedAgentName || 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,
getSessionAgentSelection,
getAgentModelForSession,
tryApplyModelSelection,
setAgent,
]);
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]);
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;
}
if (isCompact) {
closeMobilePanel();
}
} 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 getAgentDisplayName = () => {
if (!currentAgentName) {
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 === currentAgentName);
return agent ? capitalizeAgentName(agent.name) : capitalizeAgentName(currentAgentName);
};
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 enabledTools = Object.entries(currentAgent.tools || {})
.filter(([, enabled]) => enabled)
.map(([tool]) => tool)
.sort();
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 getPermissionIcon = (permission?: string) => {
const mode: EditPermissionMode =
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask';
return renderEditModeIcon(mode, 'h-3.5 w-3.5');
};
const getPermissionLabel = (permission?: string) => {
if (permission === 'full') return 'Full';
if (permission === 'allow') return 'Allow';
if (permission === 'deny') return 'Deny';
return 'Ask';
};
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">Tools</div>
{enabledTools.length > 0 ? (
<div className="flex flex-wrap gap-1 leading-tight">
{enabledTools.map((tool) => (
<span
key={tool}
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
>
{tool}
</span>
))}
</div>
) : (
<div className="typography-meta text-muted-foreground">All enabled</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">
{getPermissionIcon(currentAgent.permission?.edit)}
<span className="typography-meta font-medium text-foreground">
{getPermissionLabel(currentAgent.permission?.edit)}
</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">
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
<span className="typography-meta font-medium text-foreground">
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
</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">
{getPermissionIcon(currentAgent.permission?.webfetch)}
<span className="typography-meta font-medium text-foreground">
{getPermissionLabel(currentAgent.permission?.webfetch)}
</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().toLowerCase();
const filteredProviders = providers
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matchesProvider = normalizedQuery.length === 0
? true
: provider.name.toLowerCase().includes(normalizedQuery) || provider.id.toLowerCase().includes(normalizedQuery);
const matchingModels = normalizedQuery.length === 0
? providerModels
: providerModels.filter((model: ProviderModel) => {
const name = getModelDisplayName(model).toLowerCase();
const id = typeof model.id === 'string' ? model.id.toLowerCase() : '';
return name.includes(normalizedQuery) || id.includes(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 className="px-2">
<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-8 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>
)}
{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">
<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 (
<button
key={model.id}
type="button"
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
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',
isSelected
? 'bg-primary/15 text-primary'
: 'hover:bg-accent/40'
)}
>
<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>
);
})}
</div>
)}
</div>
);
})}
</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 === currentAgentName;
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>
);
const renderModelSelector = () => (
<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 flex-1',
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
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label',
controlTextSize,
'font-medium whitespace-nowrap text-foreground truncate min-w-0 flex-1'
)}
>
{getCurrentModelDisplayName()}
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent className="max-w-[300px]">
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
if (providerModels.length === 0) {
return (
<DropdownMenuItem
key={provider.id}
disabled
className="typography-meta text-muted-foreground"
>
<ProviderLogo
providerId={provider.id}
className="h-3 w-3 flex-shrink-0 mr-2"
/>
{provider.name} (No models)
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub key={provider.id}>
<DropdownMenuSubTrigger className="typography-meta">
<ProviderLogo
providerId={provider.id}
className="h-3 w-3 flex-shrink-0 mr-2"
/>
{provider.name}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
>
{providerModels.map((model: ProviderModel) => {
const metadata = getModelMetadata(provider.id, model.id!);
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 outputTokens = formatTokens(metadata?.limit?.output);
return (
<DropdownMenuItem
key={model.id}
className="typography-meta"
onSelect={() => {
handleProviderAndModelChange(provider.id as string, model.id as string);
}}
>
<div className="flex items-center gap-2">
<div className="flex flex-col">
<span className="font-medium">
{getModelDisplayName(model)}
</span>
{metadata?.limit?.context || metadata?.limit?.output ? (
<span className="typography-meta text-muted-foreground">
{metadata?.limit?.context ? `${contextTokens} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${outputTokens} out` : ''}
</span>
) : null}
</div>
<div className="ml-auto flex items-center gap-1">
{indicatorIcons.map(({ id, icon: Icon, label }) => (
<span
key={id}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
aria-label={label}
role="img"
title={label}
>
<Icon className="h-3 w-3" />
</span>
))}
</div>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
</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 flex-1',
'cursor-pointer hover:opacity-70',
buttonHeight
)}
>
{currentProviderId ? (
<ProviderLogo
providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')}
/>
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span className="model-controls__model-label typography-micro font-medium truncate min-w-0 flex-1">
{getCurrentModelDisplayName()}
</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 enabledTools = Object.entries(currentAgent.tools || {})
.filter(([, enabled]) => enabled)
.map(([tool]) => tool)
.sort();
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 getPermissionIcon = (permission?: string) => {
const mode: EditPermissionMode =
permission === 'full' || permission === 'allow' || permission === 'deny' ? permission : 'ask';
return renderEditModeIcon(mode, 'h-3.5 w-3.5');
};
const getPermissionLabel = (permission?: string) => {
if (permission === 'full') return 'Full';
if (permission === 'allow') return 'Allow';
if (permission === 'deny') return 'Deny';
return 'Ask';
};
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">Tools</span>
{enabledTools.length > 0 ? (
<div className="flex flex-wrap gap-1 leading-tight">
{enabledTools.map((tool) => (
<span
key={tool}
className="inline-flex items-center rounded-lg bg-muted/60 px-1.5 py-0.5 typography-meta text-foreground"
>
{tool}
</span>
))}
</div>
) : (
<span className="typography-meta text-muted-foreground">All enabled</span>
)}
</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">
{getPermissionIcon(currentAgent.permission?.edit)}
<span className="typography-meta font-medium text-foreground w-12">
{getPermissionLabel(currentAgent.permission?.edit)}
</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">
{getPermissionIcon(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
<span className="typography-meta font-medium text-foreground w-12">
{getPermissionLabel(typeof currentAgent.permission?.bash === 'string' ? currentAgent.permission.bash : undefined)}
</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">
{getPermissionIcon(currentAgent.permission?.webfetch)}
<span className="typography-meta font-medium text-foreground w-12">
{getPermissionLabel(currentAgent.permission?.webfetch)}
</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 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',
buttonHeight
)}>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
currentAgentName ? '' : 'text-muted-foreground'
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
/>
<span
className={cn(
controlTextSize,
'font-medium min-w-0 truncate'
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
{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',
isCompact && 'ml-1'
)}
>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
currentAgentName ? '' : 'text-muted-foreground'
)}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
/>
<span
className={cn('model-controls__agent-label', controlTextSize, 'font-medium truncate min-w-0')}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</button>
);
};
const inlineClassName = cn('@container/model-controls flex items-center min-w-0', inlineGapClass, className);
return (
<>
<div className={inlineClassName}>
<div className={cn('flex items-center min-w-0', !isCompact ? 'flex-1 min-w-0' : undefined)}>
{renderModelSelector()}
</div>
<div className={cn('flex items-center min-w-0', inlineGapClass)}>
{renderAgentSelector()}
</div>
</div>
{renderMobileModelPanel()}
{renderMobileAgentPanel()}
{renderMobileModelTooltip()}
{renderMobileAgentTooltip()}
</>
);
};