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 providers = useConfigStore((state) => state.providers);
|
||||||
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
|
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
|
||||||
|
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
@@ -302,9 +303,51 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
if (isUser) {
|
if (isUser) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return visibleParts.filter((part) => part.type === 'tool');
|
const filtered = visibleParts.filter((part) => part.type === 'tool');
|
||||||
|
return filtered;
|
||||||
}, [isUser, visibleParts]);
|
}, [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(() => {
|
const agentMention = React.useMemo(() => {
|
||||||
if (!isUser) {
|
if (!isUser) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -707,7 +750,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
hasTouchInput={hasTouchInput}
|
hasTouchInput={hasTouchInput}
|
||||||
copiedCode={copiedCode}
|
copiedCode={copiedCode}
|
||||||
onCopyCode={handleCopyCode}
|
onCopyCode={handleCopyCode}
|
||||||
expandedTools={expandedTools}
|
expandedTools={effectiveExpandedTools}
|
||||||
onToggleTool={handleToggleTool}
|
onToggleTool={handleToggleTool}
|
||||||
onShowPopup={handleShowPopup}
|
onShowPopup={handleShowPopup}
|
||||||
streamPhase={streamPhase}
|
streamPhase={streamPhase}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
RiPencilAiLine,
|
RiPencilAiLine,
|
||||||
RiQuestionLine,
|
RiQuestionLine,
|
||||||
RiSearchLine,
|
RiSearchLine,
|
||||||
|
RiStarFill,
|
||||||
|
RiStarLine,
|
||||||
RiText,
|
RiText,
|
||||||
|
RiTimeLine,
|
||||||
RiToolsLine,
|
RiToolsLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||||
@@ -43,6 +46,8 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useModelLists } from '@/hooks/useModelLists';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type IconComponent = ComponentType<any>;
|
type IconComponent = ComponentType<any>;
|
||||||
@@ -229,6 +234,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
||||||
|
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
|
||||||
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const isDesktopRuntime = useIsDesktopRuntime();
|
const isDesktopRuntime = useIsDesktopRuntime();
|
||||||
@@ -769,6 +776,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Add to recent models on successful selection
|
||||||
|
addRecentModel(providerId, modelId);
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
closeMobilePanel();
|
closeMobilePanel();
|
||||||
}
|
}
|
||||||
@@ -1147,6 +1156,96 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
</div>
|
</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 }) => {
|
{filteredProviders.map(({ provider, providerModels }) => {
|
||||||
if (providerModels.length === 0 && !normalizedQuery.length) {
|
if (providerModels.length === 0 && !normalizedQuery.length) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1191,47 +1290,76 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
const inputIcons = getModalityIcons(metadata, 'input');
|
const inputIcons = getModalityIcons(metadata, 'input');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
type="button"
|
|
||||||
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
|
|
||||||
className={cn(
|
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',
|
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 last:border-b-0',
|
||||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
|
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary/15 text-primary'
|
? 'bg-primary/15 text-primary'
|
||||||
: 'hover:bg-accent/40'
|
: ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-col">
|
<button
|
||||||
<span className="typography-meta font-medium text-foreground">
|
type="button"
|
||||||
{getModelDisplayName(model)}
|
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
|
||||||
</span>
|
className={cn(
|
||||||
</div>
|
'flex flex-1 min-w-0 items-start gap-2 text-left',
|
||||||
<div className="ml-auto flex flex-col items-end gap-1 text-right">
|
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
|
||||||
{(metadata?.limit?.context || metadata?.limit?.output) && (
|
!isSelected && 'hover:bg-accent/40'
|
||||||
<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">
|
<div className="flex min-w-0 flex-col">
|
||||||
{[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
|
<span className="typography-meta font-medium text-foreground">
|
||||||
<span
|
{getModelDisplayName(model)}
|
||||||
key={`meta-${provider.id}-${model.id}-${key}`}
|
</span>
|
||||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
</div>
|
||||||
title={label}
|
<div className="ml-auto flex flex-col items-end gap-1 text-right">
|
||||||
aria-label={label}
|
{(metadata?.limit?.context || metadata?.limit?.output) && (
|
||||||
>
|
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
|
||||||
<IconComponent className="h-3 w-3" />
|
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
|
||||||
</span>
|
{metadata?.limit?.context && metadata?.limit?.output ? <span>•</span> : null}
|
||||||
))}
|
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
|
||||||
</div>
|
</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>
|
aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"}
|
||||||
</button>
|
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>
|
</div>
|
||||||
@@ -1503,6 +1631,190 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<DropdownMenuContent className="max-w-[300px]">
|
<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) => {
|
{providers.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
|
|
||||||
@@ -1561,13 +1873,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className="typography-meta"
|
className="typography-meta"
|
||||||
onSelect={() => {
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
handleProviderAndModelChange(provider.id as string, model.id as string);
|
handleProviderAndModelChange(provider.id as string, model.id as string);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<span className="font-medium">
|
<span className="font-medium truncate">
|
||||||
{getModelDisplayName(model)}
|
{getModelDisplayName(model)}
|
||||||
</span>
|
</span>
|
||||||
{metadata?.limit?.context || metadata?.limit?.output ? (
|
{metadata?.limit?.context || metadata?.limit?.output ? (
|
||||||
@@ -1578,7 +1891,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 }) => (
|
{indicatorIcons.map(({ id, icon: Icon, label }) => (
|
||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
@@ -1590,6 +1903,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
|||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Message, Part } from '@opencode-ai/sdk';
|
import type { Message, Part } from '@opencode-ai/sdk';
|
||||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
export interface ChatMessageEntry {
|
export interface ChatMessageEntry {
|
||||||
info: Message;
|
info: Message;
|
||||||
@@ -302,30 +303,34 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
() => new Map()
|
() => 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(
|
const getOrCreateTurnState = React.useCallback(
|
||||||
(turnId: string): TurnUiState => {
|
(turnId: string): TurnUiState => {
|
||||||
const existing = turnUiStates.get(turnId);
|
const existing = turnUiStates.get(turnId);
|
||||||
if (existing) return existing;
|
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) => {
|
const toggleGroup = React.useCallback((turnId: string) => {
|
||||||
setTurnUiStates((prev) => {
|
setTurnUiStates((prev) => {
|
||||||
const next = new Map(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 });
|
next.set(turnId, { ...current, isExpanded: !current.isExpanded });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [defaultActivityExpanded]);
|
||||||
|
|
||||||
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
|
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
|
||||||
if (partIds.length === 0) return;
|
if (partIds.length === 0) return;
|
||||||
|
|
||||||
setTurnUiStates((prev) => {
|
setTurnUiStates((prev) => {
|
||||||
const next = new Map(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);
|
const newPreviewed = new Set(state.previewedPartIds);
|
||||||
partIds.forEach((id) => {
|
partIds.forEach((id) => {
|
||||||
if (id && id.trim().length > 0) {
|
if (id && id.trim().length > 0) {
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { ChatView } from '@/components/views';
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
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 { 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 = () => {
|
export const VSCodeLayout: React.FC = () => {
|
||||||
const [currentView, setCurrentView] = React.useState<VSCodeView>('sessions');
|
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">
|
<div className="h-full w-full bg-background text-foreground flex flex-col">
|
||||||
{currentView === 'sessions' ? (
|
{currentView === 'sessions' ? (
|
||||||
<div className="flex flex-col h-full">
|
<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">
|
<div className="flex-1 overflow-hidden">
|
||||||
<SessionSidebar
|
<SessionSidebar
|
||||||
mobileVariant
|
mobileVariant
|
||||||
@@ -210,6 +215,17 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-col h-full">
|
||||||
<VSCodeHeader
|
<VSCodeHeader
|
||||||
@@ -249,10 +265,11 @@ interface VSCodeHeaderProps {
|
|||||||
showBack?: boolean;
|
showBack?: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onNewSession?: () => void;
|
onNewSession?: () => void;
|
||||||
|
onSettings?: () => void;
|
||||||
showContextUsage?: boolean;
|
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 { getCurrentModel } = useConfigStore();
|
||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
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" />
|
<RiAddLine className="h-5 w-5" />
|
||||||
</button>
|
</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 && (
|
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
totalTokens={contextUsage.totalTokens}
|
totalTokens={contextUsage.totalTokens}
|
||||||
@@ -297,3 +323,14 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
|||||||
</div>
|
</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 }) => {
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||||
const { theme, applyTheme } = useUIStore();
|
const { theme, applyTheme, fontSize, applyTypography, padding, applyPadding } = useUIStore();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
}, [theme, applyTheme]);
|
applyTypography();
|
||||||
|
applyPadding();
|
||||||
|
}, [theme, applyTheme, fontSize, applyTypography, padding, applyPadding]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
@@ -11,11 +12,12 @@ import {
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
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 { cn } from '@/lib/utils';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { useModelLists } from '@/hooks/useModelLists';
|
||||||
|
|
||||||
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { providers, modelsMetadata } = useConfigStore();
|
const { providers, modelsMetadata } = useConfigStore();
|
||||||
const isMobile = useUIStore(state => state.isMobile);
|
const isMobile = useUIStore(state => state.isMobile);
|
||||||
|
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
|
||||||
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||||
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
||||||
const isActuallyMobile = isMobile || deviceIsMobile;
|
const isActuallyMobile = isMobile || deviceIsMobile;
|
||||||
|
|
||||||
@@ -53,6 +57,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const getModelDisplayName = (model: Record<string, unknown>) => {
|
const getModelDisplayName = (model: Record<string, unknown>) => {
|
||||||
const name = model?.name || model?.id || '';
|
const name = model?.name || model?.id || '';
|
||||||
const nameStr = String(name);
|
const nameStr = String(name);
|
||||||
@@ -69,6 +75,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
|
|
||||||
const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => {
|
const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => {
|
||||||
onChange(newProviderId, newModelId);
|
onChange(newProviderId, newModelId);
|
||||||
|
// Add to recent models on successful selection
|
||||||
|
addRecentModel(newProviderId, newModelId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMobileModelPanel = () => {
|
const renderMobileModelPanel = () => {
|
||||||
@@ -81,6 +89,126 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
title="Select Model"
|
title="Select Model"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<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) => {
|
{providers.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
if (providerModels.length === 0) return null;
|
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);
|
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={modelItem.id as string}
|
key={modelItem.id as string}
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center justify-between px-2 py-1.5 text-left',
|
'flex w-full items-center justify-between px-2 py-1.5 text-left',
|
||||||
'typography-meta',
|
'typography-meta',
|
||||||
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
|
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
|
||||||
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
|
|
||||||
closeMobilePanel();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<button
|
||||||
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
|
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' && (
|
{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}
|
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{isSelectedModel && (
|
</div>
|
||||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,6 +374,125 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="max-w-[300px]">
|
<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) => {
|
{providers.map((provider) => {
|
||||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||||
|
|
||||||
@@ -265,17 +538,40 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={modelItem.id as string}
|
key={modelItem.id as string}
|
||||||
className="typography-meta"
|
className="typography-meta"
|
||||||
onSelect={() => {
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
|
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
|
<span className="font-medium truncate">{getModelDisplayName(modelItem)}</span>
|
||||||
<span className="typography-meta text-muted-foreground">
|
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
|
||||||
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
|
<span className="typography-micro text-muted-foreground truncate">
|
||||||
</span>
|
{(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>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ThemeMode } from '@/types/theme';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ButtonSmall } from '@/components/ui/button-small';
|
import { ButtonSmall } from '@/components/ui/button-small';
|
||||||
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
interface Option<T extends string> {
|
interface Option<T extends string> {
|
||||||
id: T;
|
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'>[] = [
|
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
||||||
{
|
{
|
||||||
id: 'dynamic',
|
id: 'dynamic',
|
||||||
@@ -47,6 +54,12 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
|||||||
export const AppearanceSettings: React.FC = () => {
|
export const AppearanceSettings: React.FC = () => {
|
||||||
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
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 diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
|
||||||
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
|
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
|
||||||
const {
|
const {
|
||||||
@@ -56,22 +69,119 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-8">
|
<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-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||||
Theme Mode
|
Font Size
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
{fontSize}% of default size
|
||||||
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex gap-1 w-fit">
|
||||||
{THEME_MODE_OPTIONS.map((option) => (
|
{TOOL_EXPANSION_OPTIONS.map((option) => (
|
||||||
<ButtonSmall
|
<ButtonSmall
|
||||||
key={option.value}
|
key={option.value}
|
||||||
variant={themeMode === option.value ? 'default' : 'outline'}
|
variant={toolCallExpansion === option.value ? 'default' : 'outline'}
|
||||||
className={cn(themeMode === option.value ? undefined : 'text-foreground')}
|
className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')}
|
||||||
onClick={() => setThemeMode(option.value)}
|
onClick={() => setToolCallExpansion(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</ButtonSmall>
|
</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';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings';
|
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.',
|
description: 'Create and maintain custom slash commands for OpenCode.',
|
||||||
icon: RiCommandLine,
|
icon: RiCommandLine,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'providers',
|
||||||
|
label: 'Providers',
|
||||||
|
description: 'Configure AI model providers and API credentials.',
|
||||||
|
icon: RiStackLine,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'git-identities',
|
id: 'git-identities',
|
||||||
label: '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;
|
--ui-regular-font-weight: 400;
|
||||||
--oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4);
|
--oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4);
|
||||||
--oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6);
|
--oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6);
|
||||||
|
--padding-scale: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
@@ -224,15 +225,31 @@ svg.animate-spin {
|
|||||||
.chat-column {
|
.chat-column {
|
||||||
width: min(100%, 56rem);
|
width: min(100%, 56rem);
|
||||||
margin-inline: auto;
|
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) {
|
@media (min-width: 1024px) {
|
||||||
.chat-column {
|
.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 */
|
/* FadeInOnReveal handles all animations - no custom CSS needed */
|
||||||
|
|
||||||
/* Heading typography - all use markdown size, differentiated by weight/color */
|
/* Heading typography - all use markdown size, differentiated by weight/color */
|
||||||
@@ -410,6 +427,43 @@ body {
|
|||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--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-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ export const SEMANTIC_TYPOGRAPHY = {
|
|||||||
micro: '0.875rem',
|
micro: '0.875rem',
|
||||||
} as const;
|
} 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 = {
|
export const VSCODE_TYPOGRAPHY = {
|
||||||
// Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density.
|
// Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density.
|
||||||
markdown: '0.9063rem',
|
markdown: '0.9063rem',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { SidebarSection } from '@/constants/sidebar';
|
import type { SidebarSection } from '@/constants/sidebar';
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
|
import { getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||||
|
|
||||||
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal';
|
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal';
|
||||||
export type EventStreamStatus =
|
export type EventStreamStatus =
|
||||||
@@ -32,6 +33,13 @@ interface UIStore {
|
|||||||
eventStreamHint: string | null;
|
eventStreamHint: string | null;
|
||||||
showReasoningTraces: boolean;
|
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';
|
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
||||||
diffWrapLines: boolean;
|
diffWrapLines: boolean;
|
||||||
@@ -56,7 +64,15 @@ interface UIStore {
|
|||||||
setSidebarSection: (section: SidebarSection) => void;
|
setSidebarSection: (section: SidebarSection) => void;
|
||||||
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
|
||||||
setShowReasoningTraces: (value: boolean) => 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;
|
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;
|
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
|
||||||
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
||||||
setDiffWrapLines: (wrap: boolean) => void;
|
setDiffWrapLines: (wrap: boolean) => void;
|
||||||
@@ -83,6 +99,11 @@ export const useUIStore = create<UIStore>()(
|
|||||||
eventStreamStatus: 'idle',
|
eventStreamStatus: 'idle',
|
||||||
eventStreamHint: null,
|
eventStreamHint: null,
|
||||||
showReasoningTraces: false,
|
showReasoningTraces: false,
|
||||||
|
toolCallExpansion: 'collapsed',
|
||||||
|
fontSize: 100,
|
||||||
|
padding: 100,
|
||||||
|
favoriteModels: [],
|
||||||
|
recentModels: [],
|
||||||
diffLayoutPreference: 'dynamic',
|
diffLayoutPreference: 'dynamic',
|
||||||
diffFileLayout: {},
|
diffFileLayout: {},
|
||||||
diffWrapLines: false,
|
diffWrapLines: false,
|
||||||
@@ -194,6 +215,78 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ showReasoningTraces: value });
|
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) => {
|
setDiffLayoutPreference: (mode) => {
|
||||||
set({ diffLayoutPreference: mode });
|
set({ diffLayoutPreference: mode });
|
||||||
},
|
},
|
||||||
@@ -211,6 +304,48 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ diffWrapLines: wrap });
|
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: () => {
|
updateProportionalSidebarWidths: () => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -254,6 +389,11 @@ export const useUIStore = create<UIStore>()(
|
|||||||
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
|
||||||
isSettingsDialogOpen: state.isSettingsDialogOpen,
|
isSettingsDialogOpen: state.isSettingsDialogOpen,
|
||||||
showReasoningTraces: state.showReasoningTraces,
|
showReasoningTraces: state.showReasoningTraces,
|
||||||
|
toolCallExpansion: state.toolCallExpansion,
|
||||||
|
fontSize: state.fontSize,
|
||||||
|
padding: state.padding,
|
||||||
|
favoriteModels: state.favoriteModels,
|
||||||
|
recentModels: state.recentModels,
|
||||||
diffLayoutPreference: state.diffLayoutPreference,
|
diffLayoutPreference: state.diffLayoutPreference,
|
||||||
diffWrapLines: state.diffWrapLines,
|
diffWrapLines: state.diffWrapLines,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -263,7 +263,13 @@ export function VSCodeApp() {
|
|||||||
<div className="flex flex-col h-full bg-background text-foreground">
|
<div className="flex flex-col h-full bg-background text-foreground">
|
||||||
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
|
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
{currentView === 'sessions' ? <SessionsList /> : <ChatPanel />}
|
{currentView === 'sessions' ? (
|
||||||
|
<SessionsList />
|
||||||
|
) : currentView === 'settings' ? (
|
||||||
|
<SettingsView />
|
||||||
|
) : (
|
||||||
|
<ChatPanel />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
export type ViewType = 'sessions' | 'chat';
|
export type ViewType = 'sessions' | 'chat' | 'settings';
|
||||||
|
|
||||||
interface NavigationState {
|
interface NavigationState {
|
||||||
currentView: ViewType;
|
currentView: ViewType;
|
||||||
navigateTo: (view: ViewType) => void;
|
navigateTo: (view: ViewType) => void;
|
||||||
goToChat: () => void;
|
goToChat: () => void;
|
||||||
goToSessions: () => void;
|
goToSessions: () => void;
|
||||||
|
goToSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useNavigation = create<NavigationState>((set) => ({
|
export const useNavigation = create<NavigationState>((set) => ({
|
||||||
@@ -14,4 +15,5 @@ export const useNavigation = create<NavigationState>((set) => ({
|
|||||||
navigateTo: (view) => set({ currentView: view }),
|
navigateTo: (view) => set({ currentView: view }),
|
||||||
goToChat: () => set({ currentView: 'chat' }),
|
goToChat: () => set({ currentView: 'chat' }),
|
||||||
goToSessions: () => set({ currentView: 'sessions' }),
|
goToSessions: () => set({ currentView: 'sessions' }),
|
||||||
|
goToSettings: () => set({ currentView: 'settings' }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user