feat: enhance UI components, update theme handling, and improve tool expansion logic for better performance
This commit is contained in:
@@ -26,6 +26,11 @@ import type { TurnGroupingContext } from './hooks/useTurnGrouping';
|
||||
|
||||
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 {
|
||||
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: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setExpandedTools(new Set());
|
||||
}, [message.info.id, toolCallExpansion]);
|
||||
|
||||
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
||||
const isUser = messageRole.isUser;
|
||||
|
||||
@@ -309,44 +318,48 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const effectiveExpandedTools = React.useMemo(() => {
|
||||
// 'collapsed': Activity and tools start collapsed
|
||||
// 'activity': Activity expanded, tools collapsed
|
||||
// 'detailed': Activity and tools expanded
|
||||
|
||||
// 'activity': Activity expanded, tools collapsed
|
||||
// 'detailed': Activity expanded, only key 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
|
||||
|
||||
// 'detailed': expand only allowlisted tools by default.
|
||||
// expandedTools acts as a "toggled" set (XOR with defaults).
|
||||
const defaultExpandedToolIds = new Set<string>();
|
||||
|
||||
for (const part of toolParts) {
|
||||
if (part.id) {
|
||||
allToolIds.add(part.id);
|
||||
const toolName = (part as { tool?: unknown }).tool;
|
||||
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) {
|
||||
for (const activity of turnGroupingContext.activityParts) {
|
||||
if (activity.kind === 'tool' && activity.part.id) {
|
||||
allToolIds.add(activity.part.id);
|
||||
if (activity.kind !== 'tool') {
|
||||
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)
|
||||
// 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)) {
|
||||
|
||||
const effective = new Set(defaultExpandedToolIds);
|
||||
for (const id of expandedTools) {
|
||||
if (effective.has(id)) {
|
||||
effective.delete(id);
|
||||
} else {
|
||||
effective.add(id);
|
||||
}
|
||||
}
|
||||
return effective;
|
||||
}, [toolCallExpansion, expandedTools, toolParts, turnGroupingContext]);
|
||||
}, [expandedTools, toolCallExpansion, toolParts, turnGroupingContext]);
|
||||
|
||||
const agentMention = React.useMemo(() => {
|
||||
if (!isUser) {
|
||||
|
||||
@@ -778,6 +778,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
}
|
||||
// Add to recent models on successful selection
|
||||
addRecentModel(providerId, modelId);
|
||||
setAgentMenuOpen(false);
|
||||
if (isCompact) {
|
||||
closeMobilePanel();
|
||||
}
|
||||
@@ -1160,7 +1161,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
{!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" />
|
||||
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-primary" />
|
||||
Favorites
|
||||
</div>
|
||||
<div className="flex flex-col border-t border-border/30">
|
||||
@@ -1635,7 +1636,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
{favoriteModelsList.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<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
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
@@ -1668,8 +1669,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<DropdownMenuItem
|
||||
key={`fav-${providerID}-${modelID}`}
|
||||
className="typography-meta"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect={() => {
|
||||
handleProviderAndModelChange(providerID, modelID);
|
||||
}}
|
||||
>
|
||||
@@ -1757,8 +1757,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<DropdownMenuItem
|
||||
key={`recent-${providerID}-${modelID}`}
|
||||
className="typography-meta"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect={() => {
|
||||
handleProviderAndModelChange(providerID, modelID);
|
||||
}}
|
||||
>
|
||||
@@ -1873,8 +1872,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
className="typography-meta"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect={() => {
|
||||
handleProviderAndModelChange(provider.id as string, model.id as string);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -63,7 +63,10 @@ export const FixedSessionsButton: React.FC = () => {
|
||||
}
|
||||
|
||||
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
|
||||
type="button"
|
||||
onClick={handleOpenSessionSwitcher}
|
||||
@@ -425,7 +428,7 @@ export const Header: React.FC = () => {
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={headerClassName}
|
||||
style={{ borderColor: 'var(--interactive-border)' }}
|
||||
style={{ borderColor: 'var(--interactive-border)', ['--padding-scale' as string]: '1' } as React.CSSProperties}
|
||||
>
|
||||
{isMobile ? renderMobile() : renderDesktop()}
|
||||
</header>
|
||||
|
||||
@@ -8,8 +8,7 @@ interface ThemeProviderProps {
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const { theme, applyTheme, fontSize, applyTypography, padding, applyPadding } = useUIStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
applyTheme();
|
||||
applyTypography();
|
||||
applyPadding();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
import { RiRestartLine } from '@remixicon/react';
|
||||
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import type { ThemeMode } from '@/types/theme';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
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 }> = [
|
||||
{ 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' },
|
||||
{ value: 'detailed', label: 'Detailed', description: 'Activity expanded, key tools expanded' },
|
||||
];
|
||||
|
||||
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">
|
||||
Font Size
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{fontSize}% of default size
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 w-full max-w-md">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
@@ -109,23 +110,27 @@ export const AppearanceSettings: React.FC = () => {
|
||||
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"
|
||||
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
|
||||
type="number"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
<NumberInput
|
||||
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"
|
||||
onValueChange={setFontSize}
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
aria-label="Font size percentage"
|
||||
/>
|
||||
<button
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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
|
||||
</button>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,11 +139,9 @@ export const AppearanceSettings: React.FC = () => {
|
||||
<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">
|
||||
<div className="flex items-center gap-3 w-full max-w-md">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
@@ -146,23 +149,27 @@ export const AppearanceSettings: React.FC = () => {
|
||||
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"
|
||||
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
|
||||
type="number"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
<NumberInput
|
||||
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"
|
||||
onValueChange={setPadding}
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
aria-label="Spacing percentage"
|
||||
/>
|
||||
<button
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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
|
||||
</button>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</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 }
|
||||
@@ -3,6 +3,7 @@
|
||||
@source "./**/*.{ts,tsx,css}";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--oc-safe-area-top: 0px;
|
||||
--oc-safe-area-right: 0px;
|
||||
--oc-safe-area-bottom: 0px;
|
||||
@@ -12,6 +13,14 @@
|
||||
--oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4);
|
||||
--oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6);
|
||||
--padding-scale: 1;
|
||||
|
||||
/* 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 *));
|
||||
@@ -613,6 +622,7 @@ body {
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--oc-scrollbar-thumb: oklch(0.6 0.02 80 / 0.3);
|
||||
--oc-scrollbar-thumb-hover: oklch(0.6 0.02 80 / 0.5);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||
|
||||
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal';
|
||||
export type EventStreamStatus =
|
||||
@@ -236,51 +236,54 @@ export const useUIStore = create<UIStore>()(
|
||||
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`);
|
||||
|
||||
const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>;
|
||||
|
||||
// Default must be SEMANTIC_TYPOGRAPHY (from CSS). Remove overrides.
|
||||
if (scale === 1) {
|
||||
for (const [key] of entries) {
|
||||
root.style.removeProperty(getTypographyVariable(key));
|
||||
}
|
||||
});
|
||||
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: () => {
|
||||
const { padding } = get();
|
||||
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
|
||||
// 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
|
||||
|
||||
|
||||
// Dampened line-height scaling at extremes
|
||||
const lineHeightScale = 1 + (scale - 1) * 0.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));
|
||||
|
||||
Reference in New Issue
Block a user