feat: add UI customization and model management features (#60)

* feat: add UI customization and model management features

Add comprehensive UI customization options and enhanced model selection:

- **Favorite & Recent Models**: Star models for quick access, track 5 most recent
  - Add favorite/recent sections to model dropdowns
  - Persist preferences in local storage
  - Works in ModelControls and ModelSelector components

- **Tool Call Expansion Settings**: Control default expansion state for tool outputs
  - Three modes: collapsed, activity (summary), detailed
  - Applies to activity groups and individual tool calls
  - Configurable in Appearance Settings

- **Font Size & Spacing Controls**: Adjustable typography and layout density
  - Font size: 50-200% scaling of all semantic typography
  - Spacing/padding: 50-200% scaling of margins, gaps, line heights
  - Real-time preview with reset buttons
  - Settings in Appearance Settings

- **VSCode Extension Settings View**: Full settings access in VSCode extension
  - Add settings navigation and view type
  - Settings button in header
  - Navigate between sessions, chat, and settings

Technical changes:
- Enhanced useUIStore with new state management
- Dynamic CSS variable scaling for typography and spacing
- Typography helper functions for variable access
- ThemeProvider integration for auto-applying scales

* fix: hide theme mode in VSCode and fix detailed tool expansion

- Hide "Theme Mode" setting in VSCode extension settings as VSCode
  dynamically applies its own theme to the extension
- Fix bug where tools from subsequent messages in a turn weren't
  expanded when "Detailed" mode was selected
- Now correctly aggregates all tool IDs from turnGroupingContext when
  calculating effective expanded tools for progressive groups
This commit is contained in:
theblazehen
2025-12-15 18:11:38 +02:00
committed by GitHub
parent 42e25d03f3
commit 235f80643e
14 changed files with 1192 additions and 86 deletions
@@ -3,6 +3,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@@ -11,11 +12,12 @@ import {
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine, 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 { useModelLists } from '@/hooks/useModelLists';
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
@@ -34,6 +36,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}) => {
const { providers, modelsMetadata } = useConfigStore();
const isMobile = useUIStore(state => state.isMobile);
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile: deviceIsMobile } = useDeviceInfo();
const isActuallyMobile = isMobile || deviceIsMobile;
@@ -53,6 +57,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
});
};
const getModelDisplayName = (model: Record<string, unknown>) => {
const name = model?.name || model?.id || '';
const nameStr = String(name);
@@ -69,6 +75,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => {
onChange(newProviderId, newModelId);
// Add to recent models on successful selection
addRecentModel(newProviderId, newModelId);
};
const renderMobileModelPanel = () => {
@@ -81,6 +89,126 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
title="Select Model"
>
<div className="space-y-1">
{/* Favorites Section for Mobile */}
{favoriteModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95 mb-2">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Favorites
</div>
<div className="border-t border-border/20">
{favoriteModelsList.map(({ model, providerID, modelID }) => {
const isSelectedModel = providerID === providerId && modelID === modelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<div
key={`fav-mobile-${providerID}-${modelID}`}
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
>
<button
type="button"
className="flex-1 flex flex-col min-w-0 mr-2"
onClick={() => {
handleProviderAndModelChange(providerID, modelID);
closeMobilePanel();
}}
>
<div className="flex items-center gap-2">
<ProviderLogo
providerId={providerID}
className="h-3 w-3 flex-shrink-0"
/>
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
</div>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-8 w-8 items-center justify-center text-yellow-500 hover:text-yellow-600 active:scale-95 touch-manipulation"
aria-label="Unfavorite"
>
<RiStarFill className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Recents Section for Mobile */}
{recentModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95 mb-2">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Recents
</div>
<div className="border-t border-border/20">
{recentModelsList.map(({ model, providerID, modelID }) => {
const isSelectedModel = providerID === providerId && modelID === modelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<div
key={`recent-mobile-${providerID}-${modelID}`}
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
>
<button
type="button"
className="flex-1 flex flex-col min-w-0 mr-2"
onClick={() => {
handleProviderAndModelChange(providerID, modelID);
closeMobilePanel();
}}
>
<div className="flex items-center gap-2">
<ProviderLogo
providerId={providerID}
className="h-3 w-3 flex-shrink-0"
/>
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
</div>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-yellow-600 active:scale-95 touch-manipulation"
aria-label="Favorite"
>
<RiStarLine className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
if (providerModels.length === 0) return null;
@@ -121,31 +249,57 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
return (
<button
<div
key={modelItem.id as string}
type="button"
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
onClick={() => {
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
closeMobilePanel();
}}
>
<div className="flex flex-col">
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
<button
type="button"
className="flex-1 flex flex-col min-w-0 mr-2"
onClick={() => {
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
closeMobilePanel();
}}
>
<span className="font-medium truncate">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground">
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, modelItem.id as string);
}}
className={cn(
"flex h-8 w-8 items-center justify-center active:scale-95 touch-manipulation",
isFavoriteModel(provider.id as string, modelItem.id as string)
? "text-yellow-500"
: "text-muted-foreground/50"
)}
aria-label={isFavoriteModel(provider.id as string, modelItem.id as string) ? "Unfavorite" : "Favorite"}
>
{isFavoriteModel(provider.id as string, modelItem.id as string) ? (
<RiStarFill className="h-4 w-4" />
) : (
<RiStarLine className="h-4 w-4" />
)}
</button>
{isSelectedModel && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
{isSelectedModel && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</button>
</div>
);
})}
</div>
@@ -220,6 +374,125 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[300px]">
{/* Favorites Section */}
{favoriteModelsList.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta">
<RiStarFill className="h-3 w-3 flex-shrink-0 mr-2 text-yellow-500" />
Favorites
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
className="space-y-1 p-1"
>
{favoriteModelsList.map(({ model, providerID, modelID }) => {
const metadata = getModelMetadata(providerID, modelID);
return (
<DropdownMenuItem
key={`fav-${providerID}-${modelID}`}
className="typography-meta"
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID);
}}
>
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-4 w-4 items-center justify-center text-yellow-500 hover:text-yellow-600"
aria-label="Unfavorite"
>
<RiStarFill className="h-3.5 w-3.5" />
</button>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Recents Section */}
{recentModelsList.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta">
<RiTimeLine className="h-3 w-3 flex-shrink-0 mr-2 text-muted-foreground" />
Recent
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
className="space-y-1 p-1"
>
{recentModelsList.map(({ model, providerID, modelID }) => {
const metadata = getModelMetadata(providerID, modelID);
return (
<DropdownMenuItem
key={`recent-${providerID}-${modelID}`}
className="typography-meta"
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID);
}}
>
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-yellow-600"
aria-label="Favorite"
>
<RiStarLine className="h-3.5 w-3.5" />
</button>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Separator before providers */}
{(favoriteModelsList.length > 0 || recentModelsList.length > 0) && (
<DropdownMenuSeparator />
)}
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
@@ -265,17 +538,40 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<DropdownMenuItem
key={modelItem.id as string}
className="typography-meta"
onSelect={() => {
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
}}
>
<div className="flex flex-col">
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-meta text-muted-foreground">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, modelItem.id as string);
}}
className={cn(
"flex h-4 w-4 items-center justify-center hover:text-yellow-600",
isFavoriteModel(provider.id as string, modelItem.id as string)
? "text-yellow-500"
: "text-muted-foreground"
)}
aria-label={isFavoriteModel(provider.id as string, modelItem.id as string) ? "Unfavorite" : "Favorite"}
>
{isFavoriteModel(provider.id as string, modelItem.id as string) ? (
<RiStarFill className="h-3.5 w-3.5" />
) : (
<RiStarLine className="h-3.5 w-3.5" />
)}
</button>
</div>
</DropdownMenuItem>
);
@@ -4,6 +4,7 @@ import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import { ButtonSmall } from '@/components/ui/button-small';
import { isVSCodeRuntime } from '@/lib/desktop';
interface Option<T extends string> {
id: T;
@@ -26,6 +27,12 @@ const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
},
];
const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed'; label: string; description: string }> = [
{ value: 'collapsed', label: 'Collapsed', description: 'Activity and tools start collapsed' },
{ value: 'activity', label: 'Summary', description: 'Activity expanded, tools collapsed' },
{ value: 'detailed', label: 'Detailed', description: 'Activity and tools expanded' },
];
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
{
id: 'dynamic',
@@ -47,6 +54,12 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
export const AppearanceSettings: React.FC = () => {
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const toolCallExpansion = useUIStore(state => state.toolCallExpansion);
const setToolCallExpansion = useUIStore(state => state.setToolCallExpansion);
const fontSize = useUIStore(state => state.fontSize);
const setFontSize = useUIStore(state => state.setFontSize);
const padding = useUIStore(state => state.padding);
const setPadding = useUIStore(state => state.setPadding);
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const {
@@ -56,22 +69,119 @@ export const AppearanceSettings: React.FC = () => {
return (
<div className="w-full space-y-8">
{}
{!isVSCodeRuntime() && (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Theme Mode
</h3>
</div>
<div className="flex gap-1 w-fit">
{THEME_MODE_OPTIONS.map((option) => (
<ButtonSmall
key={option.value}
variant={themeMode === option.value ? 'default' : 'outline'}
className={cn(themeMode === option.value ? undefined : 'text-foreground')}
onClick={() => setThemeMode(option.value)}
>
{option.label}
</ButtonSmall>
))}
</div>
</div>
)}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Theme Mode
Font Size
</h3>
<p className="typography-meta text-muted-foreground">
{fontSize}% of default size
</p>
</div>
<div className="flex items-center gap-4">
<input
type="range"
min="50"
max="200"
step="5"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/>
<input
type="number"
min="50"
max="200"
step="5"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-20 px-2 py-1 text-center border border-border rounded bg-background text-foreground typography-ui-label"
/>
<button
onClick={() => setFontSize(100)}
className="px-2 py-1 text-xs border border-border rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
Reset
</button>
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Spacing
</h3>
<p className="typography-meta text-muted-foreground">
{padding}% of default spacing
</p>
</div>
<div className="flex items-center gap-4">
<input
type="range"
min="50"
max="200"
step="5"
value={padding}
onChange={(e) => setPadding(Number(e.target.value))}
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
/>
<input
type="number"
min="50"
max="200"
step="5"
value={padding}
onChange={(e) => setPadding(Number(e.target.value))}
className="w-20 px-2 py-1 text-center border border-border rounded bg-background text-foreground typography-ui-label"
/>
<button
onClick={() => setPadding(100)}
className="px-2 py-1 text-xs border border-border rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
Reset
</button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Default Tool Output
</h3>
<p className="typography-meta text-muted-foreground">
{TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description}
</p>
</div>
<div className="flex gap-1 w-fit">
{THEME_MODE_OPTIONS.map((option) => (
{TOOL_EXPANSION_OPTIONS.map((option) => (
<ButtonSmall
key={option.value}
variant={themeMode === option.value ? 'default' : 'outline'}
className={cn(themeMode === option.value ? undefined : 'text-foreground')}
onClick={() => setThemeMode(option.value)}
variant={toolCallExpansion === option.value ? 'default' : 'outline'}
className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')}
onClick={() => setToolCallExpansion(option.value)}
>
{option.label}
</ButtonSmall>