2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import type { ComponentType } from 'react';
|
2025-12-13 16:59:03 +02:00
|
|
|
import {
|
|
|
|
|
RiAiAgentLine,
|
|
|
|
|
RiArrowDownSLine,
|
|
|
|
|
RiArrowRightSLine,
|
|
|
|
|
RiBrainAi3Line,
|
2026-01-02 13:08:14 +02:00
|
|
|
RiCheckLine,
|
2025-12-13 16:59:03 +02:00
|
|
|
RiCheckboxCircleLine,
|
|
|
|
|
RiCloseCircleLine,
|
|
|
|
|
RiFileImageLine,
|
|
|
|
|
RiFileMusicLine,
|
|
|
|
|
RiFilePdfLine,
|
|
|
|
|
RiFileVideoLine,
|
|
|
|
|
RiPencilAiLine,
|
|
|
|
|
RiQuestionLine,
|
|
|
|
|
RiSearchLine,
|
2025-12-15 18:11:38 +02:00
|
|
|
RiStarFill,
|
|
|
|
|
RiStarLine,
|
2025-12-13 16:59:03 +02:00
|
|
|
RiText,
|
2025-12-15 18:11:38 +02:00
|
|
|
RiTimeLine,
|
2025-12-13 16:59:03 +02:00
|
|
|
RiToolsLine,
|
|
|
|
|
} from '@remixicon/react';
|
|
|
|
|
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
|
|
|
|
import type { ModelMetadata } from '@/types';
|
2025-12-07 19:32:53 +02:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
2026-01-02 13:08:14 +02:00
|
|
|
DropdownMenuLabel,
|
2025-12-07 19:32:53 +02:00
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
2025-12-13 16:34:17 +02:00
|
|
|
import { Input } from '@/components/ui/input';
|
2025-12-13 16:59:03 +02:00
|
|
|
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';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
2025-12-13 16:59:03 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useContextStore } from '@/stores/contextStore';
|
|
|
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
2025-12-15 18:11:38 +02:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { useModelLists } from '@/hooks/useModelLists';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
currentAgentName,
|
|
|
|
|
setProvider,
|
|
|
|
|
setModel,
|
|
|
|
|
setAgent,
|
|
|
|
|
getCurrentProvider,
|
|
|
|
|
getModelMetadata,
|
|
|
|
|
getCurrentAgent,
|
2025-12-18 19:02:42 +02:00
|
|
|
getVisibleAgents,
|
2025-12-07 19:32:53 +02:00
|
|
|
} = useConfigStore();
|
|
|
|
|
|
2025-12-18 19:02:42 +02:00
|
|
|
// Use visible agents (excludes hidden internal agents)
|
|
|
|
|
const agents = getVisibleAgents();
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const {
|
|
|
|
|
currentSessionId,
|
|
|
|
|
messages,
|
|
|
|
|
saveSessionAgentSelection,
|
|
|
|
|
getSessionAgentSelection,
|
|
|
|
|
saveAgentModelForSession,
|
|
|
|
|
getAgentModelForSession,
|
|
|
|
|
analyzeAndSaveExternalSessionChoices,
|
|
|
|
|
getSessionAgentEditMode,
|
|
|
|
|
setSessionAgentEditMode,
|
|
|
|
|
} = useSessionStore();
|
|
|
|
|
|
|
|
|
|
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
2026-01-02 13:08:14 +02:00
|
|
|
const { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore();
|
2025-12-15 18:11:38 +02:00
|
|
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const { isMobile } = useDeviceInfo();
|
2025-12-13 16:59:03 +02:00
|
|
|
const isDesktopRuntime = useIsDesktopRuntime();
|
2025-12-13 16:34:17 +02:00
|
|
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
2026-01-02 13:08:14 +02:00
|
|
|
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
|
|
|
|
const isCompact = isMobile;
|
2025-12-07 19:32:53 +02:00
|
|
|
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | null>(null);
|
|
|
|
|
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
|
2025-12-13 16:34:17 +02:00
|
|
|
const [mobileModelQuery, setMobileModelQuery] = React.useState('');
|
2025-12-07 19:32:53 +02:00
|
|
|
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);
|
2026-01-02 13:08:14 +02:00
|
|
|
// Use global state for model selector (allows Ctrl+M shortcut)
|
|
|
|
|
const agentMenuOpen = isModelSelectorOpen;
|
|
|
|
|
const setAgentMenuOpen = setModelSelectorOpen;
|
2025-12-07 19:32:53 +02:00
|
|
|
const [desktopEditOptionsOpen, setDesktopEditOptionsOpen] = React.useState(false);
|
|
|
|
|
const desktopEditOptionsId = React.useId();
|
2026-01-02 13:08:14 +02:00
|
|
|
const [desktopModelQuery, setDesktopModelQuery] = React.useState('');
|
|
|
|
|
const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0);
|
|
|
|
|
const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
if (activeMobilePanel !== 'model') {
|
|
|
|
|
setMobileModelQuery('');
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [activeMobilePanel]);
|
|
|
|
|
|
2026-01-02 13:08:14 +02:00
|
|
|
const prevAgentMenuOpenRef = React.useRef(agentMenuOpen);
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
2026-01-02 13:08:14 +02:00
|
|
|
const wasOpen = prevAgentMenuOpenRef.current;
|
|
|
|
|
prevAgentMenuOpenRef.current = agentMenuOpen;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
if (!agentMenuOpen) {
|
|
|
|
|
setDesktopEditOptionsOpen(false);
|
2026-01-02 13:08:14 +02:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-01-02 13:08:14 +02:00
|
|
|
}, [agentMenuOpen, isCompact]);
|
|
|
|
|
|
|
|
|
|
// Reset selected index when search query changes
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
setModelSelectedIndex(0);
|
|
|
|
|
}, [desktopModelQuery]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2025-12-15 13:15:45 +02:00
|
|
|
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';
|
2025-12-13 16:34:17 +02:00
|
|
|
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
|
2025-12-15 13:15:45 +02:00
|
|
|
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-2' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3';
|
2025-12-07 19:32:53 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
if (isCompact) {
|
2025-12-07 19:32:53 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2025-12-15 18:11:38 +02:00
|
|
|
// Add to recent models on successful selection
|
|
|
|
|
addRecentModel(providerId, modelId);
|
2025-12-16 13:14:28 +02:00
|
|
|
setAgentMenuOpen(false);
|
2025-12-13 16:34:17 +02:00
|
|
|
if (isCompact) {
|
2025-12-07 19:32:53 +02:00
|
|
|
closeMobilePanel();
|
|
|
|
|
}
|
2026-01-02 13:08:14 +02:00
|
|
|
// Restore focus to chat input after model selection
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
|
|
|
|
textarea?.focus();
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
} 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 = () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!isCompact || mobileTooltipOpen !== 'model') return null;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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 = () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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 = () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
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);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<MobileOverlayPanel
|
|
|
|
|
open={activeMobilePanel === 'model'}
|
|
|
|
|
onClose={closeMobilePanel}
|
|
|
|
|
title="Select model"
|
|
|
|
|
>
|
2025-12-13 16:34:17 +02:00
|
|
|
<div className="flex flex-col gap-2">
|
2025-12-21 01:56:22 +02:00
|
|
|
<div>
|
2025-12-13 16:34:17 +02:00
|
|
|
<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"
|
2025-12-21 01:56:22 +02:00
|
|
|
className="pl-7 h-9 rounded-xl border-border/40 bg-background/95 typography-meta"
|
2025-12-13 16:34:17 +02:00
|
|
|
/>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-15 18:11:38 +02:00
|
|
|
{/* Favorites Section for Mobile */}
|
|
|
|
|
{!mobileModelQuery && favoriteModelsList.length > 0 && (
|
2025-12-21 01:56:22 +02:00
|
|
|
<div className="rounded-xl border border-border/40 bg-background/95 overflow-hidden">
|
2025-12-15 18:11:38 +02:00
|
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
2025-12-16 13:14:28 +02:00
|
|
|
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-primary" />
|
2025-12-15 18:11:38 +02:00
|
|
|
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);
|
2026-01-02 13:08:14 +02:00
|
|
|
|
2025-12-15 18:11:38 +02:00
|
|
|
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',
|
2025-12-21 01:56:22 +02:00
|
|
|
'first:rounded-t-xl last:rounded-b-xl transition-colors',
|
2025-12-19 16:48:25 +02:00
|
|
|
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-muted'
|
2025-12-15 18:11:38 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<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 && (
|
2025-12-21 01:56:22 +02:00
|
|
|
<div className="rounded-xl border border-border/40 bg-background/95 overflow-hidden">
|
2025-12-15 18:11:38 +02:00
|
|
|
<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);
|
2026-01-02 13:08:14 +02:00
|
|
|
|
2025-12-15 18:11:38 +02:00
|
|
|
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',
|
2025-12-21 01:56:22 +02:00
|
|
|
'first:rounded-t-xl last:rounded-b-xl transition-colors',
|
2025-12-19 16:48:25 +02:00
|
|
|
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-muted'
|
2025-12-15 18:11:38 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
{filteredProviders.map(({ provider, providerModels }) => {
|
|
|
|
|
if (providerModels.length === 0 && !normalizedQuery.length) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isActiveProvider = provider.id === currentProviderId;
|
2025-12-13 16:34:17 +02:00
|
|
|
const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return (
|
2025-12-21 01:56:22 +02:00
|
|
|
<div key={provider.id} className="rounded-xl border border-border/40 bg-background/95 overflow-hidden">
|
2025-12-07 19:32:53 +02:00
|
|
|
<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>
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
{isExpanded && providerModels.length > 0 && (
|
2025-12-07 19:32:53 +02:00
|
|
|
<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 (
|
2025-12-15 18:11:38 +02:00
|
|
|
<div
|
2025-12-07 19:32:53 +02:00
|
|
|
key={model.id}
|
|
|
|
|
className={cn(
|
2025-12-15 18:11:38 +02:00
|
|
|
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 last:border-b-0',
|
2025-12-21 01:56:22 +02:00
|
|
|
'rounded-lg transition-colors',
|
|
|
|
|
!isSelected && 'hover:bg-muted',
|
2025-12-07 19:32:53 +02:00
|
|
|
isSelected
|
|
|
|
|
? 'bg-primary/15 text-primary'
|
2025-12-15 18:11:38 +02:00
|
|
|
: ''
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2025-12-15 18:11:38 +02:00
|
|
|
<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',
|
2025-12-21 01:56:22 +02:00
|
|
|
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary'
|
2025-12-13 16:34:17 +02:00
|
|
|
)}
|
2025-12-15 18:11:38 +02:00
|
|
|
>
|
|
|
|
|
<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"
|
2025-12-13 16:34:17 +02:00
|
|
|
)}
|
2025-12-15 18:11:38 +02:00
|
|
|
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>
|
2025-12-07 19:32:53 +02:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</MobileOverlayPanel>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderMobileAgentPanel = () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!isCompact) return null;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-02 13:08:14 +02:00
|
|
|
// 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
|
|
|
|
|
const filterByQuery = (modelName: string, providerName: string, query: string) => {
|
|
|
|
|
if (!query.trim()) return true;
|
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
|
return (
|
|
|
|
|
modelName.toLowerCase().includes(lowerQuery) ||
|
|
|
|
|
providerName.toLowerCase().includes(lowerQuery)
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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}>
|
2025-12-13 16:34:17 +02:00
|
|
|
{!isCompact ? (
|
2025-12-07 19:32:53 +02:00
|
|
|
<DropdownMenu open={agentMenuOpen} onOpenChange={setAgentMenuOpen}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-12-13 16:59:03 +02:00
|
|
|
'model-controls__model-trigger flex items-center gap-1.5 cursor-pointer hover:opacity-70 min-w-0',
|
2025-12-07 19:32:53 +02:00
|
|
|
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}`}
|
2025-12-13 16:34:17 +02:00
|
|
|
className={cn(
|
|
|
|
|
'model-controls__model-label',
|
|
|
|
|
controlTextSize,
|
2025-12-13 16:59:03 +02:00
|
|
|
'font-medium whitespace-nowrap text-foreground truncate min-w-0',
|
2025-12-15 03:04:46 +02:00
|
|
|
'max-w-[260px]'
|
2025-12-13 16:34:17 +02:00
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
{getCurrentModelDisplayName()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
</TooltipTrigger>
|
2026-01-02 13:08:14 +02:00
|
|
|
<DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="start" alignOffset={-200}>
|
|
|
|
|
{/* 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-[400px] 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);
|
2025-12-15 18:11:38 +02:00
|
|
|
})}
|
2026-01-02 13:08:14 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 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);
|
2025-12-15 18:11:38 +02:00
|
|
|
})}
|
2026-01-02 13:08:14 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-02 13:08:14 +02:00
|
|
|
{/* Separator before providers */}
|
|
|
|
|
{(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && (
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-02 13:08:14 +02:00
|
|
|
{/* 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>
|
2025-12-07 19:32:53 +02:00
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setActiveMobilePanel('model')}
|
|
|
|
|
onTouchStart={() => handleLongPressStart('model')}
|
|
|
|
|
onTouchEnd={handleLongPressEnd}
|
|
|
|
|
onTouchCancel={handleLongPressEnd}
|
|
|
|
|
className={cn(
|
2025-12-15 03:04:46 +02:00
|
|
|
'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none',
|
2025-12-13 16:34:17 +02:00
|
|
|
'cursor-pointer hover:opacity-70',
|
2025-12-07 19:32:53 +02:00
|
|
|
buttonHeight
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{currentProviderId ? (
|
|
|
|
|
<ProviderLogo
|
|
|
|
|
providerId={currentProviderId}
|
|
|
|
|
className={cn(controlIconSize, 'flex-shrink-0')}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
|
|
|
|
|
)}
|
2025-12-15 03:04:46 +02:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'model-controls__model-label typography-micro font-medium truncate min-w-0',
|
2025-12-26 02:49:44 +02:00
|
|
|
!isMobile && 'max-w-[220px]',
|
2025-12-15 03:04:46 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2025-12-07 19:32:53 +02:00
|
|
|
{getCurrentModelDisplayName()}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{renderModelTooltipContent()}
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
2026-01-02 13:08:14 +02:00
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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 = () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!isCompact) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
|
|
|
<Tooltip delayDuration={1000}>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<div className={cn(
|
2025-12-13 16:59:03 +02:00
|
|
|
'flex items-center gap-1.5 transition-opacity cursor-pointer hover:opacity-70 min-w-0',
|
2025-12-07 19:32:53 +02:00
|
|
|
buttonHeight
|
|
|
|
|
)}>
|
|
|
|
|
<RiAiAgentLine
|
|
|
|
|
className={cn(
|
|
|
|
|
controlIconSize,
|
|
|
|
|
'flex-shrink-0',
|
|
|
|
|
currentAgentName ? '' : 'text-muted-foreground'
|
|
|
|
|
)}
|
|
|
|
|
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
2025-12-13 16:59:03 +02:00
|
|
|
'model-controls__agent-label',
|
2025-12-07 19:32:53 +02:00
|
|
|
controlTextSize,
|
2025-12-13 16:59:03 +02:00
|
|
|
'font-medium min-w-0 truncate',
|
|
|
|
|
isDesktopRuntime ? 'max-w-[220px]' : undefined
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
|
|
|
|
|
>
|
|
|
|
|
{getAgentDisplayName()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
</TooltipTrigger>
|
2026-01-02 13:08:14 +02:00
|
|
|
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))]">
|
2025-12-07 19:32:53 +02:00
|
|
|
{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(
|
2025-12-13 16:34:17 +02:00
|
|
|
'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
|
2025-12-07 19:32:53 +02:00
|
|
|
buttonHeight,
|
|
|
|
|
'cursor-pointer hover:opacity-70',
|
2025-12-13 16:34:17 +02:00
|
|
|
isCompact && 'ml-1'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<RiAiAgentLine
|
|
|
|
|
className={cn(
|
|
|
|
|
controlIconSize,
|
|
|
|
|
'flex-shrink-0',
|
|
|
|
|
currentAgentName ? '' : 'text-muted-foreground'
|
|
|
|
|
)}
|
|
|
|
|
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<span
|
2025-12-13 16:34:17 +02:00
|
|
|
className={cn('model-controls__agent-label', controlTextSize, 'font-medium truncate min-w-0')}
|
2025-12-07 19:32:53 +02:00
|
|
|
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
|
|
|
|
|
>
|
|
|
|
|
{getAgentDisplayName()}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-15 03:04:46 +02:00
|
|
|
const inlineClassName = cn(
|
|
|
|
|
'@container/model-controls flex items-center min-w-0',
|
|
|
|
|
inlineGapClass,
|
|
|
|
|
// 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,
|
|
|
|
|
);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className={inlineClassName}>
|
2025-12-13 16:59:03 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-12-26 02:49:44 +02:00
|
|
|
'flex items-center min-w-0 flex-1 justify-end',
|
|
|
|
|
isMobile && 'overflow-hidden'
|
2025-12-13 16:59:03 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2025-12-07 19:32:53 +02:00
|
|
|
{renderModelSelector()}
|
|
|
|
|
</div>
|
2025-12-15 03:04:46 +02:00
|
|
|
<div className={cn('flex items-center min-w-0', inlineGapClass, isMobile && 'flex-shrink-0')}>
|
2025-12-07 19:32:53 +02:00
|
|
|
{renderAgentSelector()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{renderMobileModelPanel()}
|
|
|
|
|
{renderMobileAgentPanel()}
|
|
|
|
|
{renderMobileModelTooltip()}
|
|
|
|
|
{renderMobileAgentTooltip()}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
};
|