From c87815a2a69c8a3559e92788926336a78c407751 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 16 Dec 2025 13:14:28 +0200 Subject: [PATCH] feat: enhance UI components, update theme handling, and improve tool expansion logic for better performance --- .../ui/src/components/chat/ChatMessage.tsx | 57 ++++-- .../ui/src/components/chat/ModelControls.tsx | 14 +- packages/ui/src/components/layout/Header.tsx | 7 +- .../components/providers/ThemeProvider.tsx | 3 +- .../sections/settings/AppearanceSettings.tsx | 73 ++++--- .../ui/src/components/ui/number-input.tsx | 191 ++++++++++++++++++ packages/ui/src/index.css | 10 + packages/ui/src/stores/useUIStore.ts | 69 ++++--- 8 files changed, 324 insertions(+), 100 deletions(-) create mode 100644 packages/ui/src/components/ui/number-input.tsx diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f895814d..9c415c4e 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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(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 = ({ 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 = ({ 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(); - - // 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(); + 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(); - 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) { diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 5ede1e7b..5d99af2d 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -778,6 +778,7 @@ export const ModelControls: React.FC = ({ 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 = ({ className }) => { {!mobileModelQuery && favoriteModelsList.length > 0 && (
- + Favorites
@@ -1635,7 +1636,7 @@ export const ModelControls: React.FC = ({ className }) => { {favoriteModelsList.length > 0 && ( - + Favorites = ({ className }) => { { - e.preventDefault(); + onSelect={() => { handleProviderAndModelChange(providerID, modelID); }} > @@ -1757,8 +1757,7 @@ export const ModelControls: React.FC = ({ className }) => { { - e.preventDefault(); + onSelect={() => { handleProviderAndModelChange(providerID, modelID); }} > @@ -1873,8 +1872,7 @@ export const ModelControls: React.FC = ({ className }) => { { - e.preventDefault(); + onSelect={() => { handleProviderAndModelChange(provider.id as string, model.id as string); }} > diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 18f2f20d..e359fea8 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -63,7 +63,10 @@ export const FixedSessionsButton: React.FC = () => { } return ( -
+
-
+
{ 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" /> - 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" /> - + +
@@ -134,11 +139,9 @@ export const AppearanceSettings: React.FC = () => {

Spacing

-

- {padding}% of default spacing -

+
-
+
{ 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" /> - 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" /> - + +
diff --git a/packages/ui/src/components/ui/number-input.tsx b/packages/ui/src/components/ui/number-input.tsx new file mode 100644 index 00000000..aad0498f --- /dev/null +++ b/packages/ui/src/components/ui/number-input.tsx @@ -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, "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( + ( + { + value, + onValueChange, + min = -Infinity, + max = Infinity, + step = 1, + className, + containerClassName, + onBlur, + onKeyDown, + disabled, + ...props + }, + ref + ) => { + const inputRef = React.useRef(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) => { + 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) => { + 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) => { + 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 ( +
+ + +
+ + +
+
+ ) + } +) +NumberInput.displayName = "NumberInput" + +export { NumberInput } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a8f50a73..4678dbac 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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); } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 4c0ff831..5bcca392 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( 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 = { - 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));