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:
@@ -107,6 +107,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
|
||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
@@ -302,9 +303,51 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
if (isUser) {
|
||||
return [];
|
||||
}
|
||||
return visibleParts.filter((part) => part.type === 'tool');
|
||||
const filtered = visibleParts.filter((part) => part.type === 'tool');
|
||||
return filtered;
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const effectiveExpandedTools = React.useMemo(() => {
|
||||
// 'collapsed': Activity and tools start collapsed
|
||||
// 'activity': Activity expanded, tools collapsed
|
||||
// 'detailed': Activity and tools expanded
|
||||
|
||||
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
|
||||
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
|
||||
return expandedTools;
|
||||
}
|
||||
|
||||
// 'detailed': Tools default expanded
|
||||
// Collect all relevant tool IDs (from this message and the entire turn if we're rendering a progressive group)
|
||||
const allToolIds = new Set<string>();
|
||||
|
||||
// 1. Add tools from this message
|
||||
for (const part of toolParts) {
|
||||
if (part.id) {
|
||||
allToolIds.add(part.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If we're rendering a progressive group for the turn, include all turn tools
|
||||
if (turnGroupingContext?.isFirstAssistantInTurn) {
|
||||
for (const activity of turnGroupingContext.activityParts) {
|
||||
if (activity.kind === 'tool' && activity.part.id) {
|
||||
allToolIds.add(activity.part.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandedTools contains IDs of tools that ARE collapsed (inverted)
|
||||
// Return a set of all tool IDs EXCEPT those in expandedTools
|
||||
const effective = new Set<string>();
|
||||
for (const id of allToolIds) {
|
||||
if (!expandedTools.has(id)) {
|
||||
effective.add(id);
|
||||
}
|
||||
}
|
||||
return effective;
|
||||
}, [toolCallExpansion, expandedTools, toolParts, turnGroupingContext]);
|
||||
|
||||
const agentMention = React.useMemo(() => {
|
||||
if (!isUser) {
|
||||
return undefined;
|
||||
@@ -707,7 +750,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
hasTouchInput={hasTouchInput}
|
||||
copiedCode={copiedCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
expandedTools={expandedTools}
|
||||
expandedTools={effectiveExpandedTools}
|
||||
onToggleTool={handleToggleTool}
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
RiPencilAiLine,
|
||||
RiQuestionLine,
|
||||
RiSearchLine,
|
||||
RiStarFill,
|
||||
RiStarLine,
|
||||
RiText,
|
||||
RiTimeLine,
|
||||
RiToolsLine,
|
||||
} from '@remixicon/react';
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
@@ -43,6 +46,8 @@ import { cn } from '@/lib/utils';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = ComponentType<any>;
|
||||
@@ -229,6 +234,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
} = useSessionStore();
|
||||
|
||||
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
||||
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
|
||||
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isDesktopRuntime = useIsDesktopRuntime();
|
||||
@@ -769,6 +776,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Add to recent models on successful selection
|
||||
addRecentModel(providerId, modelId);
|
||||
if (isCompact) {
|
||||
closeMobilePanel();
|
||||
}
|
||||
@@ -1147,6 +1156,96 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites Section for Mobile */}
|
||||
{!mobileModelQuery && favoriteModelsList.length > 0 && (
|
||||
<div className="rounded-xl border border-border/40 bg-background/95">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-yellow-500" />
|
||||
Favorites
|
||||
</div>
|
||||
<div className="flex flex-col border-t border-border/30">
|
||||
{favoriteModelsList.map(({ model, providerID, modelID }) => {
|
||||
const isSelected = providerID === currentProviderId && modelID === currentModelId;
|
||||
const metadata = getModelMetadata(providerID, modelID);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`fav-mobile-${providerID}-${modelID}`}
|
||||
type="button"
|
||||
onClick={() => handleProviderAndModelChange(providerID, modelID)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
|
||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
|
||||
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="typography-meta font-medium text-foreground truncate">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{(metadata?.limit?.context || metadata?.limit?.output) && (
|
||||
<div className="typography-micro text-muted-foreground whitespace-nowrap">
|
||||
{metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
|
||||
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
|
||||
{metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Section for Mobile */}
|
||||
{!mobileModelQuery && recentModelsList.length > 0 && (
|
||||
<div className="rounded-xl border border-border/40 bg-background/95">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<RiTimeLine className="h-3 w-3 inline-block mr-1.5" />
|
||||
Recent
|
||||
</div>
|
||||
<div className="flex flex-col border-t border-border/30">
|
||||
{recentModelsList.map(({ model, providerID, modelID }) => {
|
||||
const isSelected = providerID === currentProviderId && modelID === currentModelId;
|
||||
const metadata = getModelMetadata(providerID, modelID);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`recent-mobile-${providerID}-${modelID}`}
|
||||
type="button"
|
||||
onClick={() => handleProviderAndModelChange(providerID, modelID)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
|
||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
|
||||
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="typography-meta font-medium text-foreground truncate">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{(metadata?.limit?.context || metadata?.limit?.output) && (
|
||||
<div className="typography-micro text-muted-foreground whitespace-nowrap">
|
||||
{metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
|
||||
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
|
||||
{metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProviders.map(({ provider, providerModels }) => {
|
||||
if (providerModels.length === 0 && !normalizedQuery.length) {
|
||||
return null;
|
||||
@@ -1191,47 +1290,76 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
const inputIcons = getModalityIcons(metadata, 'input');
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
|
||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
|
||||
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 last:border-b-0',
|
||||
isSelected
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'hover:bg-accent/40'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-end gap-1 text-right">
|
||||
{(metadata?.limit?.context || metadata?.limit?.output) && (
|
||||
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
|
||||
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
|
||||
{metadata?.limit?.context && metadata?.limit?.output ? <span>•</span> : null}
|
||||
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
|
||||
className={cn(
|
||||
'flex flex-1 min-w-0 items-start gap-2 text-left',
|
||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
|
||||
!isSelected && 'hover:bg-accent/40'
|
||||
)}
|
||||
{(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 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"
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
|
||||
title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{isFavoriteModel(provider.id as string, model.id as string) ? (
|
||||
<RiStarFill className="h-4 w-4" />
|
||||
) : (
|
||||
<RiStarLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1503,6 +1631,190 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<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]"
|
||||
>
|
||||
{favoriteModelsList.map(({ model, providerID, modelID }) => {
|
||||
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 outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
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>
|
||||
{metadata?.limit?.context || metadata?.limit?.output ? (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{metadata?.limit?.context ? `${contextTokens} ctx` : ''}
|
||||
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
|
||||
{metadata?.limit?.output ? `${outputTokens} out` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{indicatorIcons.map(({ id, icon: Icon, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
||||
aria-label={label}
|
||||
role="img"
|
||||
title={label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
<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"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<RiStarFill className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</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]"
|
||||
>
|
||||
{recentModelsList.map(({ model, providerID, modelID }) => {
|
||||
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 outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
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>
|
||||
{metadata?.limit?.context || metadata?.limit?.output ? (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{metadata?.limit?.context ? `${contextTokens} ctx` : ''}
|
||||
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
|
||||
{metadata?.limit?.output ? `${outputTokens} out` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{indicatorIcons.map(({ id, icon: Icon, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
||||
aria-label={label}
|
||||
role="img"
|
||||
title={label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
<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"
|
||||
title="Add to favorites"
|
||||
>
|
||||
<RiStarLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</ScrollableOverlay>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{/* Separator before providers */}
|
||||
{(favoriteModelsList.length > 0 || recentModelsList.length > 0) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
|
||||
{/* All Providers Section */}
|
||||
{providers.map((provider) => {
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
|
||||
@@ -1561,13 +1873,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
className="typography-meta"
|
||||
onSelect={() => {
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleProviderAndModelChange(provider.id as string, model.id as string);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
<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>
|
||||
{metadata?.limit?.context || metadata?.limit?.output ? (
|
||||
@@ -1578,7 +1891,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{indicatorIcons.map(({ id, icon: Icon, label }) => (
|
||||
<span
|
||||
key={id}
|
||||
@@ -1590,6 +1903,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleFavoriteModel(provider.id as string, model.id as string);
|
||||
}}
|
||||
className={cn(
|
||||
"model-favorite-button flex h-4 w-4 items-center justify-center hover:text-yellow-600",
|
||||
isFavoriteModel(provider.id as string, model.id as string)
|
||||
? "text-yellow-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
|
||||
title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{isFavoriteModel(provider.id as string, model.id as string) ? (
|
||||
<RiStarFill className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiStarLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export interface ChatMessageEntry {
|
||||
info: Message;
|
||||
@@ -302,30 +303,34 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
||||
() => new Map()
|
||||
);
|
||||
|
||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||
// Activity group is expanded for 'activity' and 'detailed', collapsed for 'collapsed'
|
||||
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
|
||||
|
||||
const getOrCreateTurnState = React.useCallback(
|
||||
(turnId: string): TurnUiState => {
|
||||
const existing = turnUiStates.get(turnId);
|
||||
if (existing) return existing;
|
||||
return { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||
return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
||||
},
|
||||
[turnUiStates]
|
||||
[turnUiStates, defaultActivityExpanded]
|
||||
);
|
||||
|
||||
const toggleGroup = React.useCallback((turnId: string) => {
|
||||
setTurnUiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
||||
next.set(turnId, { ...current, isExpanded: !current.isExpanded });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [defaultActivityExpanded]);
|
||||
|
||||
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
|
||||
if (partIds.length === 0) return;
|
||||
|
||||
setTurnUiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const state = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||
const state = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
|
||||
const newPreviewed = new Set(state.previewedPartIds);
|
||||
partIds.forEach((id) => {
|
||||
if (id && id.trim().length > 0) {
|
||||
|
||||
@@ -5,10 +5,11 @@ import { ChatView } from '@/components/views';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||
import { RiAddLine, RiArrowLeftLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react';
|
||||
import { RiLoader4Line } from '@remixicon/react';
|
||||
import { SettingsPage } from '@/components/sections/settings/SettingsPage';
|
||||
|
||||
type VSCodeView = 'sessions' | 'chat';
|
||||
type VSCodeView = 'sessions' | 'chat' | 'settings';
|
||||
|
||||
export const VSCodeLayout: React.FC = () => {
|
||||
const [currentView, setCurrentView] = React.useState<VSCodeView>('sessions');
|
||||
@@ -200,7 +201,11 @@ export const VSCodeLayout: React.FC = () => {
|
||||
<div className="h-full w-full bg-background text-foreground flex flex-col">
|
||||
{currentView === 'sessions' ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader title="Sessions" onNewSession={handleNewSession} />
|
||||
<VSCodeHeader
|
||||
title="Sessions"
|
||||
onNewSession={handleNewSession}
|
||||
onSettings={() => setCurrentView('settings')}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SessionSidebar
|
||||
mobileVariant
|
||||
@@ -210,6 +215,17 @@ export const VSCodeLayout: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : currentView === 'settings' ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader
|
||||
title="Settings"
|
||||
showBack
|
||||
onBack={handleBackToSessions}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<VSCodeSettingsView />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader
|
||||
@@ -249,10 +265,11 @@ interface VSCodeHeaderProps {
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
onNewSession?: () => void;
|
||||
onSettings?: () => void;
|
||||
showContextUsage?: boolean;
|
||||
}
|
||||
|
||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, showContextUsage }) => {
|
||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, showContextUsage }) => {
|
||||
const { getCurrentModel } = useConfigStore();
|
||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
||||
|
||||
@@ -285,6 +302,15 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
<RiAddLine className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{onSettings && (
|
||||
<button
|
||||
onClick={onSettings}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<RiSettings3Line className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
|
||||
<ContextUsageDisplay
|
||||
totalTokens={contextUsage.totalTokens}
|
||||
@@ -297,3 +323,14 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VSCodeSettingsView: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<SettingsPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,12 +6,14 @@ interface ThemeProviderProps {
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const { theme, applyTheme } = useUIStore();
|
||||
const { theme, applyTheme, fontSize, applyTypography, padding, applyPadding } = useUIStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
applyTheme();
|
||||
}, [theme, applyTheme]);
|
||||
applyTypography();
|
||||
applyPadding();
|
||||
}, [theme, applyTheme, fontSize, applyTypography, padding, applyPadding]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiPaintBrushLine } from '@remixicon/react';
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiPaintBrushLine, RiStackLine } from '@remixicon/react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings';
|
||||
@@ -32,7 +32,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
|
||||
description: 'Create and maintain custom slash commands for OpenCode.',
|
||||
icon: RiCommandLine,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Providers',
|
||||
description: 'Configure AI model providers and API credentials.',
|
||||
icon: RiStackLine,
|
||||
},
|
||||
{
|
||||
id: 'git-identities',
|
||||
label: 'Git Identities',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { Provider } from '@opencode-ai/sdk';
|
||||
|
||||
type ProviderModel = Provider["models"][string];
|
||||
type ProviderWithModelList = Omit<Provider, "models"> & { models: ProviderModel[] };
|
||||
|
||||
export interface ModelListItem {
|
||||
provider: ProviderWithModelList;
|
||||
model: ProviderModel;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
}
|
||||
|
||||
export const useModelLists = () => {
|
||||
const { providers } = useConfigStore();
|
||||
const { favoriteModels, recentModels } = useUIStore();
|
||||
|
||||
const favoriteModelsList = React.useMemo(() => {
|
||||
return favoriteModels
|
||||
.map(({ providerID, modelID }) => {
|
||||
const provider = providers.find((p) => p.id === providerID);
|
||||
if (!provider) return null;
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
const model = providerModels.find((m: ProviderModel) => m.id === modelID);
|
||||
if (!model) return null;
|
||||
return { provider, model, providerID, modelID };
|
||||
})
|
||||
.filter((item): item is ModelListItem => item !== null);
|
||||
}, [favoriteModels, providers]);
|
||||
|
||||
const recentModelsList = React.useMemo(() => {
|
||||
return recentModels
|
||||
.map(({ providerID, modelID }) => {
|
||||
const provider = providers.find((p) => p.id === providerID);
|
||||
if (!provider) return null;
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
const model = providerModels.find((m: ProviderModel) => m.id === modelID);
|
||||
if (!model) return null;
|
||||
return { provider, model, providerID, modelID };
|
||||
})
|
||||
.filter((item): item is ModelListItem => item !== null)
|
||||
.filter(({ providerID, modelID }) =>
|
||||
!favoriteModels.some(fav => fav.providerID === providerID && fav.modelID === modelID)
|
||||
);
|
||||
}, [recentModels, providers, favoriteModels]);
|
||||
|
||||
return { favoriteModelsList, recentModelsList };
|
||||
};
|
||||
@@ -11,6 +11,7 @@
|
||||
--ui-regular-font-weight: 400;
|
||||
--oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4);
|
||||
--oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6);
|
||||
--padding-scale: 1;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@@ -224,15 +225,31 @@ svg.animate-spin {
|
||||
.chat-column {
|
||||
width: min(100%, 56rem);
|
||||
margin-inline: auto;
|
||||
padding-inline: clamp(1rem, 3vw, 1.5rem);
|
||||
padding-inline: calc(clamp(1rem, 3vw, 1.5rem) * var(--padding-scale, 1));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.chat-column {
|
||||
padding-inline: clamp(1.25rem, 2.5vw, 2rem);
|
||||
padding-inline: calc(clamp(1.25rem, 2.5vw, 2rem) * var(--padding-scale, 1));
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced focus indicators for small interactive elements */
|
||||
.model-favorite-button {
|
||||
position: relative;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.model-favorite-button:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.model-favorite-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* FadeInOnReveal handles all animations - no custom CSS needed */
|
||||
|
||||
/* Heading typography - all use markdown size, differentiated by weight/color */
|
||||
@@ -410,6 +427,43 @@ body {
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
/* Override Tailwind spacing scale with custom properties that respect --padding-scale */
|
||||
--spacing-0: 0;
|
||||
--spacing-0\.5: calc(0.125rem * var(--padding-scale, 1));
|
||||
--spacing-1: calc(0.25rem * var(--padding-scale, 1));
|
||||
--spacing-1\.5: calc(0.375rem * var(--padding-scale, 1));
|
||||
--spacing-2: calc(0.5rem * var(--padding-scale, 1));
|
||||
--spacing-2\.5: calc(0.625rem * var(--padding-scale, 1));
|
||||
--spacing-3: calc(0.75rem * var(--padding-scale, 1));
|
||||
--spacing-3\.5: calc(0.875rem * var(--padding-scale, 1));
|
||||
--spacing-4: calc(1rem * var(--padding-scale, 1));
|
||||
--spacing-5: calc(1.25rem * var(--padding-scale, 1));
|
||||
--spacing-6: calc(1.5rem * var(--padding-scale, 1));
|
||||
--spacing-7: calc(1.75rem * var(--padding-scale, 1));
|
||||
--spacing-8: calc(2rem * var(--padding-scale, 1));
|
||||
--spacing-9: calc(2.25rem * var(--padding-scale, 1));
|
||||
--spacing-10: calc(2.5rem * var(--padding-scale, 1));
|
||||
--spacing-11: calc(2.75rem * var(--padding-scale, 1));
|
||||
--spacing-12: calc(3rem * var(--padding-scale, 1));
|
||||
--spacing-14: calc(3.5rem * var(--padding-scale, 1));
|
||||
--spacing-16: calc(4rem * var(--padding-scale, 1));
|
||||
--spacing-20: calc(5rem * var(--padding-scale, 1));
|
||||
--spacing-24: calc(6rem * var(--padding-scale, 1));
|
||||
--spacing-28: calc(7rem * var(--padding-scale, 1));
|
||||
--spacing-32: calc(8rem * var(--padding-scale, 1));
|
||||
--spacing-36: calc(9rem * var(--padding-scale, 1));
|
||||
--spacing-40: calc(10rem * var(--padding-scale, 1));
|
||||
--spacing-44: calc(11rem * var(--padding-scale, 1));
|
||||
--spacing-48: calc(12rem * var(--padding-scale, 1));
|
||||
--spacing-52: calc(13rem * var(--padding-scale, 1));
|
||||
--spacing-56: calc(14rem * var(--padding-scale, 1));
|
||||
--spacing-60: calc(15rem * var(--padding-scale, 1));
|
||||
--spacing-64: calc(16rem * var(--padding-scale, 1));
|
||||
--spacing-72: calc(18rem * var(--padding-scale, 1));
|
||||
--spacing-80: calc(20rem * var(--padding-scale, 1));
|
||||
--spacing-96: calc(24rem * var(--padding-scale, 1));
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
||||
@@ -7,6 +7,28 @@ export const SEMANTIC_TYPOGRAPHY = {
|
||||
micro: '0.875rem',
|
||||
} as const;
|
||||
|
||||
export const FONT_SIZE_SCALES = {
|
||||
small: {
|
||||
markdown: '0.875rem',
|
||||
code: '0.8125rem',
|
||||
uiHeader: '0.875rem',
|
||||
uiLabel: '0.8125rem',
|
||||
meta: '0.8125rem',
|
||||
micro: '0.75rem',
|
||||
},
|
||||
medium: SEMANTIC_TYPOGRAPHY,
|
||||
large: {
|
||||
markdown: '1rem',
|
||||
code: '0.9375rem',
|
||||
uiHeader: '1rem',
|
||||
uiLabel: '0.9375rem',
|
||||
meta: '0.9375rem',
|
||||
micro: '0.9375rem',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type FontSizeOption = keyof typeof FONT_SIZE_SCALES;
|
||||
|
||||
export const VSCODE_TYPOGRAPHY = {
|
||||
// Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density.
|
||||
markdown: '0.9063rem',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||
|
||||
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal';
|
||||
export type EventStreamStatus =
|
||||
@@ -32,6 +33,13 @@ interface UIStore {
|
||||
eventStreamHint: string | null;
|
||||
showReasoningTraces: boolean;
|
||||
|
||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
||||
fontSize: number;
|
||||
padding: number;
|
||||
|
||||
favoriteModels: Array<{ providerID: string; modelID: string }>;
|
||||
recentModels: Array<{ providerID: string; modelID: string }>;
|
||||
|
||||
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
||||
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
||||
diffWrapLines: boolean;
|
||||
@@ -56,7 +64,15 @@ interface UIStore {
|
||||
setSidebarSection: (section: SidebarSection) => void;
|
||||
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
||||
setShowReasoningTraces: (value: boolean) => void;
|
||||
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setPadding: (size: number) => void;
|
||||
applyTypography: () => void;
|
||||
applyPadding: () => void;
|
||||
updateProportionalSidebarWidths: () => void;
|
||||
toggleFavoriteModel: (providerID: string, modelID: string) => void;
|
||||
isFavoriteModel: (providerID: string, modelID: string) => boolean;
|
||||
addRecentModel: (providerID: string, modelID: string) => void;
|
||||
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
|
||||
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
||||
setDiffWrapLines: (wrap: boolean) => void;
|
||||
@@ -83,6 +99,11 @@ export const useUIStore = create<UIStore>()(
|
||||
eventStreamStatus: 'idle',
|
||||
eventStreamHint: null,
|
||||
showReasoningTraces: false,
|
||||
toolCallExpansion: 'collapsed',
|
||||
fontSize: 100,
|
||||
padding: 100,
|
||||
favoriteModels: [],
|
||||
recentModels: [],
|
||||
diffLayoutPreference: 'dynamic',
|
||||
diffFileLayout: {},
|
||||
diffWrapLines: false,
|
||||
@@ -194,6 +215,78 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ showReasoningTraces: value });
|
||||
},
|
||||
|
||||
setToolCallExpansion: (value) => {
|
||||
set({ toolCallExpansion: value });
|
||||
},
|
||||
|
||||
setFontSize: (size) => {
|
||||
// Clamp between 50% and 200%
|
||||
const clampedSize = Math.max(50, Math.min(200, size));
|
||||
set({ fontSize: clampedSize });
|
||||
get().applyTypography();
|
||||
},
|
||||
|
||||
setPadding: (size) => {
|
||||
// Clamp between 50% and 200%
|
||||
const clampedSize = Math.max(50, Math.min(200, size));
|
||||
set({ padding: clampedSize });
|
||||
get().applyPadding();
|
||||
},
|
||||
|
||||
applyTypography: () => {
|
||||
const { fontSize } = get();
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply font size as a percentage scale
|
||||
// 100 = default (1.0x), 50 = half size (0.5x), 200 = double (2.0x)
|
||||
const scale = fontSize / 100;
|
||||
|
||||
// Store scale for reference
|
||||
root.style.setProperty('--font-scale', scale.toString());
|
||||
|
||||
// Read base values from SEMANTIC_TYPOGRAPHY or use defaults
|
||||
const baseValues: Record<string, string> = {
|
||||
markdown: '0.9375rem',
|
||||
code: '0.9063rem',
|
||||
uiHeader: '0.9375rem',
|
||||
uiLabel: '0.875rem',
|
||||
meta: '0.875rem',
|
||||
micro: '0.875rem',
|
||||
};
|
||||
|
||||
// Apply scaled values to each typography variable
|
||||
Object.entries(baseValues).forEach(([key, baseValue]) => {
|
||||
const cssVar = getTypographyVariable(key as SemanticTypographyKey);
|
||||
const numericValue = parseFloat(baseValue);
|
||||
if (!isNaN(numericValue)) {
|
||||
root.style.setProperty(cssVar, `${numericValue * scale}rem`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
applyPadding: () => {
|
||||
const { padding } = get();
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply padding as a percentage scale with non-linear scaling
|
||||
// Use square root for more natural scaling at extremes
|
||||
const scale = padding / 100;
|
||||
const adjustedScale = Math.sqrt(scale);
|
||||
|
||||
// Set the CSS custom property that all spacing tokens reference
|
||||
root.style.setProperty('--padding-scale', adjustedScale.toString());
|
||||
|
||||
// Apply line height scaling - use much smaller scale factor
|
||||
// Line height should remain relatively constant even when font size changes
|
||||
// Use a dampened scale: 50% font = 0.9x line-height, 200% font = 1.1x line-height
|
||||
const lineHeightScale = 1 + (scale - 1) * 0.15; // Reduces impact: 50% -> 0.925, 200% -> 1.15
|
||||
|
||||
root.style.setProperty('--line-height-tight', (1.25 * lineHeightScale).toFixed(3));
|
||||
root.style.setProperty('--line-height-normal', (1.5 * lineHeightScale).toFixed(3));
|
||||
root.style.setProperty('--line-height-relaxed', (1.625 * lineHeightScale).toFixed(3));
|
||||
root.style.setProperty('--line-height-loose', (2 * lineHeightScale).toFixed(3));
|
||||
},
|
||||
|
||||
setDiffLayoutPreference: (mode) => {
|
||||
set({ diffLayoutPreference: mode });
|
||||
},
|
||||
@@ -211,6 +304,48 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ diffWrapLines: wrap });
|
||||
},
|
||||
|
||||
toggleFavoriteModel: (providerID, modelID) => {
|
||||
set((state) => {
|
||||
const exists = state.favoriteModels.some(
|
||||
(fav) => fav.providerID === providerID && fav.modelID === modelID
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
// Remove from favorites
|
||||
return {
|
||||
favoriteModels: state.favoriteModels.filter(
|
||||
(fav) => !(fav.providerID === providerID && fav.modelID === modelID)
|
||||
),
|
||||
};
|
||||
} else {
|
||||
// Add to favorites (newest first)
|
||||
return {
|
||||
favoriteModels: [{ providerID, modelID }, ...state.favoriteModels],
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
isFavoriteModel: (providerID, modelID) => {
|
||||
const { favoriteModels } = get();
|
||||
return favoriteModels.some(
|
||||
(fav) => fav.providerID === providerID && fav.modelID === modelID
|
||||
);
|
||||
},
|
||||
|
||||
addRecentModel: (providerID, modelID) => {
|
||||
set((state) => {
|
||||
// Remove existing instance if any
|
||||
const filtered = state.recentModels.filter(
|
||||
(m) => !(m.providerID === providerID && m.modelID === modelID)
|
||||
);
|
||||
// Add to front, limit to 5
|
||||
return {
|
||||
recentModels: [{ providerID, modelID }, ...filtered].slice(0, 5),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateProportionalSidebarWidths: () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -254,6 +389,11 @@ export const useUIStore = create<UIStore>()(
|
||||
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
||||
isSettingsDialogOpen: state.isSettingsDialogOpen,
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
toolCallExpansion: state.toolCallExpansion,
|
||||
fontSize: state.fontSize,
|
||||
padding: state.padding,
|
||||
favoriteModels: state.favoriteModels,
|
||||
recentModels: state.recentModels,
|
||||
diffLayoutPreference: state.diffLayoutPreference,
|
||||
diffWrapLines: state.diffWrapLines,
|
||||
})
|
||||
|
||||
@@ -263,7 +263,13 @@ export function VSCodeApp() {
|
||||
<div className="flex flex-col h-full bg-background text-foreground">
|
||||
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
|
||||
<div className="flex-1 min-h-0">
|
||||
{currentView === 'sessions' ? <SessionsList /> : <ChatPanel />}
|
||||
{currentView === 'sessions' ? (
|
||||
<SessionsList />
|
||||
) : currentView === 'settings' ? (
|
||||
<SettingsView />
|
||||
) : (
|
||||
<ChatPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ViewType = 'sessions' | 'chat';
|
||||
export type ViewType = 'sessions' | 'chat' | 'settings';
|
||||
|
||||
interface NavigationState {
|
||||
currentView: ViewType;
|
||||
navigateTo: (view: ViewType) => void;
|
||||
goToChat: () => void;
|
||||
goToSessions: () => void;
|
||||
goToSettings: () => void;
|
||||
}
|
||||
|
||||
export const useNavigation = create<NavigationState>((set) => ({
|
||||
@@ -14,4 +15,5 @@ export const useNavigation = create<NavigationState>((set) => ({
|
||||
navigateTo: (view) => set({ currentView: view }),
|
||||
goToChat: () => set({ currentView: 'chat' }),
|
||||
goToSessions: () => set({ currentView: 'sessions' }),
|
||||
goToSettings: () => set({ currentView: 'settings' }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user