diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx
index dc67f40dc..531175420 100644
--- a/packages/app/src/components/attachment-lightbox.tsx
+++ b/packages/app/src/components/attachment-lightbox.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -36,6 +36,17 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
};
}, [metadata, onClose]);
+ const closeButtonStyle = useMemo(
+ () => [
+ styles.closeButton,
+ {
+ top: insets.top + theme.spacing[3],
+ right: insets.right + theme.spacing[3],
+ },
+ ],
+ [insets.top, insets.right, theme.spacing],
+ );
+
if (!metadata) {
return null;
}
@@ -74,13 +85,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
accessibilityLabel="Close image"
hitSlop={8}
onPress={onClose}
- style={[
- styles.closeButton,
- {
- top: insets.top + theme.spacing[3],
- right: insets.right + theme.spacing[3],
- },
- ]}
+ style={closeButtonStyle}
>
diff --git a/packages/app/src/components/attachment-pill.tsx b/packages/app/src/components/attachment-pill.tsx
index 069b26000..0a9795ec3 100644
--- a/packages/app/src/components/attachment-pill.tsx
+++ b/packages/app/src/components/attachment-pill.tsx
@@ -1,4 +1,4 @@
-import { type ReactNode, useState } from "react";
+import { type ReactNode, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -30,6 +30,10 @@ export function AttachmentPill({
const [isCloseHovered, setIsCloseHovered] = useState(false);
const alwaysShow = isNative || isCompact;
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
+ const closeButtonStyle = useMemo(
+ () => [styles.closeButton, !showRemove && styles.closeButtonHidden],
+ [showRemove],
+ );
return (
diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx
index 364c752b2..80f61a763 100644
--- a/packages/app/src/components/diff-scroll.web.tsx
+++ b/packages/app/src/components/diff-scroll.web.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -16,13 +17,14 @@ export function DiffScroll({
contentContainerStyle,
}: DiffScrollProps) {
const webScrollbarStyle = useWebScrollbarStyle();
+ const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]);
return (
onScrollViewWidthChange(e.nativeEvent.layout.width)}
>
diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx
index 32b009142..a87e204a8 100644
--- a/packages/app/src/components/draggable-list.web.test.tsx
+++ b/packages/app/src/components/draggable-list.web.test.tsx
@@ -90,11 +90,13 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+const DATA: string[] = ["alpha", "beta"];
+
function renderList(): void {
act(() => {
root?.render(
item}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx
index 52302ae35..58b5ad523 100644
--- a/packages/app/src/components/file-drop-zone.tsx
+++ b/packages/app/src/components/file-drop-zone.tsx
@@ -1,7 +1,7 @@
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
@@ -33,6 +33,11 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
}));
+ const overlayStyle = useMemo(
+ () => [styles.overlay, overlayAnimatedStyle],
+ [overlayAnimatedStyle],
+ );
+
// On non-web platforms, just render children
if (!IS_WEB) {
return <>{children}>;
@@ -47,7 +52,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{children}
{/* Drop overlay */}
-
+
{/* Backdrop */}
{/* Content */}
diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx
index e0f5b2cc6..c1f6792f1 100644
--- a/packages/app/src/components/headers/header-toggle-button.tsx
+++ b/packages/app/src/components/headers/header-toggle-button.tsx
@@ -1,4 +1,4 @@
-import type { ReactElement, ReactNode } from "react";
+import { useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -42,6 +42,8 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as any)
: null;
+ const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]);
+
return (
{
onPress(e);
}}
- style={[headerIconSlotStyle.slot, style]}
+ style={combinedStyle}
>
{typeof children === "function"
? (state: { pressed: boolean; hovered?: boolean }) =>
diff --git a/packages/app/src/components/headers/screen-title.tsx b/packages/app/src/components/headers/screen-title.tsx
index de8b98449..338ad1961 100644
--- a/packages/app/src/components/headers/screen-title.tsx
+++ b/packages/app/src/components/headers/screen-title.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { useMemo, type ReactNode } from "react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -15,8 +15,9 @@ interface ScreenTitleProps {
* HeaderIconBadge) — never nested inside this component.
*/
export function ScreenTitle({ children, numberOfLines = 1, testID, style }: ScreenTitleProps) {
+ const combinedStyle = useMemo(() => [styles.text, style], [style]);
return (
-
+
{children}
);
diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx
index e882f87f6..92bc86e01 100644
--- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx
+++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx
@@ -8,6 +8,8 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
+const SNAP_POINTS: string[] = ["70%", "92%"];
+
export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
@@ -25,7 +27,7 @@ export function KeyboardShortcutsDialog() {
visible={open}
onClose={() => setOpen(false)}
testID="keyboard-shortcuts-dialog"
- snapPoints={["70%", "92%"]}
+ snapPoints={SNAP_POINTS}
>
{sections.map((section) => (
diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx
index 26e796221..94fad1fd5 100644
--- a/packages/app/src/components/sidebar/sidebar-header-row.tsx
+++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { LucideIcon } from "lucide-react-native";
@@ -47,7 +48,7 @@ export function SidebarHeaderRow({
return (
<>
- {label}
+
>
);
}}
@@ -56,6 +57,20 @@ export function SidebarHeaderRow({
);
}
+function SidebarHeaderRowLabel({
+ label,
+ isHighlighted,
+}: {
+ label: string;
+ isHighlighted: boolean;
+}) {
+ const labelStyle = useMemo(
+ () => [styles.label, isHighlighted && styles.labelHighlighted],
+ [isHighlighted],
+ );
+ return {label};
+}
+
const styles = StyleSheet.create((theme) => ({
container: {
height: {
diff --git a/packages/app/src/components/sortable-inline-list.web.test.tsx b/packages/app/src/components/sortable-inline-list.web.test.tsx
index 29cfb6a56..50b20df5a 100644
--- a/packages/app/src/components/sortable-inline-list.web.test.tsx
+++ b/packages/app/src/components/sortable-inline-list.web.test.tsx
@@ -73,11 +73,13 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+const DATA: string[] = ["alpha", "beta"];
+
function renderList(): void {
act(() => {
root?.render(
item}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx
index 7da8dbfb5..97be1d105 100644
--- a/packages/app/src/components/ui/button.tsx
+++ b/packages/app/src/components/ui/button.tsx
@@ -1,4 +1,10 @@
-import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
+import {
+ useMemo,
+ useState,
+ type ComponentType,
+ type PropsWithChildren,
+ type ReactElement,
+} from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -119,14 +125,17 @@ export function Button({
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
- const resolvedTextStyle = [
- styles.text,
- variant === "default" ? styles.textDefault : null,
- variant === "destructive" ? styles.textDestructive : null,
- variant === "ghost" ? styles.textGhost : null,
- textStyle,
- isGhostHovered ? styles.textGhostHovered : null,
- ];
+ const resolvedTextStyle = useMemo(
+ () => [
+ styles.text,
+ variant === "default" ? styles.textDefault : null,
+ variant === "destructive" ? styles.textDestructive : null,
+ variant === "ghost" ? styles.textGhost : null,
+ textStyle,
+ isGhostHovered ? styles.textGhostHovered : null,
+ ],
+ [variant, textStyle, isGhostHovered],
+ );
function renderIcon() {
if (!leftIcon) return null;
diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx
index 56fc9abc3..8a3f90537 100644
--- a/packages/app/src/components/workspace-setup-dialog.tsx
+++ b/packages/app/src/components/workspace-setup-dialog.tsx
@@ -26,6 +26,8 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
return `data:${icon.mimeType};base64,${icon.data}`;
}
+const SNAP_POINTS: string[] = ["82%", "94%"];
+
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
@@ -284,7 +286,7 @@ export function WorkspaceSetupDialog() {
subtitle={subtitleContent}
visible={true}
onClose={handleClose}
- snapPoints={["82%", "94%"]}
+ snapPoints={SNAP_POINTS}
testID="workspace-setup-dialog"
desktopMaxWidth={640}
onFilesDropped={handleFilesDropped}
diff --git a/packages/app/src/desktop/components/desktop-permission-row.tsx b/packages/app/src/desktop/components/desktop-permission-row.tsx
index a6036818b..0cf0eb389 100644
--- a/packages/app/src/desktop/components/desktop-permission-row.tsx
+++ b/packages/app/src/desktop/components/desktop-permission-row.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
@@ -38,8 +39,13 @@ export function DesktopPermissionRow({
state !== "prompt" &&
state !== "not-granted";
+ const rowStyle = useMemo(
+ () => [settingsStyles.row, showBorder && settingsStyles.rowBorder],
+ [showBorder],
+ );
+
return (
-
+
{title}
diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx
index e11d35041..85bbc87f7 100644
--- a/packages/app/src/desktop/components/desktop-permissions-section.tsx
+++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from "react";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
@@ -27,6 +28,10 @@ export function DesktopPermissionsSection() {
const isBusy = isRefreshing || requestingPermission !== null;
const notificationsGranted = snapshot?.notifications.state === "granted";
+ const errorTextStyle = useMemo(
+ () => [styles.errorText, { color: theme.colors.destructive }],
+ [theme.colors.destructive],
+ );
const refreshButton = (
- {testNotificationError ? (
-
- {testNotificationError}
-
- ) : null}
+ {testNotificationError ? {testNotificationError} : null}
)}
-
+
diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx
index 3931ab2ec..6d78d4a99 100644
--- a/packages/app/src/panels/setup-panel.tsx
+++ b/packages/app/src/panels/setup-panel.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import invariant from "tiny-invariant";
@@ -243,10 +243,9 @@ function SetupPanel() {
{command.durationMs != null ? (
{formatDuration(command.durationMs)}
) : null}
-
{showDetail ? (
@@ -324,6 +323,14 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = {
useDescriptor: useSetupPanelDescriptor,
};
+function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) {
+ const chevronStyle = useMemo(
+ () => [styles.chevron, showDetail && styles.chevronExpanded],
+ [showDetail],
+ );
+ return ;
+}
+
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/screens/settings/settings-section.tsx
index b67dfec5f..f363a0875 100644
--- a/packages/app/src/screens/settings/settings-section.tsx
+++ b/packages/app/src/screens/settings/settings-section.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
@@ -23,8 +23,9 @@ export function SettingsSection({
style,
children,
}: SettingsSectionProps) {
+ const sectionStyle = useMemo(() => [settingsStyles.section, style], [style]);
return (
-
+
{title}
{trailing}
diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
index a43371e00..9073d6294 100644
--- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
+++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
@@ -337,6 +337,11 @@ export function WorkspaceDraftAgentTab({
[composerState],
);
+ const inputAreaWrapperStyle = useMemo(
+ () => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
+ [insets.bottom],
+ );
+
return (
@@ -368,7 +373,7 @@ export function WorkspaceDraftAgentTab({
)}
-
+
Promise;
unmount: () => void;
@@ -158,7 +160,7 @@ function renderScripts(scripts: WorkspaceScriptPayload[]): {
serverId="test-server"
workspaceId="workspace-1"
scripts={nextScripts}
- liveTerminalIds={["terminal-script-1"]}
+ liveTerminalIds={LIVE_TERMINAL_IDS}
/>
);