feat: enhance UI components, update theme handling, and improve tool expansion logic for better performance

This commit is contained in:
Bohdan Triapitsyn
2025-12-16 13:14:28 +02:00
parent 8f6e6db5c5
commit c87815a2a6
8 changed files with 324 additions and 100 deletions
+35 -22
View File
@@ -26,6 +26,11 @@ import type { TurnGroupingContext } from './hooks/useTurnGrouping';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'bash']);
const isDetailedDefaultTool = (toolName: unknown): boolean =>
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined { function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined }); const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined });
@@ -124,6 +129,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
content: '', content: '',
}); });
React.useEffect(() => {
setExpandedTools(new Set());
}, [message.info.id, toolCallExpansion]);
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]); const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser; const isUser = messageRole.isUser;
@@ -309,44 +318,48 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const effectiveExpandedTools = React.useMemo(() => { const effectiveExpandedTools = React.useMemo(() => {
// 'collapsed': Activity and tools start collapsed // 'collapsed': Activity and tools start collapsed
// 'activity': Activity expanded, tools collapsed // 'activity': Activity expanded, tools collapsed
// 'detailed': Activity and tools expanded // 'detailed': Activity expanded, only key tools expanded
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') { if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded // Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
return expandedTools; return expandedTools;
} }
// 'detailed': Tools default expanded // 'detailed': expand only allowlisted tools by default.
// Collect all relevant tool IDs (from this message and the entire turn if we're rendering a progressive group) // expandedTools acts as a "toggled" set (XOR with defaults).
const allToolIds = new Set<string>(); const defaultExpandedToolIds = new Set<string>();
// 1. Add tools from this message
for (const part of toolParts) { for (const part of toolParts) {
if (part.id) { const toolName = (part as { tool?: unknown }).tool;
allToolIds.add(part.id); if (part.id && isDetailedDefaultTool(toolName)) {
defaultExpandedToolIds.add(part.id);
} }
} }
// 2. If we're rendering a progressive group for the turn, include all turn tools
if (turnGroupingContext?.isFirstAssistantInTurn) { if (turnGroupingContext?.isFirstAssistantInTurn) {
for (const activity of turnGroupingContext.activityParts) { for (const activity of turnGroupingContext.activityParts) {
if (activity.kind === 'tool' && activity.part.id) { if (activity.kind !== 'tool') {
allToolIds.add(activity.part.id); continue;
}
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
if (toolPart.id && isDetailedDefaultTool(toolPart.tool)) {
defaultExpandedToolIds.add(toolPart.id);
} }
} }
} }
// expandedTools contains IDs of tools that ARE collapsed (inverted) const effective = new Set(defaultExpandedToolIds);
// Return a set of all tool IDs EXCEPT those in expandedTools for (const id of expandedTools) {
const effective = new Set<string>(); if (effective.has(id)) {
for (const id of allToolIds) { effective.delete(id);
if (!expandedTools.has(id)) { } else {
effective.add(id); effective.add(id);
} }
} }
return effective; return effective;
}, [toolCallExpansion, expandedTools, toolParts, turnGroupingContext]); }, [expandedTools, toolCallExpansion, toolParts, turnGroupingContext]);
const agentMention = React.useMemo(() => { const agentMention = React.useMemo(() => {
if (!isUser) { if (!isUser) {
@@ -778,6 +778,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
} }
// Add to recent models on successful selection // Add to recent models on successful selection
addRecentModel(providerId, modelId); addRecentModel(providerId, modelId);
setAgentMenuOpen(false);
if (isCompact) { if (isCompact) {
closeMobilePanel(); closeMobilePanel();
} }
@@ -1160,7 +1161,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
{!mobileModelQuery && favoriteModelsList.length > 0 && ( {!mobileModelQuery && favoriteModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95"> <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"> <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" /> <RiStarFill className="h-3 w-3 inline-block mr-1.5 text-primary" />
Favorites Favorites
</div> </div>
<div className="flex flex-col border-t border-border/30"> <div className="flex flex-col border-t border-border/30">
@@ -1635,7 +1636,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
{favoriteModelsList.length > 0 && ( {favoriteModelsList.length > 0 && (
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta"> <DropdownMenuSubTrigger className="typography-meta">
<RiStarFill className="h-3 w-3 flex-shrink-0 mr-2 text-yellow-500" /> <RiStarFill className="h-3 w-3 flex-shrink-0 mr-2 text-primary" />
Favorites Favorites
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent <DropdownMenuSubContent
@@ -1668,8 +1669,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<DropdownMenuItem <DropdownMenuItem
key={`fav-${providerID}-${modelID}`} key={`fav-${providerID}-${modelID}`}
className="typography-meta" className="typography-meta"
onSelect={(e) => { onSelect={() => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID); handleProviderAndModelChange(providerID, modelID);
}} }}
> >
@@ -1757,8 +1757,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<DropdownMenuItem <DropdownMenuItem
key={`recent-${providerID}-${modelID}`} key={`recent-${providerID}-${modelID}`}
className="typography-meta" className="typography-meta"
onSelect={(e) => { onSelect={() => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID); handleProviderAndModelChange(providerID, modelID);
}} }}
> >
@@ -1873,8 +1872,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<DropdownMenuItem <DropdownMenuItem
key={model.id} key={model.id}
className="typography-meta" className="typography-meta"
onSelect={(e) => { onSelect={() => {
e.preventDefault();
handleProviderAndModelChange(provider.id as string, model.id as string); handleProviderAndModelChange(provider.id as string, model.id as string);
}} }}
> >
+5 -2
View File
@@ -63,7 +63,10 @@ export const FixedSessionsButton: React.FC = () => {
} }
return ( return (
<div className="fixed top-[0.375rem] left-[5.25rem] z-[9999]" style={{ pointerEvents: 'auto' }}> <div
className="fixed top-[0.375rem] left-[5.25rem] z-[9999]"
style={{ pointerEvents: 'auto', ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
<button <button
type="button" type="button"
onClick={handleOpenSessionSwitcher} onClick={handleOpenSessionSwitcher}
@@ -425,7 +428,7 @@ export const Header: React.FC = () => {
<header <header
ref={headerRef} ref={headerRef}
className={headerClassName} className={headerClassName}
style={{ borderColor: 'var(--interactive-border)' }} style={{ borderColor: 'var(--interactive-border)', ['--padding-scale' as string]: '1' } as React.CSSProperties}
> >
{isMobile ? renderMobile() : renderDesktop()} {isMobile ? renderMobile() : renderDesktop()}
</header> </header>
@@ -8,8 +8,7 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => { export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { theme, applyTheme, fontSize, applyTypography, padding, applyPadding } = useUIStore(); const { theme, applyTheme, fontSize, applyTypography, padding, applyPadding } = useUIStore();
React.useEffect(() => { React.useLayoutEffect(() => {
applyTheme(); applyTheme();
applyTypography(); applyTypography();
applyPadding(); applyPadding();
@@ -1,9 +1,12 @@
import React from 'react'; import React from 'react';
import { RiRestartLine } from '@remixicon/react';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme'; 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 { NumberInput } from '@/components/ui/number-input';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
interface Option<T extends string> { interface Option<T extends string> {
@@ -30,7 +33,7 @@ const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed'; label: string; description: 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: 'collapsed', label: 'Collapsed', description: 'Activity and tools start collapsed' },
{ value: 'activity', label: 'Summary', description: 'Activity expanded, tools collapsed' }, { value: 'activity', label: 'Summary', description: 'Activity expanded, tools collapsed' },
{ value: 'detailed', label: 'Detailed', description: 'Activity and tools expanded' }, { value: 'detailed', label: 'Detailed', description: 'Activity expanded, key tools expanded' },
]; ];
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
@@ -97,11 +100,9 @@ export const AppearanceSettings: React.FC = () => {
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
Font Size 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"> <div className="flex items-center gap-3 w-full max-w-md">
<input <input
type="range" type="range"
min="50" min="50"
@@ -109,23 +110,27 @@ export const AppearanceSettings: React.FC = () => {
step="5" step="5"
value={fontSize} value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))} 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" className="flex-1 min-w-0 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 <NumberInput
type="number"
min="50"
max="200"
step="5"
value={fontSize} value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))} onValueChange={setFontSize}
className="w-20 px-2 py-1 text-center border border-border rounded bg-background text-foreground typography-ui-label" min={50}
max={200}
step={5}
aria-label="Font size percentage"
/> />
<button <ButtonSmall
type="button"
variant="ghost"
onClick={() => setFontSize(100)} onClick={() => setFontSize(100)}
className="px-2 py-1 text-xs border border-border rounded hover:bg-accent text-muted-foreground hover:text-foreground" disabled={fontSize === 100}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background"
aria-label="Reset font size"
title="Reset"
> >
Reset <RiRestartLine className="h-3.5 w-3.5" />
</button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -134,11 +139,9 @@ export const AppearanceSettings: React.FC = () => {
<h3 className="typography-ui-header font-semibold text-foreground"> <h3 className="typography-ui-header font-semibold text-foreground">
Spacing Spacing
</h3> </h3>
<p className="typography-meta text-muted-foreground">
{padding}% of default spacing
</p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 w-full max-w-md">
<input <input
type="range" type="range"
min="50" min="50"
@@ -146,23 +149,27 @@ export const AppearanceSettings: React.FC = () => {
step="5" step="5"
value={padding} value={padding}
onChange={(e) => setPadding(Number(e.target.value))} 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" className="flex-1 min-w-0 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 <NumberInput
type="number"
min="50"
max="200"
step="5"
value={padding} value={padding}
onChange={(e) => setPadding(Number(e.target.value))} onValueChange={setPadding}
className="w-20 px-2 py-1 text-center border border-border rounded bg-background text-foreground typography-ui-label" min={50}
max={200}
step={5}
aria-label="Spacing percentage"
/> />
<button <ButtonSmall
type="button"
variant="ghost"
onClick={() => setPadding(100)} onClick={() => setPadding(100)}
className="px-2 py-1 text-xs border border-border rounded hover:bg-accent text-muted-foreground hover:text-foreground" disabled={padding === 100}
className="h-8 w-8 px-0 border border-border bg-background hover:bg-accent disabled:opacity-100 disabled:bg-background"
aria-label="Reset spacing"
title="Reset"
> >
Reset <RiRestartLine className="h-3.5 w-3.5" />
</button> </ButtonSmall>
</div> </div>
</div> </div>
@@ -0,0 +1,191 @@
import * as React from "react"
import { RiArrowDownSLine, RiArrowUpSLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
export interface NumberInputProps
extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> {
value: number
onValueChange: (value: number) => void
min?: number
max?: number
step?: number
containerClassName?: string
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(
{
value,
onValueChange,
min = -Infinity,
max = Infinity,
step = 1,
className,
containerClassName,
onBlur,
onKeyDown,
disabled,
...props
},
ref
) => {
const inputRef = React.useRef<HTMLInputElement | null>(null)
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
const [draft, setDraft] = React.useState(() => String(value))
React.useEffect(() => {
if (document.activeElement !== inputRef.current) {
setDraft(String(value))
}
}, [value])
const focusInput = React.useCallback(() => {
inputRef.current?.focus()
}, [])
const applyValue = React.useCallback(
(nextValue: number) => {
const clamped = clamp(nextValue, min, max)
onValueChange(clamped)
setDraft(String(clamped))
},
[max, min, onValueChange]
)
const currentNumericValue = React.useCallback(() => {
const parsed = Number(draft)
if (Number.isFinite(parsed)) {
return parsed
}
return value
}, [draft, value])
const handleIncrement = React.useCallback(() => {
applyValue(currentNumericValue() + step)
focusInput()
}, [applyValue, currentNumericValue, focusInput, step])
const handleDecrement = React.useCallback(() => {
applyValue(currentNumericValue() - step)
focusInput()
}, [applyValue, currentNumericValue, focusInput, step])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextDraft = event.target.value
setDraft(nextDraft)
const parsed = Number(nextDraft)
if (!Number.isFinite(parsed)) {
return
}
onValueChange(clamp(parsed, min, max))
}
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const parsed = Number(draft)
if (!Number.isFinite(parsed)) {
setDraft(String(value))
} else {
const clamped = clamp(parsed, min, max)
if (clamped !== parsed) {
onValueChange(clamped)
}
setDraft(String(clamped))
}
onBlur?.(event)
}
const handleKeyDownInternal = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowUp") {
event.preventDefault()
handleIncrement()
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
handleDecrement()
return
}
onKeyDown?.(event)
}
const numericValue = currentNumericValue()
const decrementDisabled = disabled || numericValue <= min
const incrementDisabled = disabled || numericValue >= max
return (
<div
className={cn(
"flex h-8 items-stretch overflow-hidden rounded-lg border border-border bg-background",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
disabled && "opacity-50",
containerClassName
)}
>
<input
{...props}
ref={inputRef}
type="number"
inputMode="numeric"
min={Number.isFinite(min) ? min : undefined}
max={Number.isFinite(max) ? max : undefined}
step={step}
value={draft}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDownInternal}
disabled={disabled}
className={cn(
"h-full w-14 bg-transparent px-1.5 text-center typography-ui-label text-foreground",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"border-0 outline-none",
"disabled:pointer-events-none disabled:cursor-not-allowed",
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
className
)}
/>
<div className="flex w-6 flex-col border-l border-border">
<button
type="button"
aria-label="Increase value"
disabled={incrementDisabled}
onClick={handleIncrement}
className={cn(
"flex flex-1 items-center justify-center",
"text-muted-foreground hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiArrowUpSLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="Decrease value"
disabled={decrementDisabled}
onClick={handleDecrement}
className={cn(
"flex flex-1 items-center justify-center border-t border-border",
"text-muted-foreground hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<RiArrowDownSLine className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
)
NumberInput.displayName = "NumberInput"
export { NumberInput }
+10
View File
@@ -3,6 +3,7 @@
@source "./**/*.{ts,tsx,css}"; @source "./**/*.{ts,tsx,css}";
:root { :root {
color-scheme: light;
--oc-safe-area-top: 0px; --oc-safe-area-top: 0px;
--oc-safe-area-right: 0px; --oc-safe-area-right: 0px;
--oc-safe-area-bottom: 0px; --oc-safe-area-bottom: 0px;
@@ -12,6 +13,14 @@
--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; --padding-scale: 1;
/* Semantic typography defaults (must match SEMANTIC_TYPOGRAPHY) */
--text-markdown: 0.9375rem;
--text-code: 0.9063rem;
--text-ui-header: 0.9375rem;
--text-ui-label: 0.8750rem;
--text-meta: 0.875rem;
--text-micro: 0.875rem;
} }
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -613,6 +622,7 @@ body {
} }
.dark { .dark {
color-scheme: dark;
--oc-scrollbar-thumb: oklch(0.6 0.02 80 / 0.3); --oc-scrollbar-thumb: oklch(0.6 0.02 80 / 0.3);
--oc-scrollbar-thumb-hover: oklch(0.6 0.02 80 / 0.5); --oc-scrollbar-thumb-hover: oklch(0.6 0.02 80 / 0.5);
} }
+36 -33
View File
@@ -2,7 +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'; import { SEMANTIC_TYPOGRAPHY, 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 =
@@ -236,51 +236,54 @@ export const useUIStore = create<UIStore>()(
applyTypography: () => { applyTypography: () => {
const { fontSize } = get(); const { fontSize } = get();
const root = document.documentElement; const root = document.documentElement;
// Apply font size as a percentage scale
// 100 = default (1.0x), 50 = half size (0.5x), 200 = double (2.0x) // 100 = default (1.0x), 50 = half size (0.5x), 200 = double (2.0x)
const scale = fontSize / 100; const scale = fontSize / 100;
// Store scale for reference const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>;
root.style.setProperty('--font-scale', scale.toString());
// Default must be SEMANTIC_TYPOGRAPHY (from CSS). Remove overrides.
// Read base values from SEMANTIC_TYPOGRAPHY or use defaults if (scale === 1) {
const baseValues: Record<string, string> = { for (const [key] of entries) {
markdown: '0.9375rem', root.style.removeProperty(getTypographyVariable(key));
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`);
} }
}); return;
}
for (const [key, baseValue] of entries) {
const numericValue = parseFloat(baseValue);
if (!Number.isFinite(numericValue)) {
continue;
}
root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`);
}
}, },
applyPadding: () => { applyPadding: () => {
const { padding } = get(); const { padding } = get();
const root = document.documentElement; const root = document.documentElement;
const scale = padding / 100;
if (scale === 1) {
root.style.removeProperty('--padding-scale');
root.style.removeProperty('--line-height-tight');
root.style.removeProperty('--line-height-normal');
root.style.removeProperty('--line-height-relaxed');
root.style.removeProperty('--line-height-loose');
return;
}
// Apply padding as a percentage scale with non-linear scaling // Apply padding as a percentage scale with non-linear scaling
// Use square root for more natural scaling at extremes // Use square root for more natural scaling at extremes
const scale = padding / 100;
const adjustedScale = Math.sqrt(scale); const adjustedScale = Math.sqrt(scale);
// Set the CSS custom property that all spacing tokens reference // Set the CSS custom property that all spacing tokens reference
root.style.setProperty('--padding-scale', adjustedScale.toString()); root.style.setProperty('--padding-scale', adjustedScale.toString());
// Apply line height scaling - use much smaller scale factor // Dampened line-height scaling at extremes
// Line height should remain relatively constant even when font size changes const lineHeightScale = 1 + (scale - 1) * 0.15;
// 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-tight', (1.25 * lineHeightScale).toFixed(3));
root.style.setProperty('--line-height-normal', (1.5 * 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-relaxed', (1.625 * lineHeightScale).toFixed(3));