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 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);
}}
>
+5 -2
View File
@@ -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 }
+10
View File
@@ -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);
}
+36 -33
View File
@@ -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));