From 01f89c7c363b310d43885a0b1ca3e267374e3839 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Apr 2026 16:12:09 +0700 Subject: [PATCH] refactor: strip useUnistyles in favor of withUnistyles + StyleSheet Replaces broad useUnistyles() subscriptions in composer, workspace, and related components with withUnistyles wrappers and StyleSheet.create so re-renders stay scoped to the tokens actually consumed. Lifts the design scales (spacing, iconSize, fontSize, etc.) to top-level exports in theme.ts so component literals share a single source of truth with the unistyles theme. --- docs/UNISTYLES.md | 68 +++++ .../app/src/components/agent-stream-view.tsx | 120 ++++---- packages/app/src/components/composer.test.tsx | 1 + packages/app/src/components/composer.tsx | 54 ++-- .../app/src/components/message-input.test.tsx | 2 +- packages/app/src/components/message-input.tsx | 53 ++-- packages/app/src/components/message.tsx | 167 +++++++---- .../src/components/sidebar-workspace-list.tsx | 275 +++++++++--------- .../src/components/stream-strategy-web.tsx | 151 +++++++--- .../src/components/workspace-hover-card.tsx | 105 ++++--- .../src/components/workspace-setup-dialog.tsx | 12 +- packages/app/src/panels/agent-panel.test.tsx | 2 +- packages/app/src/panels/agent-panel.tsx | 26 +- packages/app/src/panels/setup-panel.tsx | 43 ++- .../workspace/workspace-desktop-tabs-row.tsx | 70 ++--- .../workspace/workspace-git-actions.tsx | 45 +-- .../workspace-open-in-editor-button.tsx | 34 ++- .../screens/workspace/workspace-screen.tsx | 145 +++++---- .../workspace-scripts-button.test.tsx | 12 +- .../workspace/workspace-scripts-button.tsx | 138 +++++---- .../workspace/workspace-tab-presentation.tsx | 68 +++-- packages/app/src/styles/theme.ts | 143 ++++----- 22 files changed, 1014 insertions(+), 720 deletions(-) diff --git a/docs/UNISTYLES.md b/docs/UNISTYLES.md index b3b0d4ba4..6ca3ed34f 100644 --- a/docs/UNISTYLES.md +++ b/docs/UNISTYLES.md @@ -4,6 +4,74 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles. +## STOP — `useUnistyles()` Is Forbidden + +**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles): + +> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail. + +We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value. + +Before reaching for `useUnistyles()`, work down this list of alternatives in order: + +### 1. `StyleSheet.create((theme) => ...)` — default + +Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render. + +```tsx +const styles = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface0, + padding: theme.spacing[4], + }, +})); + +; +``` + +If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook. + +### 2. Hard-coded constants for genuinely static values + +If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static. + +### 3. `withUnistyles(Component)` for third-party props + +When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree. + +```tsx +const ThemedBlur = withUnistyles(BlurView); +; +``` + +(Mind the `> *` child-selector leak documented further down.) + +### 4. Lift the read into a tiny leaf component + +If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe. + +### 5. (Last resort) `useUnistyles()` + +Only acceptable when both of: + +- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND +- (b) The component is small, leaf-level, and not on a hot render path. + +If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out. + +### Hot-path forbidden list + +Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive: + +- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons) +- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection` +- `Composer` and `MessageInput` +- `WorkspaceScreen` shell, tabs row, deck wrapper +- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter` +- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`) + +Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above. + ## How Updates Propagate For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders. diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index eefae3570..5c0fcafbb 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -18,7 +18,7 @@ import { ActivityIndicator, type PressableStateCallbackType, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "expo-router"; @@ -84,6 +84,7 @@ import { WORKING_INDICATOR_OFFSETS, } from "@/utils/working-indicator"; import { isWeb } from "@/constants/platform"; +import { SPACING, type Theme } from "@/styles/theme"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -156,7 +157,6 @@ const AgentStreamViewComponent = forwardRef(null); - const { theme } = useUnistyles(); const router = useRouter(); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( @@ -303,9 +303,9 @@ const AgentStreamViewComponent = forwardRef { @@ -891,16 +891,33 @@ function ToolCallSlot({ return ; } +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedCheckIcon = withUnistyles(Check); +const ThemedXIcon = withUnistyles(X); + +const primaryColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); +const mutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const pressableStyle = ({ + pressed, + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) => [ + permissionStyles.optionButton, + hovered ? permissionStyles.optionButtonHovered : null, + pressed ? permissionStyles.optionButtonPressed : null, +]; + interface PermissionActionButtonProps { action: AgentPermissionAction; isRespondingAction: boolean; isResponding: boolean; - textColor: string; - iconColor: string; - isDanger: boolean; - Icon: typeof Check; + isPrimary: boolean; + Icon: typeof ThemedCheckIcon; testID: string; - theme: ReturnType["theme"]; onPress: (action: AgentPermissionAction) => void; } @@ -908,37 +925,21 @@ function PermissionActionButton({ action, isRespondingAction, isResponding, - textColor, - iconColor, - isDanger, + isPrimary, Icon, testID, - theme, onPress, }: PermissionActionButtonProps) { const handlePress = useCallback(() => onPress(action), [onPress, action]); - const pressableStyle = useCallback( - ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ - permissionStyles.optionButton, - { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent, - }, - pressed ? permissionStyles.optionButtonPressed : null, - ], - [theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger], - ); - const optionTextStyle = useMemo( - () => [permissionStyles.optionText, { color: textColor }], - [textColor], - ); + const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText; + const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping; return ( {isRespondingAction ? ( - + ) : ( - + {action.label} )} @@ -953,7 +954,6 @@ function PermissionRequestCard({ permission: PendingPermission; client: DaemonClient | null; }) { - const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); const { request } = permission; @@ -1069,10 +1069,6 @@ function PermissionRequestCard({ [handleResponse], ); - const questionTextStyle = useMemo( - () => [permissionStyles.question, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); const optionsContainerStyle = useMemo( () => [ permissionStyles.optionsContainer, @@ -1080,24 +1076,6 @@ function PermissionRequestCard({ ], [isMobile], ); - const cardContainerStyle = useMemo( - () => [ - permissionStyles.container, - { - backgroundColor: theme.colors.surface1, - borderColor: theme.colors.border, - }, - ], - [theme.colors.surface1, theme.colors.border], - ); - const cardTitleStyle = useMemo( - () => [permissionStyles.title, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const cardDescriptionStyle = useMemo( - () => [permissionStyles.description, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); if (request.kind === "question") { return ( @@ -1111,18 +1089,15 @@ function PermissionRequestCard({ const footer = ( <> - + How would you like to proceed? {resolvedActions.map((action) => { - const isDanger = action.variant === "danger" || action.behavior === "deny"; const isPrimary = action.variant === "primary"; const isRespondingAction = respondingActionId === action.id; - const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted; - const iconColor = textColor; - const Icon = action.behavior === "allow" ? Check : X; + const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon; let testID: string; if (action.behavior === "deny") testID = "permission-request-deny"; else if (action.id === "accept" || action.id === "implement") @@ -1135,12 +1110,9 @@ function PermissionRequestCard({ action={action} isRespondingAction={isRespondingAction} isResponding={isResponding} - textColor={textColor} - iconColor={iconColor} - isDanger={isDanger} + isPrimary={isPrimary} Icon={Icon} testID={testID} - theme={theme} onPress={handleActionPress} /> ); @@ -1162,10 +1134,10 @@ function PermissionRequestCard({ } return ( - - {title} + + {title} - {description ? {description} : null} + {description ? {description} : null} {planMarkdown ? ( @@ -1311,14 +1283,18 @@ const permissionStyles = StyleSheet.create((theme) => ({ borderRadius: theme.spacing[2], borderWidth: 1, gap: theme.spacing[2], + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.border, }, title: { fontSize: theme.fontSize.base, lineHeight: 22, + color: theme.colors.foreground, }, description: { fontSize: theme.fontSize.sm, lineHeight: 20, + color: theme.colors.foregroundMuted, }, section: { gap: theme.spacing[2], @@ -1330,6 +1306,7 @@ const permissionStyles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], marginBottom: theme.spacing[1], + color: theme.colors.foregroundMuted, }, optionsContainer: { gap: theme.spacing[2], @@ -1346,6 +1323,11 @@ const permissionStyles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.md, alignItems: "center", borderWidth: theme.borderWidth[1], + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + optionButtonHovered: { + backgroundColor: theme.colors.surface2, }, optionButtonPressed: { opacity: 0.9, @@ -1358,9 +1340,15 @@ const permissionStyles = StyleSheet.create((theme) => ({ optionText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + optionTextPrimary: { + color: theme.colors.foreground, }, })); +const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary]; + interface StreamItemWrapperProps { gapBelow: number; children: ReactNode; diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx index 69ad86689..423cdc160 100644 --- a/packages/app/src/components/composer.test.tsx +++ b/packages/app/src/components/composer.test.tsx @@ -188,6 +188,7 @@ vi.mock("react-native-unistyles", () => ({ StyleSheet: { create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), }, + withUnistyles: (component: T) => component, useUnistyles: () => ({ theme }), })); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 75fba3433..99978fa0a 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -7,7 +7,7 @@ import { type PressableStateCallbackType, } from "react-native"; import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; import { @@ -40,7 +40,7 @@ import { type MessageInputRef, type AttachmentMenuItem, } from "./message-input"; -import type { Theme } from "@/styles/theme"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; @@ -92,8 +92,8 @@ type AttachmentListUpdater = function noop() {} -function resolveComposerButtonIconSize(theme: Theme): number { - return isWeb ? theme.iconSize.md : theme.iconSize.lg; +function resolveComposerButtonIconSize(): number { + return isWeb ? ICON_SIZE.md : ICON_SIZE.lg; } function resolveIsComposerLocked( @@ -585,7 +585,6 @@ interface QueuedMessageRowProps { } function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { - const { theme } = useUnistyles(); const handleEdit = useCallback(() => { onEdit(item.id); }, [onEdit, item.id]); @@ -599,10 +598,10 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { - + - + @@ -668,7 +667,6 @@ function GithubAttachmentPill({ onOpen, onRemove, }: GithubAttachmentPillProps) { - const { theme } = useUnistyles(); const item = attachment.item; const kindLabel = item.kind === "pr" ? "PR" : "issue"; const handleOpen = useCallback(() => { @@ -689,9 +687,9 @@ function GithubAttachmentPill({ {item.kind === "pr" ? ( - + ) : ( - + )} @@ -719,18 +717,17 @@ function GithubPickerOption({ item, onToggle, }: GithubPickerOptionProps) { - const { theme } = useUnistyles(); const handlePress = useCallback(() => { onToggle(item); }, [onToggle, item]); const leadingSlot = useMemo( () => item.kind === "pr" ? ( - + ) : ( - + ), - [item.kind, theme.iconSize.sm, theme.colors.foregroundMuted], + [item.kind], ); return ( (object | undefined)[]; - theme: Theme; voiceToggleKeys: ReturnType; } @@ -911,7 +907,6 @@ function ComposerVoiceModeButton({ isConnected, isVoiceSwitching, realtimeVoiceButtonStyle, - theme, voiceToggleKeys, }: ComposerVoiceModeButtonProps) { const shortcutNode = voiceToggleKeys ? ( @@ -922,10 +917,10 @@ function ComposerVoiceModeButton({ if (isVoiceSwitching) { return ; } - const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; - return ; + const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; + return ; }, - [buttonIconSize, isVoiceSwitching, theme.colors.foreground, theme.colors.foregroundMuted], + [buttonIconSize, isVoiceSwitching], ); return ( @@ -978,8 +973,7 @@ export function Composer({ inputWrapperStyle, }: ComposerProps) { markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); - const { theme } = useUnistyles(); - const buttonIconSize = resolveComposerButtonIconSize(theme); + const buttonIconSize = resolveComposerButtonIconSize(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); @@ -1430,7 +1424,6 @@ export function Composer({ isConnected={isConnected} isVoiceSwitching={isVoiceSwitchingValue} realtimeVoiceButtonStyle={realtimeVoiceButtonStyle} - theme={theme} voiceToggleKeys={voiceToggleKeys} cancelButton={cancelButton} /> @@ -1447,7 +1440,6 @@ export function Composer({ isVoiceModeForAgent, isVoiceSwitchingValue, realtimeVoiceButtonStyle, - theme, voiceToggleKeys, ], ); @@ -1491,7 +1483,7 @@ export function Composer({ { id: "image", label: "Add image", - icon: , + icon: , onSelect: () => { void handlePickImage(); }, @@ -1499,13 +1491,13 @@ export function Composer({ { id: "github", label: "Add issue or PR", - icon: , + icon: , onSelect: () => { setIsGithubPickerOpen(true); }, }, ], - [handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md], + [handlePickImage], ); const handleToggleGithubItem = useCallback( @@ -1860,3 +1852,13 @@ const styles = StyleSheet.create((theme: Theme) => ({ })) as unknown as Record; const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton]; + +const ThemedPencil = withUnistyles(Pencil); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const ThemedCircleDot = withUnistyles(CircleDot); +const ThemedAudioLines = withUnistyles(AudioLines); +const ThemedPaperclip = withUnistyles(Paperclip); +const ThemedGithub = withUnistyles(Github); + +const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); diff --git a/packages/app/src/components/message-input.test.tsx b/packages/app/src/components/message-input.test.tsx index b54eb344b..a82333373 100644 --- a/packages/app/src/components/message-input.test.tsx +++ b/packages/app/src/components/message-input.test.tsx @@ -47,7 +47,7 @@ vi.mock("react-native-unistyles", () => ({ StyleSheet: { create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), }, - useUnistyles: () => ({ theme }), + withUnistyles: (component: T) => component, })); vi.mock("@/constants/platform", () => ({ diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index bf29c908f..5774019e1 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -18,8 +18,8 @@ import { useMemo, forwardRef, } from "react"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useDictation } from "@/hooks/use-dictation"; @@ -195,18 +195,15 @@ function AttachButtonIcon({ hovered, onAttachButtonRef, buttonIconSize, - foreground, - foregroundMuted, }: { hovered: boolean; onAttachButtonRef: ((node: View | null) => void) | undefined; buttonIconSize: number; - foreground: string; - foregroundMuted: string; }) { + const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ( - + ); } @@ -278,23 +275,20 @@ function VoiceButtonIcon({ isDictating, isMutedRealtime, buttonIconSize, - foreground, - foregroundMuted, }: { hovered: boolean; isDictating: boolean; isMutedRealtime: boolean; buttonIconSize: number; - foreground: string; - foregroundMuted: string; }) { if (isDictating) { return ; } + const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; if (isMutedRealtime) { - return ; + return ; } - return ; + return ; } type ShortcutChord = NonNullable["chord"]>; @@ -1365,8 +1359,7 @@ export const MessageInput = forwardRef( onHeightChange, inputWrapperStyle, } = resolveMessageInputProps(props); - const { theme } = useUnistyles(); - const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; + const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId); markScrollInvestigationRender(investigationComponentId); const toast = useToast(); @@ -1892,11 +1885,9 @@ export const MessageInput = forwardRef( hovered={Boolean(hovered)} onAttachButtonRef={onAttachButtonRef} buttonIconSize={buttonIconSize} - foreground={theme.colors.foreground} - foregroundMuted={theme.colors.foregroundMuted} /> ), - [onAttachButtonRef, buttonIconSize, theme.colors.foreground, theme.colors.foregroundMuted], + [onAttachButtonRef, buttonIconSize], ); const renderVoiceButtonIcon = useCallback( @@ -1906,18 +1897,9 @@ export const MessageInput = forwardRef( isDictating={isDictating} isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)} buttonIconSize={buttonIconSize} - foreground={theme.colors.foreground} - foregroundMuted={theme.colors.foregroundMuted} /> ), - [ - isDictating, - isRealtimeVoiceForCurrentAgent, - voice?.isMuted, - buttonIconSize, - theme.colors.foreground, - theme.colors.foregroundMuted, - ], + [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); return ( @@ -1926,12 +1908,12 @@ export const MessageInput = forwardRef( {/* Text input */} - ({ bottom: 0, }, })) as unknown as Record; + +const ThemedPlus = withUnistyles(Plus); +const ThemedMic = withUnistyles(Mic); +const ThemedMicOff = withUnistyles(MicOff); +const ThemedTextInput = withUnistyles(TextInput); + +const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const textInputPlaceholderColorMapping = (theme: Theme) => ({ + placeholderTextColor: theme.colors.surface4, +}); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 6710e4f83..c8d3035f8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -48,7 +48,8 @@ import { MicVocal, FileSymlink, } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { Easing, @@ -102,6 +103,29 @@ export type { InlinePathTarget } from "@/utils/inline-path"; type MarkdownStyles = Record; +function useMessageRenderTracker( + label: string, + identity: string, + watched: Record, +): void { + const renderCountRef = useRef(0); + const prevRef = useRef | null>(null); + renderCountRef.current += 1; + const changed: string[] = []; + if (prevRef.current !== null) { + for (const key of Object.keys(watched)) { + if (!Object.is(prevRef.current[key], watched[key])) { + changed.push(key); + } + } + } + prevRef.current = { ...watched }; + console.log(`[${label}]`, identity, `render #${renderCountRef.current}`, { + isFirst: renderCountRef.current === 1, + changed, + }); +} + interface UserMessageProps { message: string; images?: UserMessageImageAttachment[]; @@ -154,6 +178,26 @@ interface MarkdownWithStableRendererProps { } const MarkdownWithStableRenderer = Markdown as ComponentType; +const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer); +const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never; + +const ThemedMicVocal = withUnistyles(MicVocal); +const ThemedTodoCheckIcon = withUnistyles(Check); +const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); +const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); +const ThemedChevronRightIcon = withUnistyles(ChevronRight); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const mutedForegroundColorMapping = (theme: Theme) => ({ + color: theme.colors.mutedForeground, +}); +const primaryForegroundColorMapping = (theme: Theme) => ({ + color: theme.colors.primaryForeground, +}); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { 0% { @@ -381,7 +425,13 @@ export const UserMessage = memo(function UserMessage({ isLastInGroup = true, disableOuterSpacing, }: UserMessageProps) { - const { theme } = useUnistyles(); + useMessageRenderTracker("UserMessage", `${(message ?? "").slice(0, 16)}`, { + message, + images, + isFirstInGroup, + isLastInGroup, + disableOuterSpacing, + }); const isCompact = useIsCompactFormFactor(); const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); @@ -398,12 +448,12 @@ export const UserMessage = memo(function UserMessage({ () => [ userMessageStylesheet.container, !resolvedDisableOuterSpacing && [ - isFirstInGroup && { marginTop: theme.spacing[4] }, - isLastInGroup && { marginBottom: theme.spacing[4] }, - !isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined, + isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null, + isLastInGroup ? userMessageStylesheet.containerLastInGroup : null, + !isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null, ], ], - [resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup, theme.spacing], + [resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup], ); const imagePreviewContainerStyle = useMemo( () => [ @@ -628,7 +678,6 @@ function AssistantMarkdownImage({ workspaceRoot?: string; serverId?: string; }) { - const { theme } = useUnistyles(); const resolution = useMemo( () => resolveAssistantImageSource({ source, workspaceRoot }), [source, workspaceRoot], @@ -636,10 +685,10 @@ function AssistantMarkdownImage({ const dataImage = useMemo(() => parseImageDataUrl(source), [source]); const containerStyle = useMemo>( () => ({ - marginTop: hasLeadingContent ? theme.spacing[4] : 0, + marginTop: hasLeadingContent ? 16 : 0, marginBottom: 0, }), - [hasLeadingContent, theme], + [hasLeadingContent], ); const query = useQuery({ @@ -1224,7 +1273,6 @@ function AssistantMessageBlockContainer({ interface MemoizedMarkdownBlockProps { text: string; - styles: ReturnType; rules: RenderRules; parser: MarkdownIt; onLinkPress: (url: string) => boolean; @@ -1232,14 +1280,13 @@ interface MemoizedMarkdownBlockProps { const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ text, - styles, rules, parser, onLinkPress, }: MemoizedMarkdownBlockProps) { return ( - {text} - + ); }); @@ -1333,13 +1380,23 @@ export const AssistantMessage = memo(function AssistantMessage({ disableOuterSpacing, spacing = "default", }: AssistantMessageProps) { - const { theme } = useUnistyles(); + useMessageRenderTracker( + "AssistantMessage", + `${serverId ?? "?"}/${(message ?? "").slice(0, 16)}`, + { + message, + onInlinePathPress, + workspaceRoot, + serverId, + client, + disableOuterSpacing, + spacing, + }, + ); const resolvedDisableOuterSpacing = useDisableOuterSpacing( disableOuterSpacing ?? spacing !== "default", ); - const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); - const markdownParser = useMemo(() => { const parser = MarkdownIt({ typographer: true, linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser); @@ -1602,11 +1659,10 @@ export const AssistantMessage = memo(function AssistantMessage({ [ @@ -1669,7 +1724,7 @@ export const SpeakMessage = memo(function SpeakMessage({ return ( - + Spoke {message} @@ -1927,7 +1982,6 @@ interface TodoListItemRowProps { } function TodoListItemRow({ text, completed }: TodoListItemRowProps) { - const { theme: todoUnistylesTheme } = useUnistyles(); const badgeStyle = useMemo( () => [ todoListCardStylesheet.radioBadge, @@ -1944,7 +1998,9 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) { return ( - {completed ? : null} + {completed ? ( + + ) : null} {text} @@ -1996,7 +2052,6 @@ export const TodoListCard = memo(function TodoListCard({ items, disableOuterSpacing, }: TodoListCardProps) { - const { theme: _unistylesTheme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(false); const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); @@ -2158,7 +2213,6 @@ function ExpandableBadgeLabelRow({ onOpenFileHoverIn, onOpenFileHoverOut, }: ExpandableBadgeLabelRowProps) { - const { theme } = useUnistyles(); return ( - ) : null} @@ -2217,52 +2271,42 @@ function ExpandableBadgeLabelRow({ ); } -function resolveExpandableBadgeIconColor({ +function renderExpandableBadgeIcon({ isError, isActive, - theme, + ThemedIcon, }: { isError: boolean; isActive: boolean; - theme: ReturnType["theme"]; -}): string { - if (isError) return theme.colors.destructive; - if (isActive) return theme.colors.foreground; - return theme.colors.mutedForeground; -} - -function renderExpandableBadgeIcon({ - isError, - iconColor, - icon, -}: { - isError: boolean; - iconColor: string; - icon?: ComponentType<{ size?: number; color?: string }>; + ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null; }): ReactNode { if (isError) { - return ; + return ; } - if (icon) { - const IconComponent = icon; - return ; + if (ThemedIcon) { + return ( + + ); } return null; } function renderExpandableBadgeIconSlot({ showChevron, - chevronColor, chevronStyle, iconNode, }: { showChevron: boolean; - chevronColor: string; chevronStyle: StyleProp; iconNode: ReactNode; }): ReactNode { if (showChevron) { - return ; + return ( + + ); } return iconNode; } @@ -2385,7 +2429,6 @@ const ExpandableBadge = memo(function ExpandableBadge({ disableOuterSpacing, testID, }: ExpandableBadgeProps) { - const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isHovered, setIsHovered] = useState(false); const [isOpenFileHovered, setIsOpenFileHovered] = useState(false); @@ -2614,11 +2657,10 @@ const ExpandableBadge = memo(function ExpandableBadge({ [isExpanded], ); - const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme }); - const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon }); + const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]); + const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon }); const iconSlotNode = renderExpandableBadgeIconSlot({ showChevron: isInteractive && isHovered && !isIconHovered, - chevronColor: theme.colors.foreground, chevronStyle, iconNode, }); @@ -2743,6 +2785,21 @@ export const ToolCall = memo(function ToolCall({ onInlineDetailsExpandedChange, onOpenFilePath, }: ToolCallProps) { + useMessageRenderTracker("ToolCall", `${toolName}/${status}`, { + toolName, + args, + result, + error, + status, + detail, + cwd, + metadata, + isLastInSequence, + disableOuterSpacing, + onInlineDetailsHoverChange, + onInlineDetailsExpandedChange, + onOpenFilePath, + }); const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(false); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4fdb56076..19227105e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -9,6 +9,7 @@ import { ScrollView, type GestureResponderEvent, type PressableStateCallbackType, + type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; import { useQueries } from "@tanstack/react-query"; @@ -24,7 +25,8 @@ import { } from "react"; import { router, usePathname, type Href } from "expo-router"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; @@ -85,7 +87,8 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { confirmDialog } from "@/utils/confirm-dialog"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; -import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; +import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; @@ -125,17 +128,47 @@ const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = 0; const EMPHASIZED_STATUS_DOT_OFFSET = -1; -function getWorkspacePrIconColor( - theme: ReturnType["theme"], - state: PrHint["state"], -) { +const ThemedExternalLink = withUnistyles(ExternalLink); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const ThemedGitHubIcon = withUnistyles(GitHubIcon); +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedSyncedLoader = withUnistyles(SyncedLoader); +const ThemedMonitor = withUnistyles(Monitor); +const ThemedFolderGit2 = withUnistyles(FolderGit2); +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedGlobe = withUnistyles(Globe); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedMoreVertical = withUnistyles(MoreVertical); +const ThemedTrash2 = withUnistyles(Trash2); +const ThemedSettings = withUnistyles(Settings); +const ThemedCopy = withUnistyles(Copy); +const ThemedArchive = withUnistyles(Archive); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); +const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); +const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); +const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); +const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); +const syncedLoaderColorMapping = (theme: Theme) => ({ + color: + theme.colorScheme === "light" + ? theme.colors.palette.amber[700] + : theme.colors.palette.amber[500], +}); + +function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { case "merged": - return theme.colors.palette.purple[500]; + return purpleColorMapping; case "open": - return theme.colors.palette.green[500]; + return greenColorMapping; case "closed": - return theme.colors.palette.red[500]; + return redColorMapping; } } @@ -223,10 +256,7 @@ function useSidebarWorkspaceEntry( } export function PrBadge({ hint }: { hint: PrHint }) { - const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); - const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; - const iconColor = getWorkspacePrIconColor(theme, hint.state); const handlePressIn = useCallback((event: GestureResponderEvent) => { event.stopPropagation(); @@ -243,10 +273,8 @@ export function PrBadge({ hint }: { hint: PrHint }) { const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); - const prBadgeTextStyle = useMemo( - () => [prBadgeStyles.text, { color: activeColor }], - [activeColor], - ); + const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text; + const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); return ( {isHovered ? ( - + ) : ( - + )} - + #{hint.number} @@ -302,15 +330,16 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, + color: theme.colors.foregroundMuted, + }, + textHovered: { + color: theme.colors.foreground, }, })); +const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered]; + function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null { - const { theme } = useUnistyles(); - - const color = theme.colors.palette.red[500]; - const textStyle = useMemo(() => [checksBadgeStyles.text, { color }], [color]); - if (!checks || checks.length === 0) return null; const failed = checks.filter((c) => c.status === "failure").length; @@ -320,8 +349,8 @@ function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | n return ( - - {label} + + {label} ); } @@ -336,6 +365,7 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, + color: theme.colors.palette.red[500], }, })); @@ -348,13 +378,12 @@ function WorkspaceStatusIndicator({ workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; }) { - const { theme } = useUnistyles(); const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); if (loading) { return ( - + ); } @@ -362,14 +391,7 @@ function WorkspaceStatusIndicator({ if (shouldShowSyncedLoader) { return ( - + ); } @@ -377,18 +399,18 @@ function WorkspaceStatusIndicator({ if (bucket === "needs_input") { return ( - + ); } - let KindIcon: typeof Monitor | typeof FolderGit2 | null; - if (workspaceKind === "local_checkout") KindIcon = Monitor; - else if (workspaceKind === "worktree") KindIcon = FolderGit2; + let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null; + if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; + else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; else KindIcon = null; if (!KindIcon) return null; - const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false }); + const dotColorStyle = getStatusDotColorStyle(bucket); const statusDotSize = isEmphasizedStatusDotBucket(bucket) ? EMPHASIZED_STATUS_DOT_SIZE : DEFAULT_STATUS_DOT_SIZE; @@ -399,11 +421,10 @@ function WorkspaceStatusIndicator({ return ( - - {dotColor ? ( + + {dotColorStyle ? ( @@ -413,29 +434,26 @@ function WorkspaceStatusIndicator({ } function StatusDotOverlay({ - dotColor, - borderColor, + dotColorStyle, size, offset, }: { - dotColor: string; - borderColor: string; + dotColorStyle: ViewStyle; size: number; offset: number; }) { const overlayStyle = useMemo( () => [ styles.statusDotOverlay, + dotColorStyle, { - backgroundColor: dotColor, - borderColor, width: size, height: size, right: offset, bottom: offset, }, ], - [dotColor, borderColor, size, offset], + [dotColorStyle, size, offset], ); return ; } @@ -455,7 +473,6 @@ function ProjectLeadingVisual({ showChevron?: boolean; isArchiving?: boolean; }) { - const { theme } = useUnistyles(); const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); const activeWorkspace = workspace; @@ -488,7 +505,6 @@ function ProjectLeadingVisual({ isArchiving={isArchiving} shouldShowSyncedLoader={shouldShowSyncedLoader} activeWorkspace={activeWorkspace} - theme={theme} /> ); } @@ -503,7 +519,6 @@ function ProjectRowTrailingActions({ onBeginWorkspaceSetup, onRemoveProject, removeProjectStatus, - theme, }: { project: SidebarProjectEntry; displayName: string; @@ -514,7 +529,6 @@ function ProjectRowTrailingActions({ onBeginWorkspaceSetup: () => void; onRemoveProject?: () => void; removeProjectStatus: "idle" | "pending" | "success"; - theme: ReturnType["theme"]; }) { const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; return ( @@ -535,7 +549,6 @@ function ProjectRowTrailingActions({ > @@ -545,34 +558,29 @@ function ProjectRowTrailingActions({ ); } +const trash2LeadingIcon = ; +const settingsLeadingIcon = ; +const copyLeadingIcon = ; +const archiveLeadingIcon = ; + +function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { + return ( + + ); +} + function ProjectKebabMenu({ projectKey, - theme, onRemoveProject, removeProjectStatus, }: { projectKey: string; - theme: ReturnType["theme"]; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { - const removeProjectLeadingIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const settingsLeadingIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const renderTriggerIcon = useCallback( - ({ hovered }: { hovered?: boolean }) => ( - - ), - [theme.colors.foreground, theme.colors.foregroundMuted], - ); const handleOpenProjectSettings = useCallback(() => { if (projectKey.trim().length === 0) return; router.navigate(buildProjectSettingsRoute(projectKey)); @@ -587,7 +595,7 @@ function ProjectKebabMenu({ accessibilityLabel="Project actions" testID={`sidebar-project-kebab-${projectKey}`} > - {renderTriggerIcon} + {renderKebabTriggerIcon} {canOpenProjectSettings ? ( @@ -601,7 +609,7 @@ function ProjectKebabMenu({ ) : null} ["theme"]; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; @@ -654,9 +660,9 @@ function WorkspaceRowRightGroup({ {showScriptsIcon ? ( {hasRunningService ? ( - + ) : ( - + )} ) : null} @@ -664,7 +670,6 @@ function WorkspaceRowRightGroup({ {showKebab && onArchive ? ( ; -} - function WorkspaceKebabMenu({ workspaceKey, - theme, onCopyPath, onCopyBranchName, onArchive, @@ -713,7 +705,6 @@ function WorkspaceKebabMenu({ archiveShortcutKeys, }: { workspaceKey: string; - theme: ReturnType["theme"]; onCopyPath?: () => void; onCopyBranchName?: () => void; onArchive: () => void; @@ -722,28 +713,10 @@ function WorkspaceKebabMenu({ archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { - const copyLeadingIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const archiveLeadingIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); const archiveTrailing = useMemo( () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); - const renderTriggerIcon = useCallback( - ({ hovered }: { hovered?: boolean }) => ( - - ), - [theme.colors.foreground, theme.colors.foregroundMuted], - ); return ( - {renderTriggerIcon} + {renderKebabTriggerIcon} {onCopyPath ? ( @@ -813,19 +786,17 @@ function ProjectLeadingVisualStatus({ isArchiving, shouldShowSyncedLoader, activeWorkspace, - theme, }: { iconDataUri: string | null; placeholderInitial: string; isArchiving: boolean; shouldShowSyncedLoader: boolean; activeWorkspace: SidebarWorkspaceEntry; - theme: ReturnType["theme"]; }) { if (isArchiving) { return ( - + ); } @@ -833,14 +804,7 @@ function ProjectLeadingVisualStatus({ if (shouldShowSyncedLoader) { return ( - + ); } @@ -848,16 +812,12 @@ function ProjectLeadingVisualStatus({ if (activeWorkspace.statusBucket === "needs_input") { return ( - + ); } - const dotColor = getStatusDotColor({ - theme, - bucket: activeWorkspace.statusBucket, - showDoneAsInactive: false, - }); + const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket); const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket) ? EMPHASIZED_STATUS_DOT_SIZE : DEFAULT_STATUS_DOT_SIZE; @@ -869,10 +829,9 @@ function ProjectLeadingVisualStatus({ return ( - {dotColor ? ( + {dotColorStyle ? ( @@ -906,7 +865,6 @@ function NewWorktreeButton({ testID: string; showShortcutHint?: boolean; }) { - const { theme } = useUnistyles(); const newWorktreeKeys = useShortcutKeys("new-worktree"); const pressableStyle = useCallback( @@ -940,12 +898,12 @@ function NewWorktreeButton({ > {({ hovered, pressed }) => loading ? ( - + ) : ( - ) @@ -1205,7 +1163,6 @@ function ProjectHeaderRow({ removeProjectStatus = "idle", dragHandleProps, }: ProjectHeaderRowProps) { - const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = useIsCompactFormFactor(); const handleBeginWorkspaceSetup = useCallback(() => { @@ -1275,7 +1232,6 @@ function ProjectHeaderRow({ onBeginWorkspaceSetup={handleBeginWorkspaceSetup} onRemoveProject={onRemoveProject} removeProjectStatus={removeProjectStatus} - theme={theme} /> {showShortcutBadge && shortcutNumber !== null ? ( @@ -1351,7 +1307,6 @@ function WorkspaceRowInner({ onCopyPath, archiveShortcutKeys, }: WorkspaceRowInnerProps) { - const { theme } = useUnistyles(); const _isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); const isTouchPlatform = platformIsNative; @@ -1447,7 +1402,6 @@ function WorkspaceRowInner({ isCreating={isCreating} showShortcutBadge={showShortcutBadge} shortcutNumber={shortcutNumber} - theme={theme} archiveLabel={archiveLabel} archiveStatus={archiveStatus} archivePendingLabel={archivePendingLabel} @@ -2912,4 +2866,35 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.medium, lineHeight: 14, }, + statusDotNeedsInput: { + backgroundColor: theme.colors.palette.amber[500], + borderColor: theme.colors.surface0, + }, + statusDotFailed: { + backgroundColor: theme.colors.palette.red[500], + borderColor: theme.colors.surface0, + }, + statusDotRunning: { + backgroundColor: theme.colors.palette.blue[500], + borderColor: theme.colors.surface0, + }, + statusDotAttention: { + backgroundColor: theme.colors.palette.green[500], + borderColor: theme.colors.surface0, + }, })); + +function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null { + switch (bucket) { + case "needs_input": + return styles.statusDotNeedsInput; + case "failed": + return styles.statusDotFailed; + case "running": + return styles.statusDotRunning; + case "attention": + return styles.statusDotAttention; + case "done": + return null; + } +} diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index e5e4feeb4..36a91ddec 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -92,6 +92,36 @@ function isScrollContainerOverscrolledPastBottom( return getScrollContainerDistanceFromBottom(scrollContainer) < 0; } +const VIEWPORT_LOG_PREFIX = "[WebStreamViewport]"; + +function vlog(agentId: string, event: string, details: Record = {}): void { + console.log(VIEWPORT_LOG_PREFIX, agentId, event, details); +} + +function useRenderTracker( + label: string, + agentId: string, + watched: Record, +): number { + const renderCountRef = useRef(0); + const prevRef = useRef | null>(null); + renderCountRef.current += 1; + const changed: string[] = []; + if (prevRef.current !== null) { + for (const key of Object.keys(watched)) { + if (!Object.is(prevRef.current[key], watched[key])) { + changed.push(key); + } + } + } + prevRef.current = { ...watched }; + console.log(VIEWPORT_LOG_PREFIX, agentId, `${label} render #${renderCountRef.current}`, { + isFirst: renderCountRef.current === 1, + changed, + }); + return renderCountRef.current; +} + function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments, @@ -105,6 +135,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollEnabled, isMobileBreakpoint, } = props; + useRenderTracker("WebStreamViewport", props.agentId, { + agentId: props.agentId, + segments, + "segments.historyMounted": segments.historyMounted, + "segments.historyVirtualized": segments.historyVirtualized, + "segments.liveHead": segments.liveHead, + boundary, + renderers, + "renderers.renderHistoryMountedRow": renderers.renderHistoryMountedRow, + "renderers.renderHistoryVirtualizedRow": renderers.renderHistoryVirtualizedRow, + "renderers.renderLiveHeadRow": renderers.renderLiveHeadRow, + "renderers.renderLiveAuxiliary": renderers.renderLiveAuxiliary, + listEmptyComponent, + viewportRef, + routeBottomAnchorRequest, + isAuthoritativeHistoryReady, + onNearBottomChange, + scrollEnabled, + isMobileBreakpoint, + }); const scrollContainerRef = useRef(null); const contentRef = useRef(null); const handleScrollContainerRef = useCallback((node: HTMLElement | null) => { @@ -164,6 +214,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool overscan: 8, }); useEffect(() => { + vlog(props.agentId, "effect:virtualizer-adjust setup fired", {}); rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; @@ -370,6 +421,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]); useLayoutEffect(() => { + vlog(props.agentId, "effect:activation-reset fired", { activationKey, isActivationReady }); if (!isActivationReady) { return; } @@ -391,9 +443,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.clearTimeout(timeout); }; - }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]); + }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom, props.agentId]); useEffect(() => { + vlog(props.agentId, "effect:stick-on-segments-change fired", { + followOutput: followOutputRef.current, + }); if (!followOutputRef.current) { return; } @@ -403,16 +458,28 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool segments.historyMounted, segments.historyVirtualized, segments.liveHead, + props.agentId, ]); useEffect(() => { + vlog(props.agentId, "effect:stick-on-virtual-total fired", { + followOutput: followOutputRef.current, + shouldUseVirtualizer, + virtualTotalSize, + }); if (!followOutputRef.current || !shouldUseVirtualizer) { return; } scheduleStickToBottom(); - }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]); + }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize, props.agentId]); useEffect(() => { + vlog(props.agentId, "effect:metrics-on-segments-change fired", { + mounted: segments.historyMounted.length, + virtualized: segments.historyVirtualized.length, + liveHead: segments.liveHead.length, + virtualTotalSize, + }); updateScrollMetrics(); }, [ segments.historyMounted.length, @@ -420,9 +487,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool segments.liveHead.length, updateScrollMetrics, virtualTotalSize, + props.agentId, ]); useEffect(() => { + vlog(props.agentId, "effect:resize-observer setup fired", {}); const scrollContainer = scrollContainerRef.current; const contentNode = contentRef.current; if (!scrollContainer || typeof ResizeObserver === "undefined") { @@ -456,6 +525,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [props.agentId, scheduleStickToBottom, updateScrollMetrics]); useEffect(() => { + vlog(props.agentId, "effect:scroll-listeners setup fired", {}); const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; @@ -604,6 +674,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [cancelPendingStickToBottom, handleDomScroll, props.agentId]); useEffect(() => { + vlog(props.agentId, "effect:viewportRef-handle setup fired", {}); const handle: StreamViewportHandle = { scrollToBottom: () => { setFollowOutput(true); @@ -642,8 +713,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool viewportRef, ]); - const contentContainerStyle = useMemo( - (): CSSProperties => ({ + const contentContainerStyle = useMemo((): CSSProperties => { + vlog(props.agentId, "memo:contentContainerStyle rebuilt", { isMobileBreakpoint }); + return { display: "flex", flexDirection: "column", minHeight: "100%", @@ -652,27 +724,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool paddingLeft: isMobileBreakpoint ? 8 : 16, paddingRight: isMobileBreakpoint ? 8 : 16, boxSizing: "border-box", - }), - [isMobileBreakpoint], - ); - const scrollContainerStyle = useMemo( - (): CSSProperties => ({ + }; + }, [isMobileBreakpoint, props.agentId]); + const scrollContainerStyle = useMemo((): CSSProperties => { + vlog(props.agentId, "memo:scrollContainerStyle rebuilt", { scrollEnabled }); + return { flex: 1, minHeight: 0, overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", overscrollBehaviorY: "contain", - }), - [scrollEnabled], - ); - const virtualRowsContainerStyle = useMemo( - (): CSSProperties => ({ + }; + }, [scrollEnabled, props.agentId]); + const virtualRowsContainerStyle = useMemo((): CSSProperties => { + vlog(props.agentId, "memo:virtualRowsContainerStyle rebuilt", { virtualTotalSize }); + return { position: "relative", width: "100%", height: virtualTotalSize, - }), - [virtualTotalSize], - ); + }; + }, [virtualTotalSize, props.agentId]); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ position: "absolute", @@ -685,29 +756,41 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [], ); - const mountedHistoryRows = useMemo( - () => - segments.historyMounted.map((item, index) => ( - - {renderHistoryMountedRow(item, index, segments.historyMounted)} - - )), - [renderHistoryMountedRow, segments.historyMounted], - ); - const liveHeadRows = useMemo( - () => - segments.liveHead.map((item, index) => ( - {renderLiveHeadRow(item, index, segments.liveHead)} - )), - [renderLiveHeadRow, segments.liveHead], - ); - const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]); + const mountedHistoryRows = useMemo(() => { + vlog(props.agentId, "memo:mountedHistoryRows rebuilt", { + count: segments.historyMounted.length, + }); + return segments.historyMounted.map((item, index) => ( + + {renderHistoryMountedRow(item, index, segments.historyMounted)} + + )); + }, [renderHistoryMountedRow, segments.historyMounted, props.agentId]); + const liveHeadRows = useMemo(() => { + vlog(props.agentId, "memo:liveHeadRows rebuilt", { count: segments.liveHead.length }); + return segments.liveHead.map((item, index) => ( + {renderLiveHeadRow(item, index, segments.liveHead)} + )); + }, [renderLiveHeadRow, segments.liveHead, props.agentId]); + const liveAuxiliary = useMemo(() => { + vlog(props.agentId, "memo:liveAuxiliary rebuilt", {}); + return renderLiveAuxiliary(); + }, [renderLiveAuxiliary, props.agentId]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && !boundary.hasLiveHead && !liveAuxiliary; + vlog(props.agentId, "render summary", { + virtualRowsRendered: virtualRows.length, + mountedHistoryRowsRendered: segments.historyMounted.length, + liveHeadRowsRendered: segments.liveHead.length, + shouldUseVirtualizer, + virtualTotalSize, + shouldRenderEmpty, + }); + return ( <>
; contentRef: React.RefObject; }): ReactElement | null { - const { theme } = useUnistyles(); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -297,7 +305,7 @@ function WorkspaceHoverCardContent({ {prHint?.checks && prHint.checks.length > 0 ? ( <> - + ) : null} @@ -306,49 +314,49 @@ function WorkspaceHoverCardContent({ ); } +const ThemedExternalLink = withUnistyles(ExternalLink); +const ThemedGitHubIcon = withUnistyles(GitHubIcon); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + function ChecksSummaryContent({ checks, - theme, hovered, }: { checks: NonNullable; - theme: ReturnType["theme"]; hovered: boolean; }) { const failed = checks.filter((c) => c.status === "failure").length; const pending = checks.filter((c) => c.status === "pending").length; - let badgeColor: string; let badgeLabel: string; + let dotStyle: StyleProp; + let statusTextStyle: StyleProp; if (failed > 0) { - badgeColor = theme.colors.palette.red[500]; badgeLabel = `${failed} failed`; + dotStyle = styles.checksDotFailed; + statusTextStyle = styles.checksStatusTextFailed; } else if (pending > 0) { - badgeColor = theme.colors.palette.amber[500]; badgeLabel = `${pending} running`; + dotStyle = styles.checksDotPending; + statusTextStyle = styles.checksStatusTextPending; } else { - badgeColor = theme.colors.palette.green[500]; badgeLabel = `${checks.length} passed`; + dotStyle = styles.checksDotPassed; + statusTextStyle = styles.checksStatusTextPassed; } - const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; - const labelStyle = useMemo( - () => [styles.checksSummaryLabel, hovered && styles.checksSummaryLabelHovered], - [hovered], - ); - const dotStyle = useMemo(() => [styles.checksDot, { backgroundColor: badgeColor }], [badgeColor]); - const statusTextStyle = useMemo( - () => [styles.checksStatusText, { color: badgeColor }], - [badgeColor], - ); + const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel; + const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping; return ( <> {hovered ? ( - + ) : ( - + )} Checks @@ -362,38 +370,32 @@ function ChecksSummaryContent({ function ChecksSummaryPressable({ checks, url, - theme, }: { checks: NonNullable; url: string; - theme: ReturnType["theme"]; }) { const handlePress = useCallback(() => { void openExternalUrl(`${url}/checks`); }, [url]); - const pressableStyle = useCallback( - ({ hovered }: { pressed: boolean; hovered?: boolean }) => [ - styles.checksSummaryRow, - Boolean(hovered) && styles.listRowHovered, - ], - [], - ); - const renderChildren = useCallback( ({ hovered }: { pressed: boolean; hovered?: boolean }) => ( - + ), - [checks, theme], + [checks], ); return ( - + {renderChildren} ); } +function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) { + return [styles.checksSummaryRow, hovered && styles.listRowHovered]; +} + const styles = StyleSheet.create((theme) => ({ portalOverlay: { position: "absolute", @@ -467,13 +469,42 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, justifyContent: "flex-end", }, - checksDot: { + checksDotFailed: { width: 6, height: 6, borderRadius: 3, + backgroundColor: theme.colors.palette.red[500], }, - checksStatusText: { + checksDotPending: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.palette.amber[500], + }, + checksDotPassed: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.palette.green[500], + }, + checksStatusTextFailed: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.red[500], + }, + checksStatusTextPending: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.amber[500], + }, + checksStatusTextPassed: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.green[500], }, })); + +const checksSummaryLabelHoveredCombined = [ + styles.checksSummaryLabel, + styles.checksSummaryLabelHovered, +]; diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 167311dcc..8a90ddb54 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Image, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Composer } from "@/components/composer"; @@ -140,7 +140,6 @@ function buildCreateAgentOptions({ } export function WorkspaceSetupDialog() { - const { theme } = useUnistyles(); const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); @@ -349,10 +348,6 @@ export function WorkspaceSetupDialog() { addImagesRef.current = addImages; }, []); - const composerInputWrapperStyle = useMemo( - () => ({ backgroundColor: theme.colors.surface2 }), - [theme.colors.surface2], - ); const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const statusControlsWithDisabled = useMemo( () => @@ -415,7 +410,7 @@ export function WorkspaceSetupDialog() { autoFocus commandDraftConfig={composerState?.commandDraftConfig} statusControls={statusControlsWithDisabled} - inputWrapperStyle={composerInputWrapperStyle} + inputWrapperStyle={styles.composerInputWrapper} onAddImages={handleAddImagesCallback} /> @@ -463,4 +458,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, lineHeight: 20, }, + composerInputWrapper: { + backgroundColor: theme.colors.surface2, + }, })); diff --git a/packages/app/src/panels/agent-panel.test.tsx b/packages/app/src/panels/agent-panel.test.tsx index 9a124b046..e2f8b99cb 100644 --- a/packages/app/src/panels/agent-panel.test.tsx +++ b/packages/app/src/panels/agent-panel.test.tsx @@ -108,7 +108,7 @@ vi.mock("react-native-unistyles", () => { StyleSheet: { create: createPanelTestStyles, }, - useUnistyles: () => ({ theme }), + withUnistyles: (component: T) => component, }; }); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1d6bce30d..b9356eda5 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -44,6 +44,7 @@ import { import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { type Agent, useSessionStore } from "@/stores/session-store"; +import type { Theme } from "@/styles/theme"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; @@ -125,9 +126,8 @@ function buildChatAgentFromState( function renderChatAgentNonReadyView(args: { viewState: AgentScreenViewState; effectiveAgent: AgentScreenAgent | null; - theme: ReturnType["theme"]; }): React.ReactElement | null { - const { viewState, effectiveAgent, theme } = args; + const { viewState, effectiveAgent } = args; if (viewState.tag === "not_found") { return ( @@ -151,7 +151,7 @@ function renderChatAgentNonReadyView(args: { return ( - + ); @@ -419,7 +419,6 @@ function AgentPanelBody({ connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { - const { theme } = useUnistyles(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const projectPlacement = useStoreWithEqualityFn( @@ -549,7 +548,7 @@ function AgentPanelBody({ return ( - + ); @@ -585,7 +584,6 @@ function ChatAgentContent({ connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { - const { theme } = useUnistyles(); const panelToast = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); @@ -945,7 +943,6 @@ function ChatAgentContent({ const nonReadyView = renderChatAgentNonReadyView({ viewState, effectiveAgent, - theme, }); if (nonReadyView) return nonReadyView; invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); @@ -990,7 +987,7 @@ function ChatAgentContent({ viewState.sync.status === "catching_up" && viewState.sync.ui === "overlay" ? ( - + ) : null} @@ -1004,7 +1001,7 @@ function ChatAgentContent({ {isArchivingCurrentAgent ? ( - + Archiving agent... Please wait while we archive this agent. @@ -1340,6 +1337,15 @@ function AgentSessionUnavailableState({ ); } +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); + +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const foregroundColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); + const styles = StyleSheet.create((theme) => ({ root: { flex: 1, diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index e5ca777e6..626b669d4 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -9,11 +9,12 @@ import { View, } from "react-native"; import invariant from "tiny-invariant"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import type { Theme } from "@/styles/theme"; import { useWorkspaceSetupStore, type WorkspaceSetupSnapshot, @@ -62,15 +63,13 @@ function useSetupPanelDescriptor( type CommandStatus = "running" | "completed" | "failed"; function CommandStatusIcon({ status }: { status: CommandStatus }) { - const { theme } = useUnistyles(); - if (status === "running") { - return ; + return ; } if (status === "completed") { - return ; + return ; } - return ; + return ; } function formatDuration(ms: number): string { @@ -151,7 +150,6 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) { } function SetupPanel() { - const { theme } = useUnistyles(); const { serverId, target } = usePaneContext(); invariant(target.kind === "setup", "SetupPanel requires setup target"); @@ -231,7 +229,7 @@ function SetupPanel() { {isWaiting ? ( - + Setting up workspace... ) : null} @@ -269,7 +267,6 @@ function SetupPanel() { hasError={rowState.hasError} processedLog={rowState.processedLog} errorMessage={snapshot?.error ?? null} - foregroundMutedColor={theme.colors.foregroundMuted} onToggle={toggleExpanded} /> ); @@ -292,7 +289,6 @@ interface SetupCommandRowProps { hasError: boolean; processedLog: string; errorMessage: string | null; - foregroundMutedColor: string; onToggle: (index: number, isAutoExpanded: boolean) => void; } @@ -305,7 +301,6 @@ function SetupCommandRow({ hasError, processedLog, errorMessage, - foregroundMutedColor, onToggle, }: SetupCommandRowProps) { const handlePress = useCallback(() => { @@ -341,7 +336,7 @@ function SetupCommandRow({ {command.durationMs != null ? ( {formatDuration(command.durationMs)} ) : null} - + {showDetail ? ( @@ -388,12 +383,14 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = { useDescriptor: useSetupPanelDescriptor, }; -function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) { +function SetupCommandChevron({ showDetail }: { showDetail: boolean }) { const chevronStyle = useMemo( () => [styles.chevron, showDetail && styles.chevronExpanded], [showDetail], ); - return ; + return ( + + ); } function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { @@ -432,6 +429,24 @@ function TopLevelSetupError({ ); } +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedCheckCircle2 = withUnistyles(CheckCircle2); +const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedChevronRight = withUnistyles(ChevronRight); + +const foregroundColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const greenColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.green[500], +}); +const redColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.red[500], +}); + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 8a9eb8eb1..67b13c2b4 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -28,7 +28,7 @@ import { SquareTerminal, X, } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; import type { DraggableListDragHandleProps, @@ -58,39 +58,53 @@ import { type WorkspaceTabMenuEntry, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { Theme } from "@/styles/theme"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedX = withUnistyles(X); +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); +const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); +const ThemedCopyX = withUnistyles(CopyX); +const ThemedSquarePen = withUnistyles(SquarePen); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedColumns2 = withUnistyles(Columns2); +const ThemedRows2 = withUnistyles(Rows2); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; } function TabContextMenuItem({ entry, - iconColor, }: { entry: Extract; - iconColor: string; }) { const leading = useMemo(() => { switch (entry.icon) { case "copy": - return ; + return ; case "rotate-cw": - return ; + return ; case "arrow-left-to-line": - return ; + return ; case "arrow-right-to-line": - return ; + return ; case "copy-x": - return ; + return ; case "x": - return ; + return ; default: return undefined; } - }, [entry.icon, iconColor]); + }, [entry.icon]); const trailing = useMemo( () => (entry.hint ? {entry.hint} : undefined), [entry.hint], @@ -255,7 +269,6 @@ function TabChip({ onCloseTab: (tabId: string) => Promise | void; dragHandleProps: DraggableListDragHandleProps | undefined; }) { - const { theme } = useUnistyles(); const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const middleClickRef = useMiddleClickClose( useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), @@ -392,21 +405,17 @@ function TabChip({ > {({ hovered: closeHovered, pressed }) => isClosingTab ? ( - ) : ( - ) @@ -432,11 +441,7 @@ function TabChip({ entry.kind === "separator" ? ( ) : ( - + ), )} @@ -473,7 +478,6 @@ export function WorkspaceDesktopTabsRow({ tabDropPreviewIndex = null, showPaneSplitActions = true, }: WorkspaceDesktopTabsRowProps) { - const { theme } = useUnistyles(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTerminalKeys = useShortcutKeys("workspace-terminal-new"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); @@ -499,11 +503,11 @@ export function WorkspaceDesktopTabsRow({ tabGap: 0, maxTabWidth: 200, tabIconWidth: 14, - tabHorizontalPadding: theme.spacing[3], + tabHorizontalPadding: 12, estimatedCharWidth: 7, closeButtonWidth: 22, }), - [tabsActionsWidth, theme.spacing], + [tabsActionsWidth], ); const tabLabelLengths = useMemo( @@ -668,7 +672,7 @@ export function WorkspaceDesktopTabsRow({ accessibilityLabel="New agent tab" style={newTabActionButtonStyle} > - + @@ -690,7 +694,7 @@ export function WorkspaceDesktopTabsRow({ } style={newTerminalActionButtonStyle} > - + @@ -712,7 +716,7 @@ export function WorkspaceDesktopTabsRow({ accessibilityLabel="Split pane right" style={newTabActionButtonStyle} > - + @@ -730,7 +734,7 @@ export function WorkspaceDesktopTabsRow({ accessibilityLabel="Split pane down" style={newTabActionButtonStyle} > - + diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/screens/workspace/workspace-git-actions.tsx index 1b5f65b10..dc7e8179b 100644 --- a/packages/app/src/screens/workspace/workspace-git-actions.tsx +++ b/packages/app/src/screens/workspace/workspace-git-actions.tsx @@ -1,5 +1,4 @@ -import { useMemo } from "react"; -import { useUnistyles } from "react-native-unistyles"; +import { withUnistyles } from "react-native-unistyles"; import { Archive, Download, @@ -11,6 +10,7 @@ import { import { GitHubIcon } from "@/components/icons/github-icon"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { useGitActions } from "@/hooks/use-git-actions"; +import type { Theme } from "@/styles/theme"; interface WorkspaceGitActionsProps { serverId: string; @@ -18,24 +18,31 @@ interface WorkspaceGitActionsProps { hideLabels?: boolean; } +const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); +const ThemedDownload = withUnistyles(Download); +const ThemedUpload = withUnistyles(Upload); +const ThemedGitHubIcon = withUnistyles(GitHubIcon); +const ThemedGitMerge = withUnistyles(GitMerge); +const ThemedRefreshCcw = withUnistyles(RefreshCcw); +const ThemedArchive = withUnistyles(Archive); + +const mutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const ICONS = { + commit: , + pull: , + push: , + viewPr: , + createPr: , + merge: , + mergeFromBase: , + archive: , +}; + export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) { - const { theme } = useUnistyles(); - - const icons = useMemo( - () => ({ - commit: , - pull: , - push: , - viewPr: , - createPr: , - merge: , - mergeFromBase: , - archive: , - }), - [theme.colors.foregroundMuted], - ); - - const { gitActions, isGit } = useGitActions({ serverId, cwd, icons }); + const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS }); if (!isGit) { return null; diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index ae8f2b9df..09da0eb20 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -8,7 +8,7 @@ import { } from "react-native"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Check, ChevronDown } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages"; import { EditorAppIcon } from "@/components/icons/editor-app-icons"; import { @@ -22,6 +22,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { isAbsolutePath } from "@/utils/path"; import { isWeb } from "@/constants/platform"; +import type { Theme } from "@/styles/theme"; interface WorkspaceOpenInEditorButtonProps { serverId: string; @@ -33,18 +34,25 @@ interface EditorMenuItemProps { editor: EditorTargetDescriptorPayload; isPreferred: boolean; onOpen: (editorId: EditorTargetId) => void; - foregroundMuted: string; } -function EditorMenuItem({ editor, isPreferred, onOpen, foregroundMuted }: EditorMenuItemProps) { +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedEditorAppIcon = withUnistyles(EditorAppIcon); +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedCheckIcon = withUnistyles(Check); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function EditorMenuItem({ editor, isPreferred, onOpen }: EditorMenuItemProps) { const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]); const leading = useMemo( - () => , - [editor.id, foregroundMuted], + () => , + [editor.id], ); const trailing = useMemo( - () => (isPreferred ? : undefined), - [isPreferred, foregroundMuted], + () => (isPreferred ? : undefined), + [isPreferred], ); return ( {openMutation.isPending ? ( - ) : ( - {!hideLabels && Open} @@ -198,7 +205,7 @@ export function WorkspaceOpenInEditorButton({ accessibilityRole="button" accessibilityLabel="Choose editor" > - + ))} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 87862072f..7fb713692 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -22,7 +22,8 @@ import { } from "lucide-react-native"; import { GestureDetector } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; import invariant from "tiny-invariant"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; @@ -135,6 +136,32 @@ const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedEllipsis = withUnistyles(Ellipsis); +const ThemedEllipsisVertical = withUnistyles(EllipsisVertical); +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); +const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); +const ThemedCopyX = withUnistyles(CopyX); +const ThemedX = withUnistyles(X); +const ThemedSquarePen = withUnistyles(SquarePen); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedSettings = withUnistyles(Settings); +const ThemedPanelRight = withUnistyles(PanelRight); +const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 }; + +const MENU_NEW_AGENT_ICON = ; +const MENU_NEW_TERMINAL_ICON = ; +const MENU_COPY_ICON = ; +const MENU_SETTINGS_ICON = ; + interface WorkspaceScreenProps { serverId: string; workspaceId: string; @@ -298,14 +325,10 @@ function MobileTabTrailingAccessory({ menuTestIDBase, presentationLabel, menuEntries, - iconColor, - iconSizeSm, }: { menuTestIDBase: string; presentationLabel: string; menuEntries: WorkspaceTabMenuEntry[]; - iconColor: string; - iconSizeSm: number; }) { return ( @@ -316,14 +339,14 @@ function MobileTabTrailingAccessory({ hitSlop={8} style={mobileTabMenuTriggerStyle} > - + {menuEntries.map((entry) => entry.kind === "separator" ? ( ) : ( - + ), )} @@ -333,29 +356,27 @@ function MobileTabTrailingAccessory({ function MobileTabDropdownMenuItem({ entry, - iconColor, }: { entry: Extract; - iconColor: string; }) { const leading = useMemo(() => { switch (entry.icon) { case "copy": - return ; + return ; case "rotate-cw": - return ; + return ; case "arrow-left-to-line": - return ; + return ; case "arrow-right-to-line": - return ; + return ; case "copy-x": - return ; + return ; case "x": - return ; + return ; default: return undefined; } - }, [entry.icon, iconColor]); + }, [entry.icon]); const trailing = useMemo( () => (entry.hint ? {entry.hint} : undefined), [entry.hint], @@ -408,7 +429,6 @@ function MobileWorkspaceTabOption({ onCloseTabsBelow: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; }) { - const { theme } = useUnistyles(); const menuTestIDBase = `workspace-tab-menu-${tab.key}`; const menuEntries = buildWorkspaceTabMenuEntries({ surface: "mobile", @@ -425,9 +445,6 @@ function MobileWorkspaceTabOption({ onCloseOtherTabs, }); - const iconColor = theme.colors.foregroundMuted; - const iconSizeSm = theme.iconSize.sm; - const fallbackLabel = getFallbackTabOptionLabel(tab); const trailingAccessory = useMemo( () => ( @@ -435,11 +452,9 @@ function MobileWorkspaceTabOption({ menuTestIDBase={menuTestIDBase} presentationLabel={fallbackLabel} menuEntries={menuEntries} - iconColor={iconColor} - iconSizeSm={iconSizeSm} /> ), - [menuTestIDBase, fallbackLabel, menuEntries, iconColor, iconSizeSm], + [menuTestIDBase, fallbackLabel, menuEntries], ); const renderPresentation = useCallback( @@ -483,7 +498,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCloseTabsBelow, onCloseOtherTabs, }: MobileWorkspaceTabSwitcherProps) { - const { theme } = useUnistyles(); const [isOpen, setIsOpen] = useState(false); const anchorRef = useRef(null); const tabIndexByKey = useMemo(() => { @@ -572,7 +586,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ normalizedWorkspaceId={normalizedWorkspaceId} /> - + - ); + const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis; + const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping; + return ; } function WorkspaceHeaderMenu({ @@ -939,7 +948,6 @@ interface RenderWorkspaceContentInput { paneId: string | null; tab: WorkspaceTabDescriptor; }) => WorkspacePaneContentModel; - foregroundMutedColor: string; } function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode { @@ -953,13 +961,12 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React isRouteFocused, focusedPaneId, buildMobilePaneContentModel, - foregroundMutedColor, } = input; if (showMissingWorkspaceDescriptor) { return ( - + ); } @@ -975,7 +982,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React if (!activeTabDescriptor && !hasHydratedAgents) { return ( - + ); } @@ -1115,9 +1122,7 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, }: WorkspaceScreenContentProps) { - const { theme } = useUnistyles(); const _insets = useSafeAreaInsets(); - const mainBackgroundColor = theme.colors.surfaceWorkspace; const toast = useToast(); const isMobile = useIsCompactFormFactor(); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); @@ -2562,7 +2567,6 @@ function WorkspaceScreenContent({ isRouteFocused, focusedPaneId, buildMobilePaneContentModel, - foregroundMutedColor: theme.colors.foregroundMuted, }); const buildDesktopPaneContentModel = useCallback( @@ -2659,27 +2663,12 @@ function WorkspaceScreenContent({ ); }, []); - const containerStyle = useMemo( - () => [styles.container, { backgroundColor: mainBackgroundColor }], - [mainBackgroundColor], - ); + const containerStyle = containerWithWorkspaceBackgroundStyle; - const menuNewAgentIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const menuNewTerminalIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const menuCopyIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const menuSettingsIcon = useMemo( - () => , - [theme.colors.foregroundMuted], - ); + const menuNewAgentIcon = MENU_NEW_AGENT_ICON; + const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON; + const menuCopyIcon = MENU_COPY_ICON; + const menuSettingsIcon = MENU_SETTINGS_ICON; const headerRight = useMemo( () => ( @@ -2721,12 +2710,10 @@ function WorkspaceScreenContent({ > {({ hovered, pressed }) => { const active = isExplorerOpen || hovered || pressed; - const iconColor = active - ? theme.colors.foreground - : theme.colors.foregroundMuted; + const colorMapping = active ? foregroundColorMapping : mutedColorMapping; return ( <> - + {workspaceDescriptor?.diffStat ? ( {({ hovered }) => { - const color = - isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted; - return ; + const colorMapping = + isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping; + return ; }} ) : null} @@ -2786,12 +2773,16 @@ function WorkspaceScreenContent({ accessibilityState={explorerToggleAccessibilityState} > {({ hovered }) => { - const color = - isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted; + const colorMapping = + isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping; return isGitCheckout ? ( - + ) : ( - + ); }} @@ -2812,10 +2803,6 @@ function WorkspaceScreenContent({ isExplorerOpen, explorerToggleAccessibilityState, explorerToggleStyle, - theme.colors.foreground, - theme.colors.foregroundMuted, - theme.iconSize.md, - theme.iconSize.lg, ], ); @@ -3039,6 +3026,9 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.surface0, }, + containerWorkspaceBackground: { + backgroundColor: theme.colors.surfaceWorkspace, + }, threePaneRow: { flex: 1, minHeight: 0, @@ -3333,4 +3323,9 @@ const styles = StyleSheet.create((theme) => ({ }, })); +const containerWithWorkspaceBackgroundStyle = [ + styles.container, + styles.containerWorkspaceBackground, +]; + const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx index 751d410b3..4eb8f10b7 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx @@ -43,7 +43,17 @@ vi.mock("react-native-unistyles", () => ({ StyleSheet: { create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), }, - useUnistyles: () => ({ theme }), + withUnistyles: + (Component: React.ComponentType>) => + ({ + uniProps, + ...rest + }: { + uniProps?: (theme: unknown) => Record; + } & Record) => { + const themed = uniProps ? uniProps(theme) : {}; + return React.createElement(Component, { ...rest, ...themed }); + }, })); vi.mock("@/constants/platform", () => ({ diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index a72689bae..77aff3b8d 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -3,7 +3,7 @@ import type { GestureResponderEvent } from "react-native"; import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; @@ -17,6 +17,7 @@ import { useToast } from "@/contexts/toast-context"; import { isNative } from "@/constants/platform"; import { openExternalUrl } from "@/utils/open-external-url"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; +import type { Theme } from "@/styles/theme"; type ScriptActionIcon = "start" | "view"; @@ -39,28 +40,48 @@ interface ScriptActionButtonProps { testID: string; } +const ThemedPlay = withUnistyles(Play); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedGlobe = withUnistyles(Globe); +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedExternalLink = withUnistyles(ExternalLink); + +const foregroundColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); +const mutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const blueColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.blue[500], +}); +const greenColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.green[500], +}); +const redColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.red[500], +}); +const playFillTransparent = { fill: "transparent" }; + interface ScriptActionButtonChildrenProps { hovered?: boolean; icon: ScriptActionIcon; label: string; - theme: ReturnType["theme"]; } function ScriptActionButtonChildren({ hovered, icon, label, - theme, }: ScriptActionButtonChildrenProps): ReactElement { - const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; - const iconProps = { size: 10, color }; + const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping; const iconElement = icon === "view" ? ( - + ) : ( - + ); - const labelStyle = useMemo(() => [styles.actionButtonLabel, { color }], [color]); + const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel; return ( <> {iconElement} @@ -77,8 +98,6 @@ function ScriptActionButton({ onPress, testID, }: ScriptActionButtonProps): ReactElement { - const { theme } = useUnistyles(); - const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); @@ -89,9 +108,9 @@ function ScriptActionButton({ const renderChildren = useCallback( ({ hovered }: { hovered?: boolean }) => ( - + ), - [icon, label, theme], + [icon, label], ); return ( @@ -123,32 +142,26 @@ interface HostLinkChildrenProps { hovered?: boolean; disabled: boolean; label: string; - theme: ReturnType["theme"]; } -function HostLinkChildren({ - hovered, - disabled, - label, - theme, -}: HostLinkChildrenProps): ReactElement { +function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement { const showIcon = !disabled && (hovered || isNative); - const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted; - const hostLabelStyle = useMemo(() => [styles.hostLabel, { color }], [color]); + const isActive = Boolean(hovered) && !disabled; + const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping; + const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel; return ( <> {label} - {showIcon ? : null} + {showIcon ? : null} ); } function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { - const { theme } = useUnistyles(); const disabled = !url; const handlePress = useCallback( @@ -161,9 +174,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { const renderChildren = useCallback( ({ hovered }: { hovered?: boolean }) => ( - + ), - [disabled, label, theme], + [disabled, label], ); return ( @@ -181,9 +194,7 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { } function ExitCodeBadge({ code }: { code: number }): ReactElement { - const { theme } = useUnistyles(); - const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300]; - const exitTextStyle = useMemo(() => [styles.exitBadgeText, { color }], [color]); + const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle; return ( exit {code} @@ -206,26 +217,24 @@ interface ScriptRowProps { : null : null; isStartPending: boolean; - theme: ReturnType["theme"]; onStartScript: (scriptName: string) => void; onViewTerminal?: (terminalId: string) => void; } -function resolveScriptIconColor(args: { - theme: ReturnType["theme"]; +function resolveScriptIconColorMapping(args: { script: WorkspaceDescriptor["scripts"][number]; isService: boolean; isRunning: boolean; -}): string { - const { theme, script, isService, isRunning } = args; +}): (theme: Theme) => { color: string } { + const { script, isService, isRunning } = args; if (isService) { - if (isRunning && script.health === "healthy") return theme.colors.palette.green[500]; - if (isRunning && script.health === "unhealthy") return theme.colors.palette.red[500]; - if (isRunning) return theme.colors.palette.blue[500]; - return theme.colors.foregroundMuted; + if (isRunning && script.health === "healthy") return greenColorMapping; + if (isRunning && script.health === "unhealthy") return redColorMapping; + if (isRunning) return blueColorMapping; + return mutedColorMapping; } - if (isRunning) return theme.colors.palette.blue[500]; - return theme.colors.foregroundMuted; + if (isRunning) return blueColorMapping; + return mutedColorMapping; } function ScriptRow({ @@ -233,7 +242,6 @@ function ScriptRow({ liveTerminalIdSet, activeConnection, isStartPending, - theme, onStartScript, onViewTerminal, }: ScriptRowProps): ReactElement { @@ -268,9 +276,8 @@ function ScriptRow({ } } - const iconColor = resolveScriptIconColor({ theme, script, isService, isRunning }); - - const ScriptIcon = isService ? Globe : SquareTerminal; + const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning }); + const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal; const showExitBadge = !isRunning && exitCode !== null; const handleView = useCallback(() => { @@ -282,13 +289,8 @@ function ScriptRow({ }, [onStartScript, script.scriptName]); const scriptNameStyle = useMemo( - () => [ - styles.scriptName, - { - color: isRunning ? theme.colors.foreground : theme.colors.foregroundMuted, - }, - ], - [isRunning, theme.colors.foreground, theme.colors.foregroundMuted], + () => (isRunning ? scriptNameActiveStyle : styles.scriptName), + [isRunning], ); let primaryAction: ReactElement | null = null; @@ -322,7 +324,7 @@ function ScriptRow({ style={styles.scriptItem} > - + {script.scriptName} @@ -355,7 +357,6 @@ export function WorkspaceScriptsButton({ onViewTerminal, hideLabels, }: WorkspaceScriptsButtonProps): ReactElement | null { - const { theme } = useUnistyles(); const toast = useToast(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; @@ -402,6 +403,7 @@ export function WorkspaceScriptsButton({ } const hasAnyRunning = scripts.some((s) => s.lifecycle === "running"); + const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping; return ( @@ -414,15 +416,9 @@ export function WorkspaceScriptsButton({ accessibilityLabel="Workspace scripts" > - + {!hideLabels && Scripts} - + @@ -511,6 +506,10 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 18, flexShrink: 1, minWidth: 0, + color: theme.colors.foregroundMuted, + }, + scriptNameActive: { + color: theme.colors.foreground, }, spacer: { flex: 1, @@ -531,6 +530,10 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, fontSize: theme.fontSize.xs, lineHeight: 14, + color: theme.colors.foregroundMuted, + }, + hostLabelActive: { + color: theme.colors.foreground, }, hostIconSlot: { width: 10, @@ -549,6 +552,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: 10, lineHeight: 12, fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + exitBadgeTextError: { + color: theme.colors.palette.red[300], }, actionButton: { flexDirection: "row", @@ -558,5 +565,14 @@ const styles = StyleSheet.create((theme) => ({ actionButtonLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + actionButtonLabelHovered: { + color: theme.colors.foreground, }, })); + +const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered]; +const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive]; +const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive]; +const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError]; diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 6f1d7db65..ac03e040c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,15 +1,16 @@ import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Check } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { SyncedLoader } from "@/components/synced-loader"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import { getPanelRegistration } from "@/panels/panel-registry"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; +import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; +import type { Theme } from "@/styles/theme"; export interface WorkspaceTabPresentation { key: string; @@ -103,22 +104,22 @@ interface WorkspaceTabIconProps { statusDotBorderColor?: string; } +const ThemedCheckIcon = withUnistyles(Check); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + export function WorkspaceTabIcon({ presentation, active = false, size = 14, statusDotBorderColor, }: WorkspaceTabIconProps): ReactElement { - const { theme } = useUnistyles(); - const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted; - const statusDotColor = - presentation.statusBucket === null - ? null - : getStatusDotColor({ - theme, - bucket: presentation.statusBucket, - showDoneAsInactive: false, - }); + const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; + const bucket = presentation.statusBucket; + let statusDotColor: string | null = null; + if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color; + else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; + else if (bucket === "running") statusDotColor = styles.statusDotRunning.color; + else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket) ? EMPHASIZED_STATUS_DOT_SIZE : DEFAULT_STATUS_DOT_SIZE; @@ -139,27 +140,20 @@ export function WorkspaceTabIcon({ styles.statusDot, { backgroundColor: statusDotColor ?? undefined, - borderColor: statusDotBorderColor ?? theme.colors.surface0, + borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor, width: statusDotSize, height: statusDotSize, right: statusDotOffset, bottom: statusDotOffset, }, ], - [statusDotColor, statusDotBorderColor, theme.colors.surface0, statusDotSize, statusDotOffset], + [statusDotColor, statusDotBorderColor, statusDotSize, statusDotOffset], ); if (shouldShowLoader) { return ( - + ); } @@ -187,7 +181,6 @@ export function WorkspaceTabOptionRow({ onPress, trailingAccessory, }: WorkspaceTabOptionRowProps): ReactElement { - const { theme } = useUnistyles(); const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.optionMainPressable, @@ -213,7 +206,7 @@ export function WorkspaceTabOptionRow({ {selected ? ( - + ) : null} {trailingAccessory ? ( @@ -238,6 +231,33 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.full, borderWidth: 1, }, + statusDotBorderDefault: { + borderColor: theme.colors.surface0, + }, + statusDotNeedsInput: { + color: theme.colors.palette.amber[500], + }, + statusDotFailed: { + color: theme.colors.palette.red[500], + }, + statusDotRunning: { + color: theme.colors.palette.blue[500], + }, + statusDotAttention: { + color: theme.colors.palette.green[500], + }, + iconActive: { + color: theme.colors.foreground, + }, + iconInactive: { + color: theme.colors.foregroundMuted, + }, + syncedLoader: { + color: + theme.colorScheme === "light" + ? theme.colors.palette.amber[700] + : theme.colors.palette.amber[500], + }, optionRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 4b20000f5..43617e483 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -412,74 +412,83 @@ const ghosttyDarkColors = buildDarkSemanticColors({ destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces }); +export const SPACING = { + 0: 0, + 1: 4, + 1.5: 6, + 2: 8, + 3: 12, + 4: 16, + 6: 24, + 8: 32, + 12: 48, + 16: 64, + 20: 80, + 24: 96, + 32: 128, +} as const; + +export const FONT_SIZE = { + xs: 12, + sm: 14, + base: 16, + lg: 18, + xl: 20, + "2xl": 22, + "3xl": 26, + "4xl": 34, +} as const; + +export const LINE_HEIGHT = { + diff: 22, +} as const; + +export const ICON_SIZE = { + xs: 12, + sm: 14, + md: 16, + lg: 20, +} as const; + +export const FONT_WEIGHT = { + normal: "normal" as const, + medium: "500" as const, + semibold: "600" as const, + bold: "bold" as const, +} as const; + +export const BORDER_RADIUS = { + none: 0, + sm: 2, + base: 4, + md: 6, + lg: 8, + xl: 12, + "2xl": 16, + full: 9999, +} as const; + +export const BORDER_WIDTH = { + 0: 0, + 1: 1, + 2: 2, +} as const; + +export const OPACITY = { + 0: 0, + 50: 0.5, + 100: 1, +} as const; + const commonTheme = { - spacing: { - 0: 0, - 1: 4, - 1.5: 6, - 2: 8, - 3: 12, - 4: 16, - 6: 24, - 8: 32, - 12: 48, - 16: 64, - 20: 80, - 24: 96, - 32: 128, - }, - - fontSize: { - xs: 12, - sm: 14, - base: 16, - lg: 18, - xl: 20, - "2xl": 22, - "3xl": 26, - "4xl": 34, - }, - - lineHeight: { - diff: 22, - }, - - iconSize: { - xs: 12, - sm: 14, - md: 16, - lg: 20, - }, - - fontWeight: { - normal: "normal" as const, - medium: "500" as const, - semibold: "600" as const, - bold: "bold" as const, - }, - - borderRadius: { - none: 0, - sm: 2, - base: 4, - md: 6, - lg: 8, - xl: 12, - "2xl": 16, - full: 9999, - }, - - borderWidth: { - 0: 0, - 1: 1, - 2: 2, - }, - - opacity: { - 0: 0, - 50: 0.5, - 100: 1, - }, + spacing: SPACING, + fontSize: FONT_SIZE, + lineHeight: LINE_HEIGHT, + iconSize: ICON_SIZE, + fontWeight: FONT_WEIGHT, + borderRadius: BORDER_RADIUS, + borderWidth: BORDER_WIDTH, + opacity: OPACITY, } as const; const darkShadow = {