diff --git a/packages/app/src/components/callout-card.test.tsx b/packages/app/src/components/callout-card.test.tsx index 2f51c610b..0cb0cc8c9 100644 --- a/packages/app/src/components/callout-card.test.tsx +++ b/packages/app/src/components/callout-card.test.tsx @@ -101,14 +101,9 @@ describe("CalloutCard", () => { it("renders one action when one is provided", () => { const onPress = vi.fn(); + const actions = [{ label: "Undo", onPress }]; act(() => { - root?.render( - , - ); + root?.render(); }); const button = container?.querySelector( @@ -119,15 +114,16 @@ describe("CalloutCard", () => { }); it("renders up to two actions", () => { + const actions: React.ComponentProps["actions"] = [ + { label: "What's new", onPress: vi.fn() }, + { label: "Install & restart", onPress: vi.fn(), variant: "primary" }, + ]; act(() => { root?.render( , ); diff --git a/packages/app/src/components/callout-card.tsx b/packages/app/src/components/callout-card.tsx index c295ea316..2258a3335 100644 --- a/packages/app/src/components/callout-card.tsx +++ b/packages/app/src/components/callout-card.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react-native"; -import type { ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -43,7 +43,10 @@ export function CalloutCard({ const hasHeader = title != null || icon != null; const hasDescription = description != null && description !== ""; - const containerStyle = [styles.container, variant === "error" ? styles.containerError : null]; + const containerStyle = useMemo( + () => [styles.container, variant === "error" ? styles.containerError : null], + [variant], + ); return ( @@ -106,6 +109,10 @@ export function CalloutCard({ function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) { const isPrimary = action.variant === "primary"; + const labelStyle = useMemo( + () => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary], + [isPrimary], + ); return ( - + {action.label} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 35022d245..9f5e5c8ec 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -16,7 +16,9 @@ import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/a import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; const IS_WEB = platformIsWeb; -import { Combobox, ComboboxItem } from "@/components/ui/combobox"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; + +const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray = []; import { getProviderIcon } from "@/components/provider-icons"; import { buildModelRows, @@ -356,13 +358,18 @@ function ProviderSearchInput({ } }, [autoFocus]); + const inputStyle = useMemo( + () => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }], + [], + ); + return ( {}} open={isOpen} diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx index 61a7ef713..993168a3e 100644 --- a/packages/app/src/components/download-toast.tsx +++ b/packages/app/src/components/download-toast.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -36,15 +36,17 @@ export function DownloadToast() { }; }, [activeDownload, dismissDownload]); + const containerStyle = useMemo( + () => [styles.container, { bottom: theme.spacing[4] + insets.bottom }], + [theme.spacing, insets.bottom], + ); + if (!activeDownload) { return null; } return ( - + {activeDownload.status === "downloading" ? ( @@ -68,12 +70,7 @@ export function DownloadToast() { {activeDownload.status === "downloading" && activeDownload.progress && ( - + )} @@ -91,6 +88,12 @@ export function DownloadToast() { ); } +function ProgressFill({ percent }: { percent: number }) { + const width: `${number}%` = `${Math.round(percent * 100)}%`; + const fillStyle = useMemo(() => [styles.progressFill, { width }], [width]); + return ; +} + const styles = StyleSheet.create((theme) => ({ container: { position: "absolute", diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index d88e0eb12..5e7f0a817 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -1,5 +1,5 @@ import { RefreshControl } from "react-native"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import DraggableFlatList, { NestableDraggableFlatList, type RenderItemParams, @@ -37,7 +37,15 @@ export function DraggableList({ // Pass the ref directly to DraggableFlatList - it handles gesture // coordination internally for nestable lists. - const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined; + const simultaneousHandlers = useMemo( + () => (simultaneousGestureRef ? [simultaneousGestureRef] : undefined), + [simultaneousGestureRef], + ); + + const refreshColors = useMemo( + () => [theme.colors.foregroundMuted], + [theme.colors.foregroundMuted], + ); const handleRenderItem = useCallback( ({ item, drag, isActive, getIndex }: RenderItemParams) => { @@ -106,7 +114,7 @@ export function DraggableList({ refreshing={refreshing ?? false} onRefresh={onRefresh} tintColor={theme.colors.foregroundMuted} - colors={[theme.colors.foregroundMuted]} + colors={refreshColors} /> ) : undefined } diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 2a11d8cb0..a0e15aa10 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -100,10 +100,15 @@ const CodeLine = React.memo(function CodeLine({ colorMap, baseColor, }: CodeLineProps) { + const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]); + const gutterTextStyle = useMemo( + () => [codeLineStyles.gutterText, { color: baseColor }], + [baseColor], + ); return ( - - {String(lineNumber)} + + {String(lineNumber)} {tokens.map((token, index) => ( diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 4c7390a89..7e04ac4af 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; @@ -23,6 +24,19 @@ export function RealtimeVoiceOverlay({ }: RealtimeVoiceOverlayProps) { const { theme } = useUnistyles(); const { volume, isSpeaking } = useVoiceTelemetry(); + const muteButtonStyle = useMemo( + () => [ + styles.actionButton, + styles.muteButton, + isMuted ? styles.muteButtonMuted : undefined, + isSwitching ? styles.buttonDisabled : undefined, + ], + [isMuted, isSwitching], + ); + const stopButtonStyle = useMemo( + () => [styles.actionButton, styles.stopButton, isSwitching ? styles.buttonDisabled : undefined], + [isSwitching], + ); return ( @@ -40,12 +54,7 @@ export function RealtimeVoiceOverlay({ disabled={isSwitching} accessibilityRole="button" accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"} - style={[ - styles.actionButton, - styles.muteButton, - isMuted ? styles.muteButtonMuted : undefined, - isSwitching ? styles.buttonDisabled : undefined, - ]} + style={muteButtonStyle} > {isMuted ? ( @@ -59,11 +68,7 @@ export function RealtimeVoiceOverlay({ disabled={isSwitching} accessibilityRole="button" accessibilityLabel="Stop realtime voice and interrupt turn" - style={[ - styles.actionButton, - styles.stopButton, - isSwitching ? styles.buttonDisabled : undefined, - ]} + style={stopButtonStyle} > {isSwitching ? ( diff --git a/packages/app/src/components/sidebar-agent-list-skeleton.tsx b/packages/app/src/components/sidebar-agent-list-skeleton.tsx index 835abf361..baf0104c3 100644 --- a/packages/app/src/components/sidebar-agent-list-skeleton.tsx +++ b/packages/app/src/components/sidebar-agent-list-skeleton.tsx @@ -1,14 +1,52 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4]; + function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp }) { const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [0.4, 0.8], }); - return ; + const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]); + + return ; +} + +function SkeletonSection({ + pulse, + sectionOpacity, + sectionIdx, +}: { + pulse: Animated.Value; + sectionOpacity: number; + sectionIdx: number; +}) { + const sectionStyle = useMemo( + () => [styles.section, { opacity: sectionOpacity }], + [sectionOpacity], + ); + return ( + + + + + + + + + {Array.from({ length: 3 }).map((__, rowIdx) => ( + + + + + + ))} + + + ); } export function SidebarAgentListSkeleton() { @@ -36,27 +74,13 @@ export function SidebarAgentListSkeleton() { return ( - {[1, 0.7, 0.4].map((sectionOpacity, sectionIdx) => ( - ( + - - - - - - - - {Array.from({ length: 3 }).map((__, rowIdx) => ( - - - - - - ))} - - + pulse={pulse} + sectionOpacity={sectionOpacity} + sectionIdx={sectionIdx} + /> ))} ); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index cd71a39ca..f2dd07a33 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, type ReactElement } from "react"; +import { useCallback, useMemo, useState, type ReactElement } from "react"; import { DndContext, closestCenter, @@ -25,6 +25,8 @@ const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ y: 0, }); +const DND_MODIFIERS: Modifier[] = [restrictToHorizontalAxis]; + function SortableItem({ id, item, @@ -183,7 +185,10 @@ export function SortableInlineList({ [clearDragState, disabled, items, keyExtractor, onDragEnd], ); - const ids = items.map((item, index) => keyExtractor(item, index)); + const ids = useMemo( + () => items.map((item, index) => keyExtractor(item, index)), + [items, keyExtractor], + ); const renderedItems = ( @@ -215,7 +220,7 @@ export function SortableInlineList({ [animatedStyle, { width: gridWidth, height: gridHeight }], + [animatedStyle, gridWidth, gridHeight], + ); + return ( - + {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { const rowIndex = Math.floor(dotIndex / GRID_COLUMNS); const columnIndex = dotIndex % GRID_COLUMNS; @@ -144,18 +141,19 @@ function SpinnerDot({ }; }); - return ( - + const dotStyle = useMemo( + () => [ + animatedStyle, + { + width: dotSize, + height: dotSize, + borderRadius: dotSize / 2, + backgroundColor: color, + }, + style, + ], + [animatedStyle, dotSize, color, style], ); + + return ; } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index b748ac277..69623121c 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -543,6 +543,11 @@ export function TerminalPane({ ], ); + const containerStyle = useMemo( + () => [styles.container, keyboardPaddingStyle], + [keyboardPaddingStyle], + ); + if (!client || !isConnected) { return ( @@ -552,7 +557,7 @@ export function TerminalPane({ } return ( - + {isWorkspaceFocused ? ( diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index d5ecde0b0..734cb07bc 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -212,10 +212,6 @@ export function ToastViewport({ }; }, [clearTimer, opacity, scheduleDismiss, toast, translateY]); - if (!toast) { - return null; - } - const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topOffset = @@ -223,6 +219,29 @@ export function ToastViewport({ ? insets.top + headerTopPadding + headerHeight + theme.spacing[2] : theme.spacing[3]; + const toastVariant = toast?.variant; + const toastAnimatedStyle = useMemo( + () => [ + styles.toast, + toastVariant === "success" ? styles.toastSuccess : null, + toastVariant === "error" ? styles.toastError : null, + { + marginTop: topOffset, + opacity, + transform: [{ translateY }], + }, + ], + [toastVariant, topOffset, opacity, translateY], + ); + const toastMessageStyle = useMemo( + () => [styles.message, toastVariant === "error" ? styles.messageError : null], + [toastVariant], + ); + + if (!toast) { + return null; + } + const icon = toast.icon ?? (toast.variant === "success" ? ( @@ -237,24 +256,12 @@ export function ToastViewport({ testID={toast.testID ?? "app-toast"} onPointerEnter={isWeb ? pauseDismiss : undefined} onPointerLeave={isWeb ? resumeDismiss : undefined} - style={[ - styles.toast, - toast.variant === "success" ? styles.toastSuccess : null, - toast.variant === "error" ? styles.toastError : null, - { - marginTop: topOffset, - opacity, - transform: [{ translateY }], - }, - ]} + style={toastAnimatedStyle} accessibilityRole="alert" > {icon ? {icon} : null} {typeof toast.content === "string" ? ( - + {toast.content} ) : ( diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 8515061b5..0b4280eac 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; -import type { StyleProp, ViewStyle } from "react-native"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; type SegmentedControlSize = "sm" | "md"; @@ -40,53 +40,92 @@ export function SegmentedControl({ const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md; + const containerStyle = useMemo( + () => [styles.container, containerSizeStyle, style], + [containerSizeStyle, style], + ); + return ( - + {options.map((option) => { const isSelected = option.value === value; const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; return ( - { if (!option.disabled && option.value !== value) { onValueChange(option.value); } }} - style={({ hovered, pressed }) => [ - styles.segment, - segmentSizeStyle, - isSelected && styles.segmentSelected, - hovered && !isSelected && styles.segmentHover, - pressed && !isSelected && styles.segmentPressed, - option.disabled && styles.segmentDisabled, - ]} - > - {option.icon ? ( - - {option.icon({ color: iconColor, size: iconSize })} - - ) : null} - {hideLabels ? null : ( - - {option.label} - - )} - + /> ); })} ); } +function SegmentItem({ + option, + isSelected, + iconColor, + iconSize, + hideLabels, + segmentSizeStyle, + labelSizeStyle, + onPress, +}: { + option: SegmentedControlOption; + isSelected: boolean; + iconColor: string; + iconSize: number; + hideLabels: boolean; + segmentSizeStyle: StyleProp; + labelSizeStyle: StyleProp; + onPress: () => void; +}) { + const labelStyle = useMemo( + () => [styles.label, labelSizeStyle, isSelected && styles.labelSelected], + [labelSizeStyle, isSelected], + ); + return ( + [ + styles.segment, + segmentSizeStyle, + isSelected && styles.segmentSelected, + hovered && !isSelected && styles.segmentHover, + pressed && !isSelected && styles.segmentPressed, + option.disabled && styles.segmentDisabled, + ]} + > + {option.icon ? ( + + {option.icon({ color: iconColor, size: iconSize })} + + ) : null} + {hideLabels ? null : ( + + {option.label} + + )} + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flexDirection: "row", diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index ca80ebb7a..8ba00efd1 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -1,5 +1,6 @@ +import { useMemo } from "react"; import { View, Text } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; type StatusBadgeVariant = "success" | "error" | "muted"; @@ -9,25 +10,26 @@ interface StatusBadgeProps { } export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) { - const { theme } = useUnistyles(); + const pillStyle = useMemo( + () => [ + styles.pill, + variant === "success" && styles.pillSuccess, + variant === "error" && styles.pillError, + ], + [variant], + ); + const textStyle = useMemo( + () => [ + styles.pillText, + variant === "success" && styles.pillTextSuccess, + variant === "error" && styles.pillTextError, + ], + [variant], + ); return ( - - - {label} - + + {label} ); } diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index af3560581..e77ace85e 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -480,6 +480,20 @@ export function TooltipContent({ [], ); + const contentStyle = useMemo( + () => [ + styles.content, + { maxWidth }, + style, + { + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + }, + ], + [maxWidth, style, position?.x, position?.y], + ); + if (!ctx.open || !ctx.enabled) return null; // On web, avoid React Native's implementation (it uses and can @@ -496,16 +510,7 @@ export function TooltipContent({ collapsable={false} testID={testID} onLayout={handleLayout} - style={[ - styles.content, - { maxWidth }, - style, - { - position: "absolute", - top: position?.y ?? -9999, - left: position?.x ?? -9999, - }, - ]} + style={contentStyle} > {children} @@ -530,16 +535,7 @@ export function TooltipContent({ collapsable={false} testID={testID} onLayout={handleLayout} - style={[ - styles.content, - { maxWidth }, - style, - { - position: "absolute", - top: position?.y ?? -9999, - left: position?.x ?? -9999, - }, - ]} + style={contentStyle} > {children} diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index 63b929a5c..5589803e5 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -357,26 +357,51 @@ export function WebDesktopScrollbarOverlay({ ); const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2); + const thumbRegionStyle = useMemo( + () => [ + styles.thumbRegion, + { + top: 0, + height: thumbRegionHeight, + transform: [{ translateY: thumbRegionOffset }], + }, + platformIsWeb && + ({ + cursor: handleCursor, + touchAction: "none", + userSelect: "none", + transitionProperty: "transform", + transitionDuration: `${handleTravelDurationMs}ms`, + transitionTimingFunction: "linear", + } as any), + ], + [thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs], + ); + + const handleStyle = useMemo( + () => [ + styles.handle, + { + marginTop: handleInsetTop, + height: geometry.handleSize, + width: handleWidth, + backgroundColor: handleColor, + opacity: handleOpacity, + }, + platformIsWeb && + ({ + transitionProperty: "opacity, width, background-color", + transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, + transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", + } as any), + ], + [handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity], + ); + return ( - + );