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
@@ -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 (
-
+
);