perf(ui): migrate icons to SVG sprite system

Replace @remixicon/react with a shared Icon component that renders
via <use href> references to a single hidden SVG sprite. This reduces
DOM node count by replacing inline SVGs with lightweight references.

- Create Icon component with sprite injection (packages/ui/src/components/icon/)
- Migrate all 164 files from @remixicon/react to Icon component
- Auto-generate sprite data from remixicon bundle (scripts/generate-icon-sprite.mjs)
- Add bun run icons:generate to package.json
- Move @remixicon/react to devDependencies
- Add icon usage instructions to theme-system skill
This commit is contained in:
Bohdan Triapitsyn
2026-05-13 13:26:35 +03:00
parent b4cbd7f0d6
commit 14357257ae
173 changed files with 2342 additions and 2227 deletions
@@ -10,7 +10,6 @@ import { useDirectorySync } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useDeviceInfo } from '@/lib/device';
import { opencodeClient } from '@/lib/opencode/client';
import { RiCloseLine, RiInformationLine, RiRobot2Line, RiSubtractLine, RiUser3Line, RiFolderLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { ModelSelector } from './ModelSelector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -24,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
type PermissionAction = 'allow' | 'ask' | 'deny';
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
@@ -182,7 +182,6 @@ const buildPermissionConfigWithGlobal = (
return result as AgentConfig['permission'];
};
export const AgentsPage: React.FC = () => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
@@ -305,7 +304,6 @@ export const AgentsPage: React.FC = () => {
currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action
), [currentRuleMap]);
const getPatternRules = React.useCallback((permissionName: string): PermissionRule[] => (
permissionRules
.filter((rule) => rule.permission === permissionName && rule.pattern !== '*')
@@ -323,7 +321,6 @@ export const AgentsPage: React.FC = () => {
return Array.from(names).sort((a, b) => a.localeCompare(b));
}, [knownPermissionNames]);
const getPermissionSummary = React.useCallback((permissionName: string) => {
const defaultAction = permissionName === '*'
? globalPermission
@@ -608,12 +605,11 @@ export const AgentsPage: React.FC = () => {
}
};
if (!selectedAgentName) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiRobot2Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
<Icon name="robot-2" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.agents.page.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.agents.page.empty.description')}</p>
</div>
@@ -669,13 +665,13 @@ export const AgentsPage: React.FC = () => {
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<RiUser3Line className="h-3.5 w-3.5" />
<Icon name="user-3" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.global')}</span>
</div>
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<RiFolderLine className="h-3.5 w-3.5" />
<Icon name="folder" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.project')}</span>
</div>
</SelectItem>
@@ -704,7 +700,7 @@ export const AgentsPage: React.FC = () => {
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.mode')}</span>
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.agents.page.field.modeTooltip')}
@@ -781,7 +777,7 @@ export const AgentsPage: React.FC = () => {
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.temperature')}</span>
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.agents.page.field.temperatureTooltip')}
@@ -813,7 +809,7 @@ export const AgentsPage: React.FC = () => {
aria-label={t('settings.agents.page.field.clearTemperatureAria')}
title={t('settings.common.actions.clear')}
>
<RiCloseLine className="h-3.5 w-3.5" />
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
)}
</div>
@@ -825,7 +821,7 @@ export const AgentsPage: React.FC = () => {
<span className="typography-ui-label text-foreground">{t('settings.agents.page.field.topP')}</span>
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.agents.page.field.topPTooltip')}
@@ -857,7 +853,7 @@ export const AgentsPage: React.FC = () => {
aria-label={t('settings.agents.page.field.clearTopPAria')}
title={t('settings.common.actions.clear')}
>
<RiCloseLine className="h-3.5 w-3.5" />
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
)}
</div>
@@ -981,7 +977,7 @@ export const AgentsPage: React.FC = () => {
onClick={() => revertRule(permissionName, '*')}
className="px-1.5 py-0 h-5"
>
<RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
<Icon name="subtract" className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</Button>
)}
</div>
@@ -1028,7 +1024,7 @@ export const AgentsPage: React.FC = () => {
onClick={() => isAdded ? removeRule(rule.permission, rule.pattern) : revertRule(rule.permission, rule.pattern)}
className="px-1.5 py-0 h-5"
>
<RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
<Icon name="subtract" className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</Button>
)}
</div>
@@ -17,7 +17,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine, RiRestartLine, RiEditLine } from '@remixicon/react';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
@@ -25,6 +24,7 @@ import type { Agent } from '@opencode-ai/sdk/v2';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
interface AgentsSidebarProps {
@@ -228,7 +228,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
});
setSelectedAgent(newName);
};
const handleOpenRenameDialog = (agent: Agent) => {
@@ -293,11 +292,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const getAgentModeIcon = (mode?: string) => {
switch (mode) {
case 'primary':
return <RiAiAgentLine className="h-3 w-3 text-primary" />;
return <Icon name="ai-agent" className="h-3 w-3 text-primary" />;
case 'all':
return <RiAiAgentFill className="h-3 w-3 text-primary" />;
return <Icon name="ai-agent-fill" className="h-3 w-3 text-primary" />;
case 'subagent':
return <RiRobotLine className="h-3 w-3 text-primary" />;
return <Icon name="robot" className="h-3 w-3 text-primary" />;
default:
return null;
}
@@ -339,7 +338,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="h-3.5 w-3.5" />
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</div>
</div>
@@ -347,7 +346,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{visibleAgents.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiRobot2Line className="mx-auto mb-3 h-10 w-10 opacity-50" />
<Icon name="robot-2" className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">{t('settings.agents.sidebar.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.agents.sidebar.empty.description')}</p>
</div>
@@ -580,7 +579,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
variant="ghost"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
<Icon name="more-2" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
@@ -591,7 +590,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
onRename();
}}
>
<RiEditLine className="h-4 w-4 mr-px" />
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</DropdownMenuItem>
)}
@@ -602,7 +601,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
onDuplicate();
}}
>
<RiFileCopyLine className="h-4 w-4 mr-px" />
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</DropdownMenuItem>
@@ -613,7 +612,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
onReset();
}}
>
<RiRestartLine className="h-4 w-4 mr-px" />
<Icon name="restart" className="h-4 w-4 mr-px" />
{t('settings.common.actions.reset')}
</DropdownMenuItem>
)}
@@ -626,7 +625,7 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</DropdownMenuItem>
)}
@@ -11,11 +11,11 @@ import { Input } from '@/components/ui/input';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiLoader4Line, RiPencilAiLine, RiSearchLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import { useModelLists } from '@/hooks/useModelLists';
import type { ModelMetadata } from '@/types';
import { useI18n } from '@/lib/i18n';
@@ -204,7 +204,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{isSelected && (
<RiCheckLine className="h-4 w-4 text-primary" />
<Icon name="check" className="h-4 w-4 text-primary" />
)}
<button
onClick={(e) => {
@@ -220,9 +220,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
title={isFavorite ? t('settings.agents.modelSelector.actions.removeFromFavorites') : t('settings.agents.modelSelector.actions.addToFavorites')}
>
{isFavorite ? (
<RiStarFill className="h-3.5 w-3.5" />
<Icon name="star-fill" className="h-3.5 w-3.5" />
) : (
<RiStarLine className="h-3.5 w-3.5" />
<Icon name="star" className="h-3.5 w-3.5" />
)}
</button>
</div>
@@ -319,7 +319,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
className="model-favorite-button flex h-8 w-8 items-center justify-center text-primary hover:text-primary/80 active:scale-95 touch-manipulation"
aria-label={t('settings.agents.modelSelector.actions.unfavorite')}
>
<RiStarFill className="h-4 w-4" />
<Icon name="star-fill" className="h-4 w-4" />
</button>
</div>
);
@@ -373,7 +373,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
className="model-favorite-button flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-primary/80 active:scale-95 touch-manipulation"
aria-label={t('settings.agents.modelSelector.actions.favorite')}
>
<RiStarLine className="h-4 w-4" />
<Icon name="star" className="h-4 w-4" />
</button>
</div>
);
@@ -409,9 +409,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
)}
</div>
{isExpanded ? (
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
<Icon name="arrow-down-s" className="h-3 w-3 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-3 w-3 text-muted-foreground" />
<Icon name="arrow-right-s" className="h-3 w-3 text-muted-foreground" />
)}
</button>
@@ -458,9 +458,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
: t('settings.agents.modelSelector.actions.favorite')}
>
{isFavoriteModel(provider.id as string, modelItem.id as string) ? (
<RiStarFill className="h-4 w-4" />
<Icon name="star-fill" className="h-4 w-4" />
) : (
<RiStarLine className="h-4 w-4" />
<Icon name="star" className="h-4 w-4" />
)}
</button>
@@ -508,7 +508,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<div className="flex items-center gap-2">
{!isReady ? (
<>
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="typography-meta text-muted-foreground">{isUnavailable ? t('common.unavailable') : t('common.loading')}</span>
</>
) : providerId ? (
@@ -517,7 +517,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
className="h-3.5 w-3.5"
/>
) : (
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
<Icon name="pencil-ai" className="h-3 w-3 text-muted-foreground" />
)}
{isReady && (
<span className="typography-meta font-medium text-foreground">
@@ -525,7 +525,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</span>
)}
</div>
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
<Icon name="arrow-down-s" className="h-3 w-3 text-muted-foreground" />
</button>
) : (
<DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}>
@@ -536,7 +536,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
)}>
{!isReady ? (
<>
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-muted-foreground flex-shrink-0" />
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label font-normal whitespace-nowrap text-muted-foreground">
{isUnavailable ? t('common.unavailable') : t('common.loading')}
</span>
@@ -549,17 +549,17 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
providerId={providerId}
className="h-3.5 w-3.5 flex-shrink-0"
/>
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
<Icon name="pencil-ai" className="h-3 w-3 text-primary/60 hidden" />
</>
) : (
<RiPencilAiLine className="h-3.5 w-3.5 text-muted-foreground" />
<Icon name="pencil-ai" className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-ui-label font-normal whitespace-nowrap text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'))}
</span>
</>
)}
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" align="start">
@@ -619,7 +619,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{/* 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" />
<Icon name="search" 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={t('settings.agents.modelSelector.searchPlaceholder')}
@@ -642,10 +642,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
)}
onSelect={() => handleProviderAndModelChange('', '')}
>
<RiCloseLine className="h-3.5 w-3.5 text-muted-foreground" />
<Icon name="close" className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">{placeholder || t('settings.agents.modelSelector.notSelected')}</span>
{!providerId && !modelId && (
<RiCheckLine className="h-4 w-4 text-primary ml-auto" />
<Icon name="check" className="h-4 w-4 text-primary ml-auto" />
)}
</DropdownMenuItem>
@@ -661,7 +661,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{filteredFavorites.length > 0 && (
<div>
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
<RiStarFill className="h-4 w-4 text-primary" />
<Icon name="star-fill" className="h-4 w-4 text-primary" />
{t('settings.agents.modelSelector.section.favorites')}
</DropdownMenuLabel>
{filteredFavorites.map(({ model, providerID, modelID }) => {
@@ -676,7 +676,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<div>
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
<RiTimeLine className="h-4 w-4" />
<Icon name="time" className="h-4 w-4" />
{t('settings.agents.modelSelector.section.recent')}
</DropdownMenuLabel>
{filteredRecents.map(({ model, providerID, modelID }) => {