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.
This commit is contained in:
Mohamed Boudra
2026-04-26 16:12:09 +07:00
parent 941b4fb760
commit 01f89c7c36
22 changed files with 1014 additions and 720 deletions
+68
View File
@@ -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. 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],
},
}));
<View style={styles.container} />;
```
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);
<ThemedBlur tint={theme.colors.surface0} />;
```
(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 ## 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. 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.
@@ -18,7 +18,7 @@ import {
ActivityIndicator, ActivityIndicator,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -84,6 +84,7 @@ import {
WORKING_INDICATOR_OFFSETS, WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator"; } from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -156,7 +157,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref, ref,
) { ) {
const viewportRef = useRef<StreamViewportHandle | null>(null); const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter(); const router = useRouter();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo( const streamRenderStrategy = useMemo(
@@ -303,9 +303,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom"); viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []); }, []);
const tightGap = theme.spacing[1]; // 4px const tightGap = SPACING[1];
const assistantBlockGap = theme.spacing[3]; // 12px const assistantBlockGap = SPACING[3];
const looseGap = theme.spacing[4]; // 16px const looseGap = SPACING[4];
const getGapBetween = useCallback( const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => { (item: StreamItem | null, belowItem: StreamItem | null) => {
@@ -891,16 +891,33 @@ function ToolCallSlot({
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />; return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
} }
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 { interface PermissionActionButtonProps {
action: AgentPermissionAction; action: AgentPermissionAction;
isRespondingAction: boolean; isRespondingAction: boolean;
isResponding: boolean; isResponding: boolean;
textColor: string; isPrimary: boolean;
iconColor: string; Icon: typeof ThemedCheckIcon;
isDanger: boolean;
Icon: typeof Check;
testID: string; testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void; onPress: (action: AgentPermissionAction) => void;
} }
@@ -908,37 +925,21 @@ function PermissionActionButton({
action, action,
isRespondingAction, isRespondingAction,
isResponding, isResponding,
textColor, isPrimary,
iconColor,
isDanger,
Icon, Icon,
testID, testID,
theme,
onPress, onPress,
}: PermissionActionButtonProps) { }: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]); const handlePress = useCallback(() => onPress(action), [onPress, action]);
const pressableStyle = useCallback( const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
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],
);
return ( return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}> <Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? ( {isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} /> <ThemedActivityIndicator size="small" uniProps={colorMapping} />
) : ( ) : (
<View style={permissionStyles.optionContent}> <View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} /> <Icon size={14} uniProps={colorMapping} />
<Text style={optionTextStyle}>{action.label}</Text> <Text style={optionTextStyle}>{action.label}</Text>
</View> </View>
)} )}
@@ -953,7 +954,6 @@ function PermissionRequestCard({
permission: PendingPermission; permission: PendingPermission;
client: DaemonClient | null; client: DaemonClient | null;
}) { }) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const { request } = permission; const { request } = permission;
@@ -1069,10 +1069,6 @@ function PermissionRequestCard({
[handleResponse], [handleResponse],
); );
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo( const optionsContainerStyle = useMemo(
() => [ () => [
permissionStyles.optionsContainer, permissionStyles.optionsContainer,
@@ -1080,24 +1076,6 @@ function PermissionRequestCard({
], ],
[isMobile], [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") { if (request.kind === "question") {
return ( return (
@@ -1111,18 +1089,15 @@ function PermissionRequestCard({
const footer = ( const footer = (
<> <>
<Text testID="permission-request-question" style={questionTextStyle}> <Text testID="permission-request-question" style={permissionStyles.question}>
How would you like to proceed? How would you like to proceed?
</Text> </Text>
<View style={optionsContainerStyle}> <View style={optionsContainerStyle}>
{resolvedActions.map((action) => { {resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary"; const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id; const isRespondingAction = respondingActionId === action.id;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted; const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
let testID: string; let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny"; if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement") else if (action.id === "accept" || action.id === "implement")
@@ -1135,12 +1110,9 @@ function PermissionRequestCard({
action={action} action={action}
isRespondingAction={isRespondingAction} isRespondingAction={isRespondingAction}
isResponding={isResponding} isResponding={isResponding}
textColor={textColor} isPrimary={isPrimary}
iconColor={iconColor}
isDanger={isDanger}
Icon={Icon} Icon={Icon}
testID={testID} testID={testID}
theme={theme}
onPress={handleActionPress} onPress={handleActionPress}
/> />
); );
@@ -1162,10 +1134,10 @@ function PermissionRequestCard({
} }
return ( return (
<View style={cardContainerStyle}> <View style={permissionStyles.container}>
<Text style={cardTitleStyle}>{title}</Text> <Text style={permissionStyles.title}>{title}</Text>
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null} {description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{planMarkdown ? ( {planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
@@ -1311,14 +1283,18 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.spacing[2], borderRadius: theme.spacing[2],
borderWidth: 1, borderWidth: 1,
gap: theme.spacing[2], gap: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
}, },
title: { title: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
lineHeight: 22, lineHeight: 22,
color: theme.colors.foreground,
}, },
description: { description: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
lineHeight: 20, lineHeight: 20,
color: theme.colors.foregroundMuted,
}, },
section: { section: {
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -1330,6 +1306,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1], marginTop: theme.spacing[1],
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
}, },
optionsContainer: { optionsContainer: {
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -1346,6 +1323,11 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
alignItems: "center", alignItems: "center",
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
optionButtonHovered: {
backgroundColor: theme.colors.surface2,
}, },
optionButtonPressed: { optionButtonPressed: {
opacity: 0.9, opacity: 0.9,
@@ -1358,9 +1340,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
optionText: { optionText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
optionTextPrimary: {
color: theme.colors.foreground,
}, },
})); }));
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
interface StreamItemWrapperProps { interface StreamItemWrapperProps {
gapBelow: number; gapBelow: number;
children: ReactNode; children: ReactNode;
@@ -188,6 +188,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: { StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
}, },
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }), useUnistyles: () => ({ theme }),
})); }));
+28 -26
View File
@@ -7,7 +7,7 @@ import {
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react"; 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 { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { import {
@@ -40,7 +40,7 @@ import {
type MessageInputRef, type MessageInputRef,
type AttachmentMenuItem, type AttachmentMenuItem,
} from "./message-input"; } 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 type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
@@ -92,8 +92,8 @@ type AttachmentListUpdater =
function noop() {} function noop() {}
function resolveComposerButtonIconSize(theme: Theme): number { function resolveComposerButtonIconSize(): number {
return isWeb ? theme.iconSize.md : theme.iconSize.lg; return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
} }
function resolveIsComposerLocked( function resolveIsComposerLocked(
@@ -585,7 +585,6 @@ interface QueuedMessageRowProps {
} }
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
const { theme } = useUnistyles();
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
onEdit(item.id); onEdit(item.id);
}, [onEdit, item.id]); }, [onEdit, item.id]);
@@ -599,10 +598,10 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
</Text> </Text>
<View style={styles.queueActions}> <View style={styles.queueActions}>
<Pressable onPress={handleEdit} style={styles.queueActionButton}> <Pressable onPress={handleEdit} style={styles.queueActionButton}>
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} /> <ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable> </Pressable>
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}> <Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
<ArrowUp size={theme.iconSize.sm} color="white" /> <ArrowUp size={ICON_SIZE.sm} color="white" />
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -668,7 +667,6 @@ function GithubAttachmentPill({
onOpen, onOpen,
onRemove, onRemove,
}: GithubAttachmentPillProps) { }: GithubAttachmentPillProps) {
const { theme } = useUnistyles();
const item = attachment.item; const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue"; const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
@@ -689,9 +687,9 @@ function GithubAttachmentPill({
<View style={styles.githubPillBody}> <View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}> <View style={styles.githubPillIcon}>
{item.kind === "pr" ? ( {item.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : ( ) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
)} )}
</View> </View>
<Text style={styles.githubPillText} numberOfLines={1}> <Text style={styles.githubPillText} numberOfLines={1}>
@@ -719,18 +717,17 @@ function GithubPickerOption({
item, item,
onToggle, onToggle,
}: GithubPickerOptionProps) { }: GithubPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
onToggle(item); onToggle(item);
}, [onToggle, item]); }, [onToggle, item]);
const leadingSlot = useMemo( const leadingSlot = useMemo(
() => () =>
item.kind === "pr" ? ( item.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : ( ) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
), ),
[item.kind, theme.iconSize.sm, theme.colors.foregroundMuted], [item.kind],
); );
return ( return (
<ComboboxItem <ComboboxItem
@@ -872,7 +869,6 @@ interface ComposerVoiceModeButtonProps {
realtimeVoiceButtonStyle: ( realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean }, state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[]; ) => (object | undefined)[];
theme: Theme;
voiceToggleKeys: ReturnType<typeof useShortcutKeys>; voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
} }
@@ -911,7 +907,6 @@ function ComposerVoiceModeButton({
isConnected, isConnected,
isVoiceSwitching, isVoiceSwitching,
realtimeVoiceButtonStyle, realtimeVoiceButtonStyle,
theme,
voiceToggleKeys, voiceToggleKeys,
}: ComposerVoiceModeButtonProps) { }: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? ( const shortcutNode = voiceToggleKeys ? (
@@ -922,10 +917,10 @@ function ComposerVoiceModeButton({
if (isVoiceSwitching) { if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />; return <ActivityIndicator size="small" color="white" />;
} }
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <AudioLines size={buttonIconSize} color={color} />; return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
}, },
[buttonIconSize, isVoiceSwitching, theme.colors.foreground, theme.colors.foregroundMuted], [buttonIconSize, isVoiceSwitching],
); );
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -978,8 +973,7 @@ export function Composer({
inputWrapperStyle, inputWrapperStyle,
}: ComposerProps) { }: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles(); const buttonIconSize = resolveComposerButtonIconSize();
const buttonIconSize = resolveComposerButtonIconSize(theme);
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1430,7 +1424,6 @@ export function Composer({
isConnected={isConnected} isConnected={isConnected}
isVoiceSwitching={isVoiceSwitchingValue} isVoiceSwitching={isVoiceSwitchingValue}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle} realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
theme={theme}
voiceToggleKeys={voiceToggleKeys} voiceToggleKeys={voiceToggleKeys}
cancelButton={cancelButton} cancelButton={cancelButton}
/> />
@@ -1447,7 +1440,6 @@ export function Composer({
isVoiceModeForAgent, isVoiceModeForAgent,
isVoiceSwitchingValue, isVoiceSwitchingValue,
realtimeVoiceButtonStyle, realtimeVoiceButtonStyle,
theme,
voiceToggleKeys, voiceToggleKeys,
], ],
); );
@@ -1491,7 +1483,7 @@ export function Composer({
{ {
id: "image", id: "image",
label: "Add image", label: "Add image",
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />, icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => { onSelect: () => {
void handlePickImage(); void handlePickImage();
}, },
@@ -1499,13 +1491,13 @@ export function Composer({
{ {
id: "github", id: "github",
label: "Add issue or PR", label: "Add issue or PR",
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />, icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => { onSelect: () => {
setIsGithubPickerOpen(true); setIsGithubPickerOpen(true);
}, },
}, },
], ],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md], [handlePickImage],
); );
const handleToggleGithubItem = useCallback( const handleToggleGithubItem = useCallback(
@@ -1860,3 +1852,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
})) as unknown as Record<string, object>; })) as unknown as Record<string, object>;
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton]; 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 });
@@ -47,7 +47,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: { StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
}, },
useUnistyles: () => ({ theme }), withUnistyles: <T,>(component: T) => component,
})); }));
vi.mock("@/constants/platform", () => ({ vi.mock("@/constants/platform", () => ({
+23 -30
View File
@@ -18,8 +18,8 @@ import {
useMemo, useMemo,
forwardRef, forwardRef,
} from "react"; } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
@@ -195,18 +195,15 @@ function AttachButtonIcon({
hovered, hovered,
onAttachButtonRef, onAttachButtonRef,
buttonIconSize, buttonIconSize,
foreground,
foregroundMuted,
}: { }: {
hovered: boolean; hovered: boolean;
onAttachButtonRef: ((node: View | null) => void) | undefined; onAttachButtonRef: ((node: View | null) => void) | undefined;
buttonIconSize: number; buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) { }) {
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return ( return (
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}> <View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
<Plus size={buttonIconSize} color={hovered ? foreground : foregroundMuted} /> <ThemedPlus size={buttonIconSize} uniProps={colorMapping} />
</View> </View>
); );
} }
@@ -278,23 +275,20 @@ function VoiceButtonIcon({
isDictating, isDictating,
isMutedRealtime, isMutedRealtime,
buttonIconSize, buttonIconSize,
foreground,
foregroundMuted,
}: { }: {
hovered: boolean; hovered: boolean;
isDictating: boolean; isDictating: boolean;
isMutedRealtime: boolean; isMutedRealtime: boolean;
buttonIconSize: number; buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) { }) {
if (isDictating) { if (isDictating) {
return <Square size={buttonIconSize} color="white" fill="white" />; return <Square size={buttonIconSize} color="white" fill="white" />;
} }
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) { if (isMutedRealtime) {
return <MicOff size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />; return <ThemedMicOff size={buttonIconSize} uniProps={colorMapping} />;
} }
return <Mic size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />; return <ThemedMic size={buttonIconSize} uniProps={colorMapping} />;
} }
type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>; type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>;
@@ -1365,8 +1359,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onHeightChange, onHeightChange,
inputWrapperStyle, inputWrapperStyle,
} = resolveMessageInputProps(props); } = resolveMessageInputProps(props);
const { theme } = useUnistyles(); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId); const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId);
markScrollInvestigationRender(investigationComponentId); markScrollInvestigationRender(investigationComponentId);
const toast = useToast(); const toast = useToast();
@@ -1892,11 +1885,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
hovered={Boolean(hovered)} hovered={Boolean(hovered)}
onAttachButtonRef={onAttachButtonRef} onAttachButtonRef={onAttachButtonRef}
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/> />
), ),
[onAttachButtonRef, buttonIconSize, theme.colors.foreground, theme.colors.foregroundMuted], [onAttachButtonRef, buttonIconSize],
); );
const renderVoiceButtonIcon = useCallback( const renderVoiceButtonIcon = useCallback(
@@ -1906,18 +1897,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictating={isDictating} isDictating={isDictating}
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)} isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/> />
), ),
[ [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
isDictating,
isRealtimeVoiceForCurrentAgent,
voice?.isMuted,
buttonIconSize,
theme.colors.foreground,
theme.colors.foregroundMuted,
],
); );
return ( return (
@@ -1926,12 +1908,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}> <Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
{/* Text input */} {/* Text input */}
<View style={styles.textInputScrollWrapper}> <View style={styles.textInputScrollWrapper}>
<TextInput <ThemedTextInput
ref={textInputRef} ref={textInputRef}
value={value} value={value}
onChangeText={handleInputChange} onChangeText={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.surface4} uniProps={textInputPlaceholderColorMapping}
accessibilityLabel="Message agent..." accessibilityLabel="Message agent..."
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
@@ -2159,3 +2141,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
bottom: 0, bottom: 0,
}, },
})) as unknown as Record<string, object>; })) as unknown as Record<string, object>;
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,
});
+112 -55
View File
@@ -48,7 +48,8 @@ import {
MicVocal, MicVocal,
FileSymlink, FileSymlink,
} from "lucide-react-native"; } 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 { useIsCompactFormFactor } from "@/constants/layout";
import Animated, { import Animated, {
Easing, Easing,
@@ -102,6 +103,29 @@ export type { InlinePathTarget } from "@/utils/inline-path";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>; type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
function useMessageRenderTracker(
label: string,
identity: string,
watched: Record<string, unknown>,
): void {
const renderCountRef = useRef(0);
const prevRef = useRef<Record<string, unknown> | 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 { interface UserMessageProps {
message: string; message: string;
images?: UserMessageImageAttachment[]; images?: UserMessageImageAttachment[];
@@ -154,6 +178,26 @@ interface MarkdownWithStableRendererProps {
} }
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>; const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
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 = ` const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% { 0% {
@@ -381,7 +425,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true, isLastInGroup = true,
disableOuterSpacing, disableOuterSpacing,
}: UserMessageProps) { }: UserMessageProps) {
const { theme } = useUnistyles(); useMessageRenderTracker("UserMessage", `${(message ?? "").slice(0, 16)}`, {
message,
images,
isFirstInGroup,
isLastInGroup,
disableOuterSpacing,
});
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false); const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false);
@@ -398,12 +448,12 @@ export const UserMessage = memo(function UserMessage({
() => [ () => [
userMessageStylesheet.container, userMessageStylesheet.container,
!resolvedDisableOuterSpacing && [ !resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] }, isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null,
isLastInGroup && { marginBottom: theme.spacing[4] }, isLastInGroup ? userMessageStylesheet.containerLastInGroup : null,
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined, !isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null,
], ],
], ],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup, theme.spacing], [resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup],
); );
const imagePreviewContainerStyle = useMemo( const imagePreviewContainerStyle = useMemo(
() => [ () => [
@@ -628,7 +678,6 @@ function AssistantMarkdownImage({
workspaceRoot?: string; workspaceRoot?: string;
serverId?: string; serverId?: string;
}) { }) {
const { theme } = useUnistyles();
const resolution = useMemo( const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }), () => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot], [source, workspaceRoot],
@@ -636,10 +685,10 @@ function AssistantMarkdownImage({
const dataImage = useMemo(() => parseImageDataUrl(source), [source]); const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>( const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({ () => ({
marginTop: hasLeadingContent ? theme.spacing[4] : 0, marginTop: hasLeadingContent ? 16 : 0,
marginBottom: 0, marginBottom: 0,
}), }),
[hasLeadingContent, theme], [hasLeadingContent],
); );
const query = useQuery({ const query = useQuery({
@@ -1224,7 +1273,6 @@ function AssistantMessageBlockContainer({
interface MemoizedMarkdownBlockProps { interface MemoizedMarkdownBlockProps {
text: string; text: string;
styles: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules; rules: RenderRules;
parser: MarkdownIt; parser: MarkdownIt;
onLinkPress: (url: string) => boolean; onLinkPress: (url: string) => boolean;
@@ -1232,14 +1280,13 @@ interface MemoizedMarkdownBlockProps {
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
text, text,
styles,
rules, rules,
parser, parser,
onLinkPress, onLinkPress,
}: MemoizedMarkdownBlockProps) { }: MemoizedMarkdownBlockProps) {
return ( return (
<MarkdownWithStableRenderer <ThemedMarkdown
style={styles} uniProps={markdownStyleMapping}
rules={rules} rules={rules}
markdownit={parser} markdownit={parser}
onLinkPress={onLinkPress} onLinkPress={onLinkPress}
@@ -1247,7 +1294,7 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM} topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
> >
{text} {text}
</MarkdownWithStableRenderer> </ThemedMarkdown>
); );
}); });
@@ -1333,13 +1380,23 @@ export const AssistantMessage = memo(function AssistantMessage({
disableOuterSpacing, disableOuterSpacing,
spacing = "default", spacing = "default",
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { theme } = useUnistyles(); useMessageRenderTracker(
"AssistantMessage",
`${serverId ?? "?"}/${(message ?? "").slice(0, 16)}`,
{
message,
onInlinePathPress,
workspaceRoot,
serverId,
client,
disableOuterSpacing,
spacing,
},
);
const resolvedDisableOuterSpacing = useDisableOuterSpacing( const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default", disableOuterSpacing ?? spacing !== "default",
); );
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => { const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true }); const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser); const defaultValidateLink = parser.validateLink.bind(parser);
@@ -1602,11 +1659,10 @@ export const AssistantMessage = memo(function AssistantMessage({
<AssistantMessageBlockContainer <AssistantMessageBlockContainer
key={key} key={key}
block={block} block={block}
marginBottom={index < keyedBlocks.length - 1 ? theme.spacing[3] : 0} marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
> >
<MemoizedMarkdownBlock <MemoizedMarkdownBlock
text={block} text={block}
styles={markdownStyles}
rules={markdownRules} rules={markdownRules}
parser={markdownParser} parser={markdownParser}
onLinkPress={handleLinkPress} onLinkPress={handleLinkPress}
@@ -1656,7 +1712,6 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp, timestamp: _timestamp,
disableOuterSpacing, disableOuterSpacing,
}: SpeakMessageProps) { }: SpeakMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [ () => [
@@ -1669,7 +1724,7 @@ export const SpeakMessage = memo(function SpeakMessage({
return ( return (
<View testID="speak-message" style={containerStyle}> <View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}> <View style={speakMessageStylesheet.header}>
<MicVocal size={14} color={theme.colors.foregroundMuted} /> <ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text> <Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
</View> </View>
<Text style={speakMessageStylesheet.text}>{message}</Text> <Text style={speakMessageStylesheet.text}>{message}</Text>
@@ -1927,7 +1982,6 @@ interface TodoListItemRowProps {
} }
function TodoListItemRow({ text, completed }: TodoListItemRowProps) { function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
const { theme: todoUnistylesTheme } = useUnistyles();
const badgeStyle = useMemo( const badgeStyle = useMemo(
() => [ () => [
todoListCardStylesheet.radioBadge, todoListCardStylesheet.radioBadge,
@@ -1944,7 +1998,9 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
return ( return (
<View style={todoListCardStylesheet.itemRow}> <View style={todoListCardStylesheet.itemRow}>
<View style={badgeStyle}> <View style={badgeStyle}>
{completed ? <Check size={12} color={todoUnistylesTheme.colors.primaryForeground} /> : null} {completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : null}
</View> </View>
<Text style={textStyle}>{text}</Text> <Text style={textStyle}>{text}</Text>
</View> </View>
@@ -1996,7 +2052,6 @@ export const TodoListCard = memo(function TodoListCard({
items, items,
disableOuterSpacing, disableOuterSpacing,
}: TodoListCardProps) { }: TodoListCardProps) {
const { theme: _unistylesTheme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
@@ -2158,7 +2213,6 @@ function ExpandableBadgeLabelRow({
onOpenFileHoverIn, onOpenFileHoverIn,
onOpenFileHoverOut, onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) { }: ExpandableBadgeLabelRowProps) {
const { theme } = useUnistyles();
return ( return (
<View <View
style={expandableBadgeStylesheet.labelRow} style={expandableBadgeStylesheet.labelRow}
@@ -2188,9 +2242,9 @@ function ExpandableBadgeLabelRow({
style={expandableBadgeStylesheet.openFileButton} style={expandableBadgeStylesheet.openFileButton}
hitSlop={6} hitSlop={6}
> >
<FileSymlink <ThemedFileSymlinkIcon
size={14} size={14}
color={isOpenFileHovered ? theme.colors.foreground : theme.colors.foregroundMuted} uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/> />
</Pressable> </Pressable>
) : null} ) : null}
@@ -2217,52 +2271,42 @@ function ExpandableBadgeLabelRow({
); );
} }
function resolveExpandableBadgeIconColor({ function renderExpandableBadgeIcon({
isError, isError,
isActive, isActive,
theme, ThemedIcon,
}: { }: {
isError: boolean; isError: boolean;
isActive: boolean; isActive: boolean;
theme: ReturnType<typeof useUnistyles>["theme"]; ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
}): 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 }>;
}): ReactNode { }): ReactNode {
if (isError) { if (isError) {
return <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />; return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
} }
if (icon) { if (ThemedIcon) {
const IconComponent = icon; return (
return <IconComponent size={12} color={iconColor} />; <ThemedIcon
size={12}
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
/>
);
} }
return null; return null;
} }
function renderExpandableBadgeIconSlot({ function renderExpandableBadgeIconSlot({
showChevron, showChevron,
chevronColor,
chevronStyle, chevronStyle,
iconNode, iconNode,
}: { }: {
showChevron: boolean; showChevron: boolean;
chevronColor: string;
chevronStyle: StyleProp<ViewStyle>; chevronStyle: StyleProp<ViewStyle>;
iconNode: ReactNode; iconNode: ReactNode;
}): ReactNode { }): ReactNode {
if (showChevron) { if (showChevron) {
return <ChevronRight size={14} color={chevronColor} style={chevronStyle} />; return (
<ThemedChevronRightIcon size={14} style={chevronStyle} uniProps={foregroundColorMapping} />
);
} }
return iconNode; return iconNode;
} }
@@ -2385,7 +2429,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
disableOuterSpacing, disableOuterSpacing,
testID, testID,
}: ExpandableBadgeProps) { }: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false); const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
@@ -2614,11 +2657,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
[isExpanded], [isExpanded],
); );
const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme }); const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon }); const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
const iconSlotNode = renderExpandableBadgeIconSlot({ const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered && !isIconHovered, showChevron: isInteractive && isHovered && !isIconHovered,
chevronColor: theme.colors.foreground,
chevronStyle, chevronStyle,
iconNode, iconNode,
}); });
@@ -2743,6 +2785,21 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsExpandedChange, onInlineDetailsExpandedChange,
onOpenFilePath, onOpenFilePath,
}: ToolCallProps) { }: ToolCallProps) {
useMessageRenderTracker("ToolCall", `${toolName}/${status}`, {
toolName,
args,
result,
error,
status,
detail,
cwd,
metadata,
isLastInSequence,
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
});
const { openToolCall } = useToolCallSheet(); const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -9,6 +9,7 @@ import {
ScrollView, ScrollView,
type GestureResponderEvent, type GestureResponderEvent,
type PressableStateCallbackType, type PressableStateCallbackType,
type ViewStyle,
} from "react-native"; } from "react-native";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
@@ -24,7 +25,8 @@ import {
} from "react"; } from "react";
import { router, usePathname, type Href } from "expo-router"; import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; 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 { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
@@ -85,7 +87,8 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; 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 { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
@@ -125,17 +128,47 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9; const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0; const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1; const EMPHASIZED_STATUS_DOT_OFFSET = -1;
function getWorkspacePrIconColor( const ThemedExternalLink = withUnistyles(ExternalLink);
theme: ReturnType<typeof useUnistyles>["theme"], const ThemedGitPullRequest = withUnistyles(GitPullRequest);
state: PrHint["state"], 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) { switch (state) {
case "merged": case "merged":
return theme.colors.palette.purple[500]; return purpleColorMapping;
case "open": case "open":
return theme.colors.palette.green[500]; return greenColorMapping;
case "closed": case "closed":
return theme.colors.palette.red[500]; return redColorMapping;
} }
} }
@@ -223,10 +256,7 @@ function useSidebarWorkspaceEntry(
} }
export function PrBadge({ hint }: { hint: PrHint }) { export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); 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) => { const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -243,10 +273,8 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []); const handleHoverOut = useCallback(() => setIsHovered(false), []);
const prBadgeTextStyle = useMemo( const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
() => [prBadgeStyles.text, { color: activeColor }], const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
[activeColor],
);
return ( return (
<Pressable <Pressable
@@ -260,11 +288,11 @@ export function PrBadge({ hint }: { hint: PrHint }) {
style={prBadgePressableStyle} style={prBadgePressableStyle}
> >
{isHovered ? ( {isHovered ? (
<ExternalLink size={12} color={activeColor} /> <ThemedExternalLink size={12} uniProps={iconUniProps} />
) : ( ) : (
<GitPullRequest size={12} color={iconColor} /> <ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)} )}
<Text style={prBadgeTextStyle} numberOfLines={1}> <Text style={textStyle} numberOfLines={1}>
#{hint.number} #{hint.number}
</Text> </Text>
</Pressable> </Pressable>
@@ -302,15 +330,16 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
lineHeight: 14, 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 { 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; if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length; const failed = checks.filter((c) => c.status === "failure").length;
@@ -320,8 +349,8 @@ function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | n
return ( return (
<View style={checksBadgeStyles.badge}> <View style={checksBadgeStyles.badge}>
<GitHubIcon size={10} color={color} /> <ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={textStyle}>{label}</Text> <Text style={checksBadgeStyles.text}>{label}</Text>
</View> </View>
); );
} }
@@ -336,6 +365,7 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
lineHeight: 14, lineHeight: 14,
color: theme.colors.palette.red[500],
}, },
})); }));
@@ -348,13 +378,12 @@ function WorkspaceStatusIndicator({
workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean; loading?: boolean;
}) { }) {
const { theme } = useUnistyles();
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) { if (loading) {
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View> </View>
); );
} }
@@ -362,14 +391,7 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) { if (shouldShowSyncedLoader) {
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
<SyncedLoader <ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View> </View>
); );
} }
@@ -377,18 +399,18 @@ function WorkspaceStatusIndicator({
if (bucket === "needs_input") { if (bucket === "needs_input") {
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} /> <ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View> </View>
); );
} }
let KindIcon: typeof Monitor | typeof FolderGit2 | null; let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = Monitor; if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2; else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = null; else KindIcon = null;
if (!KindIcon) return null; if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false }); const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket) const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE ? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE; : DEFAULT_STATUS_DOT_SIZE;
@@ -399,11 +421,10 @@ function WorkspaceStatusIndicator({
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} /> <KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColor ? ( {dotColorStyle ? (
<StatusDotOverlay <StatusDotOverlay
dotColor={dotColor} dotColorStyle={dotColorStyle}
borderColor={theme.colors.surface0}
size={statusDotSize} size={statusDotSize}
offset={statusDotOffset} offset={statusDotOffset}
/> />
@@ -413,29 +434,26 @@ function WorkspaceStatusIndicator({
} }
function StatusDotOverlay({ function StatusDotOverlay({
dotColor, dotColorStyle,
borderColor,
size, size,
offset, offset,
}: { }: {
dotColor: string; dotColorStyle: ViewStyle;
borderColor: string;
size: number; size: number;
offset: number; offset: number;
}) { }) {
const overlayStyle = useMemo( const overlayStyle = useMemo(
() => [ () => [
styles.statusDotOverlay, styles.statusDotOverlay,
dotColorStyle,
{ {
backgroundColor: dotColor,
borderColor,
width: size, width: size,
height: size, height: size,
right: offset, right: offset,
bottom: offset, bottom: offset,
}, },
], ],
[dotColor, borderColor, size, offset], [dotColorStyle, size, offset],
); );
return <View style={overlayStyle} />; return <View style={overlayStyle} />;
} }
@@ -455,7 +473,6 @@ function ProjectLeadingVisual({
showChevron?: boolean; showChevron?: boolean;
isArchiving?: boolean; isArchiving?: boolean;
}) { }) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace; const activeWorkspace = workspace;
@@ -488,7 +505,6 @@ function ProjectLeadingVisual({
isArchiving={isArchiving} isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader} shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace} activeWorkspace={activeWorkspace}
theme={theme}
/> />
); );
} }
@@ -503,7 +519,6 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup, onBeginWorkspaceSetup,
onRemoveProject, onRemoveProject,
removeProjectStatus, removeProjectStatus,
theme,
}: { }: {
project: SidebarProjectEntry; project: SidebarProjectEntry;
displayName: string; displayName: string;
@@ -514,7 +529,6 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup: () => void; onBeginWorkspaceSetup: () => void;
onRemoveProject?: () => void; onRemoveProject?: () => void;
removeProjectStatus: "idle" | "pending" | "success"; removeProjectStatus: "idle" | "pending" | "success";
theme: ReturnType<typeof useUnistyles>["theme"];
}) { }) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
return ( return (
@@ -535,7 +549,6 @@ function ProjectRowTrailingActions({
> >
<ProjectKebabMenu <ProjectKebabMenu
projectKey={project.projectKey} projectKey={project.projectKey}
theme={theme}
onRemoveProject={onRemoveProject} onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus} removeProjectStatus={removeProjectStatus}
/> />
@@ -545,34 +558,29 @@ function ProjectRowTrailingActions({
); );
} }
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function ProjectKebabMenu({ function ProjectKebabMenu({
projectKey, projectKey,
theme,
onRemoveProject, onRemoveProject,
removeProjectStatus, removeProjectStatus,
}: { }: {
projectKey: string; projectKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onRemoveProject: () => void; onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success"; removeProjectStatus: "idle" | "pending" | "success";
}) { }) {
const removeProjectLeadingIcon = useMemo(
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const settingsLeadingIcon = useMemo(
() => <Settings size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
const handleOpenProjectSettings = useCallback(() => { const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return; if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey)); router.navigate(buildProjectSettingsRoute(projectKey));
@@ -587,7 +595,7 @@ function ProjectKebabMenu({
accessibilityLabel="Project actions" accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`} testID={`sidebar-project-kebab-${projectKey}`}
> >
{renderTriggerIcon} {renderKebabTriggerIcon}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}> <DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? ( {canOpenProjectSettings ? (
@@ -601,7 +609,7 @@ function ProjectKebabMenu({
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`} testID={`sidebar-project-menu-remove-${projectKey}`}
leading={removeProjectLeadingIcon} leading={trash2LeadingIcon}
status={removeProjectStatus} status={removeProjectStatus}
pendingLabel="Removing..." pendingLabel="Removing..."
onSelect={onRemoveProject} onSelect={onRemoveProject}
@@ -622,7 +630,6 @@ function WorkspaceRowRightGroup({
isCreating, isCreating,
showShortcutBadge, showShortcutBadge,
shortcutNumber, shortcutNumber,
theme,
archiveLabel, archiveLabel,
archiveStatus, archiveStatus,
archivePendingLabel, archivePendingLabel,
@@ -639,7 +646,6 @@ function WorkspaceRowRightGroup({
isCreating: boolean; isCreating: boolean;
showShortcutBadge: boolean; showShortcutBadge: boolean;
shortcutNumber: number | null; shortcutNumber: number | null;
theme: ReturnType<typeof useUnistyles>["theme"];
archiveLabel?: string; archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success"; archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string; archivePendingLabel?: string;
@@ -654,9 +660,9 @@ function WorkspaceRowRightGroup({
{showScriptsIcon ? ( {showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available"> <View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{hasRunningService ? ( {hasRunningService ? (
<Globe size={12} color={theme.colors.palette.blue[500]} /> <ThemedGlobe size={12} uniProps={blueColorMapping} />
) : ( ) : (
<SquareTerminal size={12} color={theme.colors.palette.blue[500]} /> <ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)} )}
</View> </View>
) : null} ) : null}
@@ -664,7 +670,6 @@ function WorkspaceRowRightGroup({
{showKebab && onArchive ? ( {showKebab && onArchive ? (
<WorkspaceKebabMenu <WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey} workspaceKey={workspace.workspaceKey}
theme={theme}
onCopyPath={onCopyPath} onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName} onCopyBranchName={onCopyBranchName}
onArchive={onArchive} onArchive={onArchive}
@@ -689,21 +694,8 @@ function WorkspaceRowRightGroup({
); );
} }
function WorkspaceKebabTriggerIcon({
hovered,
foreground,
foregroundMuted,
}: {
hovered: boolean;
foreground: string;
foregroundMuted: string;
}) {
return <MoreVertical size={14} color={hovered ? foreground : foregroundMuted} />;
}
function WorkspaceKebabMenu({ function WorkspaceKebabMenu({
workspaceKey, workspaceKey,
theme,
onCopyPath, onCopyPath,
onCopyBranchName, onCopyBranchName,
onArchive, onArchive,
@@ -713,7 +705,6 @@ function WorkspaceKebabMenu({
archiveShortcutKeys, archiveShortcutKeys,
}: { }: {
workspaceKey: string; workspaceKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onCopyPath?: () => void; onCopyPath?: () => void;
onCopyBranchName?: () => void; onCopyBranchName?: () => void;
onArchive: () => void; onArchive: () => void;
@@ -722,28 +713,10 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string; archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null; archiveShortcutKeys?: ShortcutKey[][] | null;
}) { }) {
const copyLeadingIcon = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const archiveLeadingIcon = useMemo(
() => <Archive size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const archiveTrailing = useMemo( const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null), () => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys], [archiveShortcutKeys],
); );
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<WorkspaceKebabTriggerIcon
hovered={Boolean(hovered)}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -753,7 +726,7 @@ function WorkspaceKebabMenu({
accessibilityLabel="Workspace actions" accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`} testID={`sidebar-workspace-kebab-${workspaceKey}`}
> >
{renderTriggerIcon} {renderKebabTriggerIcon}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}> <DropdownMenuContent align="end" width={260}>
{onCopyPath ? ( {onCopyPath ? (
@@ -813,19 +786,17 @@ function ProjectLeadingVisualStatus({
isArchiving, isArchiving,
shouldShowSyncedLoader, shouldShowSyncedLoader,
activeWorkspace, activeWorkspace,
theme,
}: { }: {
iconDataUri: string | null; iconDataUri: string | null;
placeholderInitial: string; placeholderInitial: string;
isArchiving: boolean; isArchiving: boolean;
shouldShowSyncedLoader: boolean; shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry; activeWorkspace: SidebarWorkspaceEntry;
theme: ReturnType<typeof useUnistyles>["theme"];
}) { }) {
if (isArchiving) { if (isArchiving) {
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View> </View>
); );
} }
@@ -833,14 +804,7 @@ function ProjectLeadingVisualStatus({
if (shouldShowSyncedLoader) { if (shouldShowSyncedLoader) {
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
<SyncedLoader <ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View> </View>
); );
} }
@@ -848,16 +812,12 @@ function ProjectLeadingVisualStatus({
if (activeWorkspace.statusBucket === "needs_input") { if (activeWorkspace.statusBucket === "needs_input") {
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} /> <ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View> </View>
); );
} }
const dotColor = getStatusDotColor({ const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket);
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket) const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE ? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE; : DEFAULT_STATUS_DOT_SIZE;
@@ -869,10 +829,9 @@ function ProjectLeadingVisualStatus({
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} /> <ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
{dotColor ? ( {dotColorStyle ? (
<StatusDotOverlay <StatusDotOverlay
dotColor={dotColor} dotColorStyle={dotColorStyle}
borderColor={theme.colors.surface0}
size={statusDotSize} size={statusDotSize}
offset={statusDotOffset} offset={statusDotOffset}
/> />
@@ -906,7 +865,6 @@ function NewWorktreeButton({
testID: string; testID: string;
showShortcutHint?: boolean; showShortcutHint?: boolean;
}) { }) {
const { theme } = useUnistyles();
const newWorktreeKeys = useShortcutKeys("new-worktree"); const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback( const pressableStyle = useCallback(
@@ -940,12 +898,12 @@ function NewWorktreeButton({
> >
{({ hovered, pressed }) => {({ hovered, pressed }) =>
loading ? ( loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
) : ( ) : (
<FolderPlus <ThemedFolderPlus
size={15} size={15}
color={ uniProps={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
} }
/> />
) )
@@ -1205,7 +1163,6 @@ function ProjectHeaderRow({
removeProjectStatus = "idle", removeProjectStatus = "idle",
dragHandleProps, dragHandleProps,
}: ProjectHeaderRowProps) { }: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor(); const isMobileBreakpoint = useIsCompactFormFactor();
const handleBeginWorkspaceSetup = useCallback(() => { const handleBeginWorkspaceSetup = useCallback(() => {
@@ -1275,7 +1232,6 @@ function ProjectHeaderRow({
onBeginWorkspaceSetup={handleBeginWorkspaceSetup} onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
onRemoveProject={onRemoveProject} onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus} removeProjectStatus={removeProjectStatus}
theme={theme}
/> />
{showShortcutBadge && shortcutNumber !== null ? ( {showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}> <View style={styles.shortcutBadge}>
@@ -1351,7 +1307,6 @@ function WorkspaceRowInner({
onCopyPath, onCopyPath,
archiveShortcutKeys, archiveShortcutKeys,
}: WorkspaceRowInnerProps) { }: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const _isCompact = useIsCompactFormFactor(); const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative; const isTouchPlatform = platformIsNative;
@@ -1447,7 +1402,6 @@ function WorkspaceRowInner({
isCreating={isCreating} isCreating={isCreating}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
theme={theme}
archiveLabel={archiveLabel} archiveLabel={archiveLabel}
archiveStatus={archiveStatus} archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel} archivePendingLabel={archivePendingLabel}
@@ -2912,4 +2866,35 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
lineHeight: 14, 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;
}
}
@@ -92,6 +92,36 @@ function isScrollContainerOverscrolledPastBottom(
return getScrollContainerDistanceFromBottom(scrollContainer) < 0; return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
} }
const VIEWPORT_LOG_PREFIX = "[WebStreamViewport]";
function vlog(agentId: string, event: string, details: Record<string, unknown> = {}): void {
console.log(VIEWPORT_LOG_PREFIX, agentId, event, details);
}
function useRenderTracker(
label: string,
agentId: string,
watched: Record<string, unknown>,
): number {
const renderCountRef = useRef(0);
const prevRef = useRef<Record<string, unknown> | 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 }) { function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const { const {
segments, segments,
@@ -105,6 +135,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled, scrollEnabled,
isMobileBreakpoint, isMobileBreakpoint,
} = props; } = 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<HTMLElement | null>(null); const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null); const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => { const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
@@ -164,6 +214,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8, overscan: 8,
}); });
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:virtualizer-adjust setup fired", {});
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0; const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0; const scrollOffset = instance.scrollOffset ?? 0;
@@ -370,6 +421,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]); }, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]);
useLayoutEffect(() => { useLayoutEffect(() => {
vlog(props.agentId, "effect:activation-reset fired", { activationKey, isActivationReady });
if (!isActivationReady) { if (!isActivationReady) {
return; return;
} }
@@ -391,9 +443,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => { return () => {
window.clearTimeout(timeout); window.clearTimeout(timeout);
}; };
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]); }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom, props.agentId]);
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:stick-on-segments-change fired", {
followOutput: followOutputRef.current,
});
if (!followOutputRef.current) { if (!followOutputRef.current) {
return; return;
} }
@@ -403,16 +458,28 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
segments.historyMounted, segments.historyMounted,
segments.historyVirtualized, segments.historyVirtualized,
segments.liveHead, segments.liveHead,
props.agentId,
]); ]);
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:stick-on-virtual-total fired", {
followOutput: followOutputRef.current,
shouldUseVirtualizer,
virtualTotalSize,
});
if (!followOutputRef.current || !shouldUseVirtualizer) { if (!followOutputRef.current || !shouldUseVirtualizer) {
return; return;
} }
scheduleStickToBottom(); scheduleStickToBottom();
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]); }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize, props.agentId]);
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:metrics-on-segments-change fired", {
mounted: segments.historyMounted.length,
virtualized: segments.historyVirtualized.length,
liveHead: segments.liveHead.length,
virtualTotalSize,
});
updateScrollMetrics(); updateScrollMetrics();
}, [ }, [
segments.historyMounted.length, segments.historyMounted.length,
@@ -420,9 +487,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
segments.liveHead.length, segments.liveHead.length,
updateScrollMetrics, updateScrollMetrics,
virtualTotalSize, virtualTotalSize,
props.agentId,
]); ]);
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:resize-observer setup fired", {});
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current; const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") { if (!scrollContainer || typeof ResizeObserver === "undefined") {
@@ -456,6 +525,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics]); }, [props.agentId, scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:scroll-listeners setup fired", {});
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) { if (!scrollContainer) {
return; return;
@@ -604,6 +674,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId]); }, [cancelPendingStickToBottom, handleDomScroll, props.agentId]);
useEffect(() => { useEffect(() => {
vlog(props.agentId, "effect:viewportRef-handle setup fired", {});
const handle: StreamViewportHandle = { const handle: StreamViewportHandle = {
scrollToBottom: () => { scrollToBottom: () => {
setFollowOutput(true); setFollowOutput(true);
@@ -642,8 +713,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef, viewportRef,
]); ]);
const contentContainerStyle = useMemo( const contentContainerStyle = useMemo((): CSSProperties => {
(): CSSProperties => ({ vlog(props.agentId, "memo:contentContainerStyle rebuilt", { isMobileBreakpoint });
return {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
minHeight: "100%", minHeight: "100%",
@@ -652,27 +724,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
paddingLeft: isMobileBreakpoint ? 8 : 16, paddingLeft: isMobileBreakpoint ? 8 : 16,
paddingRight: isMobileBreakpoint ? 8 : 16, paddingRight: isMobileBreakpoint ? 8 : 16,
boxSizing: "border-box", boxSizing: "border-box",
}), };
[isMobileBreakpoint], }, [isMobileBreakpoint, props.agentId]);
); const scrollContainerStyle = useMemo((): CSSProperties => {
const scrollContainerStyle = useMemo( vlog(props.agentId, "memo:scrollContainerStyle rebuilt", { scrollEnabled });
(): CSSProperties => ({ return {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflowX: "hidden", overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden", overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain", overscrollBehaviorY: "contain",
}), };
[scrollEnabled], }, [scrollEnabled, props.agentId]);
); const virtualRowsContainerStyle = useMemo((): CSSProperties => {
const virtualRowsContainerStyle = useMemo( vlog(props.agentId, "memo:virtualRowsContainerStyle rebuilt", { virtualTotalSize });
(): CSSProperties => ({ return {
position: "relative", position: "relative",
width: "100%", width: "100%",
height: virtualTotalSize, height: virtualTotalSize,
}), };
[virtualTotalSize], }, [virtualTotalSize, props.agentId]);
);
const renderVirtualRowStyle = useCallback( const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({ (start: number): CSSProperties => ({
position: "absolute", position: "absolute",
@@ -685,29 +756,41 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}), }),
[], [],
); );
const mountedHistoryRows = useMemo( const mountedHistoryRows = useMemo(() => {
() => vlog(props.agentId, "memo:mountedHistoryRows rebuilt", {
segments.historyMounted.map((item, index) => ( count: segments.historyMounted.length,
<Fragment key={item.id}> });
{renderHistoryMountedRow(item, index, segments.historyMounted)} return segments.historyMounted.map((item, index) => (
</Fragment> <Fragment key={item.id}>
)), {renderHistoryMountedRow(item, index, segments.historyMounted)}
[renderHistoryMountedRow, segments.historyMounted], </Fragment>
); ));
const liveHeadRows = useMemo( }, [renderHistoryMountedRow, segments.historyMounted, props.agentId]);
() => const liveHeadRows = useMemo(() => {
segments.liveHead.map((item, index) => ( vlog(props.agentId, "memo:liveHeadRows rebuilt", { count: segments.liveHead.length });
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment> return segments.liveHead.map((item, index) => (
)), <Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
[renderLiveHeadRow, segments.liveHead], ));
); }, [renderLiveHeadRow, segments.liveHead, props.agentId]);
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]); const liveAuxiliary = useMemo(() => {
vlog(props.agentId, "memo:liveAuxiliary rebuilt", {});
return renderLiveAuxiliary();
}, [renderLiveAuxiliary, props.agentId]);
const shouldRenderEmpty = const shouldRenderEmpty =
!boundary.hasMountedHistory && !boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory && !boundary.hasVirtualizedHistory &&
!boundary.hasLiveHead && !boundary.hasLiveHead &&
!liveAuxiliary; !liveAuxiliary;
vlog(props.agentId, "render summary", {
virtualRowsRendered: virtualRows.length,
mountedHistoryRowsRendered: segments.historyMounted.length,
liveHeadRowsRendered: segments.liveHead.length,
shouldUseVirtualizer,
virtualTotalSize,
shouldRenderEmpty,
});
return ( return (
<> <>
<div <div
@@ -8,11 +8,20 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Dimensions, Platform, Text, View } from "react-native"; import {
Dimensions,
Platform,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink } from "lucide-react-native"; import { ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native"; import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
@@ -207,7 +216,6 @@ function WorkspaceHoverCardContent({
triggerRef: React.RefObject<View | null>; triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>; contentRef: React.RefObject<View | null>;
}): ReactElement | null { }): ReactElement | null {
const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true); const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -297,7 +305,7 @@ function WorkspaceHoverCardContent({
{prHint?.checks && prHint.checks.length > 0 ? ( {prHint?.checks && prHint.checks.length > 0 ? (
<> <>
<View style={styles.separator} /> <View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} theme={theme} /> <ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
</> </>
) : null} ) : null}
</Animated.View> </Animated.View>
@@ -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({ function ChecksSummaryContent({
checks, checks,
theme,
hovered, hovered,
}: { }: {
checks: NonNullable<PrHint["checks"]>; checks: NonNullable<PrHint["checks"]>;
theme: ReturnType<typeof useUnistyles>["theme"];
hovered: boolean; hovered: boolean;
}) { }) {
const failed = checks.filter((c) => c.status === "failure").length; const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length; const pending = checks.filter((c) => c.status === "pending").length;
let badgeColor: string;
let badgeLabel: string; let badgeLabel: string;
let dotStyle: StyleProp<ViewStyle>;
let statusTextStyle: StyleProp<TextStyle>;
if (failed > 0) { if (failed > 0) {
badgeColor = theme.colors.palette.red[500];
badgeLabel = `${failed} failed`; badgeLabel = `${failed} failed`;
dotStyle = styles.checksDotFailed;
statusTextStyle = styles.checksStatusTextFailed;
} else if (pending > 0) { } else if (pending > 0) {
badgeColor = theme.colors.palette.amber[500];
badgeLabel = `${pending} running`; badgeLabel = `${pending} running`;
dotStyle = styles.checksDotPending;
statusTextStyle = styles.checksStatusTextPending;
} else { } else {
badgeColor = theme.colors.palette.green[500];
badgeLabel = `${checks.length} passed`; badgeLabel = `${checks.length} passed`;
dotStyle = styles.checksDotPassed;
statusTextStyle = styles.checksStatusTextPassed;
} }
const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const labelStyle = useMemo( const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
() => [styles.checksSummaryLabel, hovered && styles.checksSummaryLabelHovered],
[hovered],
);
const dotStyle = useMemo(() => [styles.checksDot, { backgroundColor: badgeColor }], [badgeColor]);
const statusTextStyle = useMemo(
() => [styles.checksStatusText, { color: badgeColor }],
[badgeColor],
);
return ( return (
<> <>
{hovered ? ( {hovered ? (
<ExternalLink size={12} color={iconColor} /> <ThemedExternalLink size={12} uniProps={iconUniProps} />
) : ( ) : (
<GitHubIcon size={12} color={iconColor} /> <ThemedGitHubIcon size={12} uniProps={iconUniProps} />
)} )}
<Text style={labelStyle}>Checks</Text> <Text style={labelStyle}>Checks</Text>
<View style={styles.checksSummaryCounts}> <View style={styles.checksSummaryCounts}>
@@ -362,38 +370,32 @@ function ChecksSummaryContent({
function ChecksSummaryPressable({ function ChecksSummaryPressable({
checks, checks,
url, url,
theme,
}: { }: {
checks: NonNullable<PrHint["checks"]>; checks: NonNullable<PrHint["checks"]>;
url: string; url: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}) { }) {
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
void openExternalUrl(`${url}/checks`); void openExternalUrl(`${url}/checks`);
}, [url]); }, [url]);
const pressableStyle = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => [
styles.checksSummaryRow,
Boolean(hovered) && styles.listRowHovered,
],
[],
);
const renderChildren = useCallback( const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => ( ({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} theme={theme} hovered={Boolean(hovered)} /> <ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
), ),
[checks, theme], [checks],
); );
return ( return (
<Pressable style={pressableStyle} onPress={handlePress}> <Pressable style={checksSummaryPressableStyle} onPress={handlePress}>
{renderChildren} {renderChildren}
</Pressable> </Pressable>
); );
} }
function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) {
return [styles.checksSummaryRow, hovered && styles.listRowHovered];
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
portalOverlay: { portalOverlay: {
position: "absolute", position: "absolute",
@@ -467,13 +469,42 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
checksDot: { checksDotFailed: {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: 3, 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, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, 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,
];
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native"; 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 { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
@@ -140,7 +140,6 @@ function buildCreateAgentOptions({
} }
export function WorkspaceSetupDialog() { export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
@@ -349,10 +348,6 @@ export function WorkspaceSetupDialog() {
addImagesRef.current = addImages; addImagesRef.current = addImages;
}, []); }, []);
const composerInputWrapperStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const statusControlsWithDisabled = useMemo( const statusControlsWithDisabled = useMemo(
() => () =>
@@ -415,7 +410,7 @@ export function WorkspaceSetupDialog() {
autoFocus autoFocus
commandDraftConfig={composerState?.commandDraftConfig} commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled} statusControls={statusControlsWithDisabled}
inputWrapperStyle={composerInputWrapperStyle} inputWrapperStyle={styles.composerInputWrapper}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
/> />
</View> </View>
@@ -463,4 +458,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive, color: theme.colors.destructive,
lineHeight: 20, lineHeight: 20,
}, },
composerInputWrapper: {
backgroundColor: theme.colors.surface2,
},
})); }));
+1 -1
View File
@@ -108,7 +108,7 @@ vi.mock("react-native-unistyles", () => {
StyleSheet: { StyleSheet: {
create: createPanelTestStyles, create: createPanelTestStyles,
}, },
useUnistyles: () => ({ theme }), withUnistyles: <T,>(component: T) => component,
}; };
}); });
+16 -10
View File
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow"; import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -44,6 +44,7 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store"; import { type Agent, useSessionStore } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
@@ -125,9 +126,8 @@ function buildChatAgentFromState(
function renderChatAgentNonReadyView(args: { function renderChatAgentNonReadyView(args: {
viewState: AgentScreenViewState; viewState: AgentScreenViewState;
effectiveAgent: AgentScreenAgent | null; effectiveAgent: AgentScreenAgent | null;
theme: ReturnType<typeof useUnistyles>["theme"];
}): React.ReactElement | null { }): React.ReactElement | null {
const { viewState, effectiveAgent, theme } = args; const { viewState, effectiveAgent } = args;
if (viewState.tag === "not_found") { if (viewState.tag === "not_found") {
return ( return (
<View style={styles.container} testID="agent-not-found"> <View style={styles.container} testID="agent-not-found">
@@ -151,7 +151,7 @@ function renderChatAgentNonReadyView(args: {
return ( return (
<View style={styles.container} testID="agent-loading"> <View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View> </View>
</View> </View>
); );
@@ -419,7 +419,6 @@ function AgentPanelBody({
connectionStatus: HostRuntimeConnectionStatus; connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) { }) {
const { theme } = useUnistyles();
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const projectPlacement = useStoreWithEqualityFn( const projectPlacement = useStoreWithEqualityFn(
@@ -549,7 +548,7 @@ function AgentPanelBody({
return ( return (
<View style={styles.container} testID="agent-loading"> <View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View> </View>
</View> </View>
); );
@@ -585,7 +584,6 @@ function ChatAgentContent({
connectionStatus: HostRuntimeConnectionStatus; connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) { }) {
const { theme } = useUnistyles();
const panelToast = useToastHost(); const panelToast = useToastHost();
const { isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null); const streamViewRef = useRef<AgentStreamViewHandle>(null);
@@ -945,7 +943,6 @@ function ChatAgentContent({
const nonReadyView = renderChatAgentNonReadyView({ const nonReadyView = renderChatAgentNonReadyView({
viewState, viewState,
effectiveAgent, effectiveAgent,
theme,
}); });
if (nonReadyView) return nonReadyView; if (nonReadyView) return nonReadyView;
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); 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.status === "catching_up" &&
viewState.sync.ui === "overlay" ? ( viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay"> <View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View> </View>
) : null} ) : null}
@@ -1004,7 +1001,7 @@ function ChatAgentContent({
{isArchivingCurrentAgent ? ( {isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay"> <View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} /> <ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<Text style={styles.archivingTitle}>Archiving agent...</Text> <Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text> <Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View> </View>
@@ -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) => ({ const styles = StyleSheet.create((theme) => ({
root: { root: {
flex: 1, flex: 1,
+29 -14
View File
@@ -9,11 +9,12 @@ import {
View, View,
} from "react-native"; } from "react-native";
import invariant from "tiny-invariant"; 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 { Fonts } from "@/constants/theme";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import { import {
useWorkspaceSetupStore, useWorkspaceSetupStore,
type WorkspaceSetupSnapshot, type WorkspaceSetupSnapshot,
@@ -62,15 +63,13 @@ function useSetupPanelDescriptor(
type CommandStatus = "running" | "completed" | "failed"; type CommandStatus = "running" | "completed" | "failed";
function CommandStatusIcon({ status }: { status: CommandStatus }) { function CommandStatusIcon({ status }: { status: CommandStatus }) {
const { theme } = useUnistyles();
if (status === "running") { if (status === "running") {
return <ActivityIndicator size={14} color={theme.colors.foreground} />; return <ThemedActivityIndicator size={14} uniProps={foregroundColorMapping} />;
} }
if (status === "completed") { if (status === "completed") {
return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />; return <ThemedCheckCircle2 size={14} uniProps={greenColorMapping} />;
} }
return <CircleAlert size={14} color={theme.colors.palette.red[500]} />; return <ThemedCircleAlert size={14} uniProps={redColorMapping} />;
} }
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -151,7 +150,6 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
} }
function SetupPanel() { function SetupPanel() {
const { theme } = useUnistyles();
const { serverId, target } = usePaneContext(); const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target"); invariant(target.kind === "setup", "SetupPanel requires setup target");
@@ -231,7 +229,7 @@ function SetupPanel() {
{isWaiting ? ( {isWaiting ? (
<View style={styles.waitingContainer}> <View style={styles.waitingContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.waitingText}>Setting up workspace...</Text> <Text style={styles.waitingText}>Setting up workspace...</Text>
</View> </View>
) : null} ) : null}
@@ -269,7 +267,6 @@ function SetupPanel() {
hasError={rowState.hasError} hasError={rowState.hasError}
processedLog={rowState.processedLog} processedLog={rowState.processedLog}
errorMessage={snapshot?.error ?? null} errorMessage={snapshot?.error ?? null}
foregroundMutedColor={theme.colors.foregroundMuted}
onToggle={toggleExpanded} onToggle={toggleExpanded}
/> />
); );
@@ -292,7 +289,6 @@ interface SetupCommandRowProps {
hasError: boolean; hasError: boolean;
processedLog: string; processedLog: string;
errorMessage: string | null; errorMessage: string | null;
foregroundMutedColor: string;
onToggle: (index: number, isAutoExpanded: boolean) => void; onToggle: (index: number, isAutoExpanded: boolean) => void;
} }
@@ -305,7 +301,6 @@ function SetupCommandRow({
hasError, hasError,
processedLog, processedLog,
errorMessage, errorMessage,
foregroundMutedColor,
onToggle, onToggle,
}: SetupCommandRowProps) { }: SetupCommandRowProps) {
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
@@ -341,7 +336,7 @@ function SetupCommandRow({
{command.durationMs != null ? ( {command.durationMs != null ? (
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text> <Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
) : null} ) : null}
<SetupCommandChevron showDetail={showDetail} color={foregroundMutedColor} /> <SetupCommandChevron showDetail={showDetail} />
</Pressable> </Pressable>
{showDetail ? ( {showDetail ? (
<View style={styles.commandDetail}> <View style={styles.commandDetail}>
@@ -388,12 +383,14 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = {
useDescriptor: useSetupPanelDescriptor, useDescriptor: useSetupPanelDescriptor,
}; };
function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) { function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
const chevronStyle = useMemo( const chevronStyle = useMemo(
() => [styles.chevron, showDetail && styles.chevronExpanded], () => [styles.chevron, showDetail && styles.chevronExpanded],
[showDetail], [showDetail],
); );
return <ChevronRight size={14} color={color} style={chevronStyle} />; return (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} style={chevronStyle} />
);
} }
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { 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) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
@@ -28,7 +28,7 @@ import {
SquareTerminal, SquareTerminal,
X, X,
} from "lucide-react-native"; } 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 { SortableInlineList } from "@/components/sortable-inline-list";
import type { import type {
DraggableListDragHandleProps, DraggableListDragHandleProps,
@@ -58,39 +58,53 @@ import {
type WorkspaceTabMenuEntry, type WorkspaceTabMenuEntry,
} from "@/screens/workspace/workspace-tab-menu"; } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; 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) { function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
} }
function TabContextMenuItem({ function TabContextMenuItem({
entry, entry,
iconColor,
}: { }: {
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>; entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
iconColor: string;
}) { }) {
const leading = useMemo(() => { const leading = useMemo(() => {
switch (entry.icon) { switch (entry.icon) {
case "copy": case "copy":
return <Copy size={16} color={iconColor} />; return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
case "rotate-cw": case "rotate-cw":
return <RotateCw size={16} color={iconColor} />; return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
case "arrow-left-to-line": case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />; return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
case "arrow-right-to-line": case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />; return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
case "copy-x": case "copy-x":
return <CopyX size={16} color={iconColor} />; return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
case "x": case "x":
return <X size={16} color={iconColor} />; return <ThemedX size={16} uniProps={mutedColorMapping} />;
default: default:
return undefined; return undefined;
} }
}, [entry.icon, iconColor]); }, [entry.icon]);
const trailing = useMemo( const trailing = useMemo(
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined), () => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
[entry.hint], [entry.hint],
@@ -255,7 +269,6 @@ function TabChip({
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: DraggableListDragHandleProps | undefined; dragHandleProps: DraggableListDragHandleProps | undefined;
}) { }) {
const { theme } = useUnistyles();
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const middleClickRef = useMiddleClickClose( const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
@@ -392,21 +405,17 @@ function TabChip({
> >
{({ hovered: closeHovered, pressed }) => {({ hovered: closeHovered, pressed }) =>
isClosingTab ? ( isClosingTab ? (
<ActivityIndicator <ThemedActivityIndicator
size={12} size={12}
color={ uniProps={
closeHovered || pressed closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
? theme.colors.foreground
: theme.colors.foregroundMuted
} }
/> />
) : ( ) : (
<X <ThemedX
size={12} size={12}
color={ uniProps={
closeHovered || pressed closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
? theme.colors.foreground
: theme.colors.foregroundMuted
} }
/> />
) )
@@ -432,11 +441,7 @@ function TabChip({
entry.kind === "separator" ? ( entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} /> <ContextMenuSeparator key={entry.key} />
) : ( ) : (
<TabContextMenuItem <TabContextMenuItem key={entry.key} entry={entry} />
key={entry.key}
entry={entry}
iconColor={theme.colors.foregroundMuted}
/>
), ),
)} )}
</ContextMenuContent> </ContextMenuContent>
@@ -473,7 +478,6 @@ export function WorkspaceDesktopTabsRow({
tabDropPreviewIndex = null, tabDropPreviewIndex = null,
showPaneSplitActions = true, showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) { }: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTabKeys = useShortcutKeys("workspace-tab-new");
const newTerminalKeys = useShortcutKeys("workspace-terminal-new"); const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
@@ -499,11 +503,11 @@ export function WorkspaceDesktopTabsRow({
tabGap: 0, tabGap: 0,
maxTabWidth: 200, maxTabWidth: 200,
tabIconWidth: 14, tabIconWidth: 14,
tabHorizontalPadding: theme.spacing[3], tabHorizontalPadding: 12,
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 22,
}), }),
[tabsActionsWidth, theme.spacing], [tabsActionsWidth],
); );
const tabLabelLengths = useMemo( const tabLabelLengths = useMemo(
@@ -668,7 +672,7 @@ export function WorkspaceDesktopTabsRow({
accessibilityLabel="New agent tab" accessibilityLabel="New agent tab"
style={newTabActionButtonStyle} style={newTabActionButtonStyle}
> >
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedSquarePen size={14} uniProps={mutedColorMapping} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
@@ -690,7 +694,7 @@ export function WorkspaceDesktopTabsRow({
} }
style={newTerminalActionButtonStyle} style={newTerminalActionButtonStyle}
> >
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
@@ -712,7 +716,7 @@ export function WorkspaceDesktopTabsRow({
accessibilityLabel="Split pane right" accessibilityLabel="Split pane right"
style={newTabActionButtonStyle} style={newTabActionButtonStyle}
> >
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedColumns2 size={14} uniProps={mutedColorMapping} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
@@ -730,7 +734,7 @@ export function WorkspaceDesktopTabsRow({
accessibilityLabel="Split pane down" accessibilityLabel="Split pane down"
style={newTabActionButtonStyle} style={newTabActionButtonStyle}
> >
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedRows2 size={14} uniProps={mutedColorMapping} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
@@ -1,5 +1,4 @@
import { useMemo } from "react"; import { withUnistyles } from "react-native-unistyles";
import { useUnistyles } from "react-native-unistyles";
import { import {
Archive, Archive,
Download, Download,
@@ -11,6 +10,7 @@ import {
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions"; import { useGitActions } from "@/hooks/use-git-actions";
import type { Theme } from "@/styles/theme";
interface WorkspaceGitActionsProps { interface WorkspaceGitActionsProps {
serverId: string; serverId: string;
@@ -18,24 +18,31 @@ interface WorkspaceGitActionsProps {
hideLabels?: boolean; 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: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) { export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
const { theme } = useUnistyles(); const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS });
const icons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
merge: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
mergeFromBase: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
archive: <Archive size={16} color={theme.colors.foregroundMuted} />,
}),
[theme.colors.foregroundMuted],
);
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
if (!isGit) { if (!isGit) {
return null; return null;
@@ -8,7 +8,7 @@ import {
} from "react-native"; } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native"; 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 type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons"; import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import { import {
@@ -22,6 +22,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
interface WorkspaceOpenInEditorButtonProps { interface WorkspaceOpenInEditorButtonProps {
serverId: string; serverId: string;
@@ -33,18 +34,25 @@ interface EditorMenuItemProps {
editor: EditorTargetDescriptorPayload; editor: EditorTargetDescriptorPayload;
isPreferred: boolean; isPreferred: boolean;
onOpen: (editorId: EditorTargetId) => void; 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 handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
const leading = useMemo( const leading = useMemo(
() => <EditorAppIcon editorId={editor.id} size={16} color={foregroundMuted} />, () => <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
[editor.id, foregroundMuted], [editor.id],
); );
const trailing = useMemo( const trailing = useMemo(
() => (isPreferred ? <Check size={16} color={foregroundMuted} /> : undefined), () => (isPreferred ? <ThemedCheckIcon size={16} uniProps={mutedColorMapping} /> : undefined),
[isPreferred, foregroundMuted], [isPreferred],
); );
return ( return (
<DropdownMenuItem <DropdownMenuItem
@@ -63,7 +71,6 @@ export function WorkspaceOpenInEditorButton({
cwd, cwd,
hideLabels, hideLabels,
}: WorkspaceOpenInEditorButtonProps) { }: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -174,17 +181,17 @@ export function WorkspaceOpenInEditorButton({
accessibilityLabel={`Open workspace in ${primaryOption.label}`} accessibilityLabel={`Open workspace in ${primaryOption.label}`}
> >
{openMutation.isPending ? ( {openMutation.isPending ? (
<ActivityIndicator <ThemedActivityIndicator
size="small" size="small"
color={theme.colors.foreground} uniProps={foregroundColorMapping}
style={styles.splitButtonSpinnerOnly} style={styles.splitButtonSpinnerOnly}
/> />
) : ( ) : (
<View style={styles.splitButtonContent}> <View style={styles.splitButtonContent}>
<EditorAppIcon <ThemedEditorAppIcon
editorId={primaryOption.id} editorId={primaryOption.id}
size={16} size={16}
color={theme.colors.foregroundMuted} uniProps={mutedColorMapping}
/> />
{!hideLabels && <Text style={styles.splitButtonText}>Open</Text>} {!hideLabels && <Text style={styles.splitButtonText}>Open</Text>}
</View> </View>
@@ -198,7 +205,7 @@ export function WorkspaceOpenInEditorButton({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Choose editor" accessibilityLabel="Choose editor"
> >
<ChevronDown size={16} color={theme.colors.foregroundMuted} /> <ThemedChevronDown size={16} uniProps={mutedColorMapping} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
@@ -212,7 +219,6 @@ export function WorkspaceOpenInEditorButton({
editor={editor} editor={editor}
isPreferred={editor.id === effectivePreferredEditorId} isPreferred={editor.id === effectivePreferredEditorId}
onOpen={handleOpenEditor} onOpen={handleOpenEditor}
foregroundMuted={theme.colors.foregroundMuted}
/> />
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -22,7 +22,8 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
@@ -135,6 +136,32 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>(); const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>(); const EMPTY_SET = new Set<string>();
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 = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
interface WorkspaceScreenProps { interface WorkspaceScreenProps {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -298,14 +325,10 @@ function MobileTabTrailingAccessory({
menuTestIDBase, menuTestIDBase,
presentationLabel, presentationLabel,
menuEntries, menuEntries,
iconColor,
iconSizeSm,
}: { }: {
menuTestIDBase: string; menuTestIDBase: string;
presentationLabel: string; presentationLabel: string;
menuEntries: WorkspaceTabMenuEntry[]; menuEntries: WorkspaceTabMenuEntry[];
iconColor: string;
iconSizeSm: number;
}) { }) {
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -316,14 +339,14 @@ function MobileTabTrailingAccessory({
hitSlop={8} hitSlop={8}
style={mobileTabMenuTriggerStyle} style={mobileTabMenuTriggerStyle}
> >
<Ellipsis size={iconSizeSm} color={iconColor} /> <ThemedEllipsis size={14} uniProps={mutedColorMapping} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}> <DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
{menuEntries.map((entry) => {menuEntries.map((entry) =>
entry.kind === "separator" ? ( entry.kind === "separator" ? (
<DropdownMenuSeparator key={entry.key} /> <DropdownMenuSeparator key={entry.key} />
) : ( ) : (
<MobileTabDropdownMenuItem key={entry.key} entry={entry} iconColor={iconColor} /> <MobileTabDropdownMenuItem key={entry.key} entry={entry} />
), ),
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@@ -333,29 +356,27 @@ function MobileTabTrailingAccessory({
function MobileTabDropdownMenuItem({ function MobileTabDropdownMenuItem({
entry, entry,
iconColor,
}: { }: {
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>; entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
iconColor: string;
}) { }) {
const leading = useMemo(() => { const leading = useMemo(() => {
switch (entry.icon) { switch (entry.icon) {
case "copy": case "copy":
return <Copy size={16} color={iconColor} />; return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
case "rotate-cw": case "rotate-cw":
return <RotateCw size={16} color={iconColor} />; return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
case "arrow-left-to-line": case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />; return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
case "arrow-right-to-line": case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />; return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
case "copy-x": case "copy-x":
return <CopyX size={16} color={iconColor} />; return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
case "x": case "x":
return <X size={16} color={iconColor} />; return <ThemedX size={16} uniProps={mutedColorMapping} />;
default: default:
return undefined; return undefined;
} }
}, [entry.icon, iconColor]); }, [entry.icon]);
const trailing = useMemo( const trailing = useMemo(
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined), () => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
[entry.hint], [entry.hint],
@@ -408,7 +429,6 @@ function MobileWorkspaceTabOption({
onCloseTabsBelow: (tabId: string) => Promise<void> | void; onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) { }) {
const { theme } = useUnistyles();
const menuTestIDBase = `workspace-tab-menu-${tab.key}`; const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
const menuEntries = buildWorkspaceTabMenuEntries({ const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile", surface: "mobile",
@@ -425,9 +445,6 @@ function MobileWorkspaceTabOption({
onCloseOtherTabs, onCloseOtherTabs,
}); });
const iconColor = theme.colors.foregroundMuted;
const iconSizeSm = theme.iconSize.sm;
const fallbackLabel = getFallbackTabOptionLabel(tab); const fallbackLabel = getFallbackTabOptionLabel(tab);
const trailingAccessory = useMemo( const trailingAccessory = useMemo(
() => ( () => (
@@ -435,11 +452,9 @@ function MobileWorkspaceTabOption({
menuTestIDBase={menuTestIDBase} menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel} presentationLabel={fallbackLabel}
menuEntries={menuEntries} menuEntries={menuEntries}
iconColor={iconColor}
iconSizeSm={iconSizeSm}
/> />
), ),
[menuTestIDBase, fallbackLabel, menuEntries, iconColor, iconSizeSm], [menuTestIDBase, fallbackLabel, menuEntries],
); );
const renderPresentation = useCallback( const renderPresentation = useCallback(
@@ -483,7 +498,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCloseTabsBelow, onCloseTabsBelow,
onCloseOtherTabs, onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) { }: MobileWorkspaceTabSwitcherProps) {
const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => { const tabIndexByKey = useMemo(() => {
@@ -572,7 +586,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
/> />
</View> </View>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable> </Pressable>
<Combobox <Combobox
@@ -730,14 +744,9 @@ function WorkspaceHeaderMenuTriggerIcon({
open: boolean; open: boolean;
isMobile: boolean; isMobile: boolean;
}) { }) {
const { theme } = useUnistyles(); const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
const Icon = isMobile ? EllipsisVertical : Ellipsis; const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
return ( return <Icon size={16} uniProps={colorMapping} />;
<Icon
size={theme.iconSize.md}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
);
} }
function WorkspaceHeaderMenu({ function WorkspaceHeaderMenu({
@@ -939,7 +948,6 @@ interface RenderWorkspaceContentInput {
paneId: string | null; paneId: string | null;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel; }) => WorkspacePaneContentModel;
foregroundMutedColor: string;
} }
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode { function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
@@ -953,13 +961,12 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
isRouteFocused, isRouteFocused,
focusedPaneId, focusedPaneId,
buildMobilePaneContentModel, buildMobilePaneContentModel,
foregroundMutedColor,
} = input; } = input;
if (showMissingWorkspaceDescriptor) { if (showMissingWorkspaceDescriptor) {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<ActivityIndicator color={foregroundMutedColor} /> <ThemedActivityIndicator uniProps={mutedColorMapping} />
</View> </View>
); );
} }
@@ -975,7 +982,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
if (!activeTabDescriptor && !hasHydratedAgents) { if (!activeTabDescriptor && !hasHydratedAgents) {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<ActivityIndicator color={foregroundMutedColor} /> <ThemedActivityIndicator uniProps={mutedColorMapping} />
</View> </View>
); );
} }
@@ -1115,9 +1122,7 @@ function WorkspaceScreenContent({
workspaceId, workspaceId,
isRouteFocused, isRouteFocused,
}: WorkspaceScreenContentProps) { }: WorkspaceScreenContentProps) {
const { theme } = useUnistyles();
const _insets = useSafeAreaInsets(); const _insets = useSafeAreaInsets();
const mainBackgroundColor = theme.colors.surfaceWorkspace;
const toast = useToast(); const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
@@ -2562,7 +2567,6 @@ function WorkspaceScreenContent({
isRouteFocused, isRouteFocused,
focusedPaneId, focusedPaneId,
buildMobilePaneContentModel, buildMobilePaneContentModel,
foregroundMutedColor: theme.colors.foregroundMuted,
}); });
const buildDesktopPaneContentModel = useCallback( const buildDesktopPaneContentModel = useCallback(
@@ -2659,27 +2663,12 @@ function WorkspaceScreenContent({
); );
}, []); }, []);
const containerStyle = useMemo( const containerStyle = containerWithWorkspaceBackgroundStyle;
() => [styles.container, { backgroundColor: mainBackgroundColor }],
[mainBackgroundColor],
);
const menuNewAgentIcon = useMemo( const menuNewAgentIcon = MENU_NEW_AGENT_ICON;
() => <SquarePen size={16} color={theme.colors.foregroundMuted} />, const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
[theme.colors.foregroundMuted], const menuCopyIcon = MENU_COPY_ICON;
); const menuSettingsIcon = MENU_SETTINGS_ICON;
const menuNewTerminalIcon = useMemo(
() => <SquareTerminal size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const menuCopyIcon = useMemo(
() => <Copy size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const menuSettingsIcon = useMemo(
() => <Settings size={16} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const headerRight = useMemo( const headerRight = useMemo(
() => ( () => (
@@ -2721,12 +2710,10 @@ function WorkspaceScreenContent({
> >
{({ hovered, pressed }) => { {({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed; const active = isExplorerOpen || hovered || pressed;
const iconColor = active const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
? theme.colors.foreground
: theme.colors.foregroundMuted;
return ( return (
<> <>
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} /> <ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? ( {workspaceDescriptor?.diffStat ? (
<DiffStat <DiffStat
additions={workspaceDescriptor.diffStat.additions} additions={workspaceDescriptor.diffStat.additions}
@@ -2766,9 +2753,9 @@ function WorkspaceScreenContent({
accessibilityState={explorerToggleAccessibilityState} accessibilityState={explorerToggleAccessibilityState}
> >
{({ hovered }) => { {({ hovered }) => {
const color = const colorMapping =
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted; isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
return <PanelRight size={theme.iconSize.md} color={color} />; return <ThemedPanelRight size={16} uniProps={colorMapping} />;
}} }}
</HeaderToggleButton> </HeaderToggleButton>
) : null} ) : null}
@@ -2786,12 +2773,16 @@ function WorkspaceScreenContent({
accessibilityState={explorerToggleAccessibilityState} accessibilityState={explorerToggleAccessibilityState}
> >
{({ hovered }) => { {({ hovered }) => {
const color = const colorMapping =
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted; isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
return isGitCheckout ? ( return isGitCheckout ? (
<SourceControlPanelIcon size={theme.iconSize.lg} color={color} strokeWidth={1.5} /> <ThemedSourceControlPanelIcon
size={20}
uniProps={colorMapping}
{...sourceControlPanelStrokeWidth15}
/>
) : ( ) : (
<PanelRight size={theme.iconSize.lg} color={color} /> <ThemedPanelRight size={20} uniProps={colorMapping} />
); );
}} }}
</HeaderToggleButton> </HeaderToggleButton>
@@ -2812,10 +2803,6 @@ function WorkspaceScreenContent({
isExplorerOpen, isExplorerOpen,
explorerToggleAccessibilityState, explorerToggleAccessibilityState,
explorerToggleStyle, explorerToggleStyle,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.iconSize.md,
theme.iconSize.lg,
], ],
); );
@@ -3039,6 +3026,9 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
containerWorkspaceBackground: {
backgroundColor: theme.colors.surfaceWorkspace,
},
threePaneRow: { threePaneRow: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
@@ -3333,4 +3323,9 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
const containerWithWorkspaceBackgroundStyle = [
styles.container,
styles.containerWorkspaceBackground,
];
const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
@@ -43,7 +43,17 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: { StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
}, },
useUnistyles: () => ({ theme }), withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({
uniProps,
...rest
}: {
uniProps?: (theme: unknown) => Record<string, unknown>;
} & Record<string, unknown>) => {
const themed = uniProps ? uniProps(theme) : {};
return React.createElement(Component, { ...rest, ...themed });
},
})); }));
vi.mock("@/constants/platform", () => ({ vi.mock("@/constants/platform", () => ({
@@ -3,7 +3,7 @@ import type { GestureResponderEvent } from "react-native";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native"; 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 type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
@@ -17,6 +17,7 @@ import { useToast } from "@/contexts/toast-context";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
import type { Theme } from "@/styles/theme";
type ScriptActionIcon = "start" | "view"; type ScriptActionIcon = "start" | "view";
@@ -39,28 +40,48 @@ interface ScriptActionButtonProps {
testID: string; 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 { interface ScriptActionButtonChildrenProps {
hovered?: boolean; hovered?: boolean;
icon: ScriptActionIcon; icon: ScriptActionIcon;
label: string; label: string;
theme: ReturnType<typeof useUnistyles>["theme"];
} }
function ScriptActionButtonChildren({ function ScriptActionButtonChildren({
hovered, hovered,
icon, icon,
label, label,
theme,
}: ScriptActionButtonChildrenProps): ReactElement { }: ScriptActionButtonChildrenProps): ReactElement {
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
const iconProps = { size: 10, color };
const iconElement = const iconElement =
icon === "view" ? ( icon === "view" ? (
<SquareTerminal {...iconProps} /> <ThemedSquareTerminal size={10} uniProps={colorMapping} />
) : ( ) : (
<Play {...iconProps} fill="transparent" /> <ThemedPlay size={10} uniProps={colorMapping} {...playFillTransparent} />
); );
const labelStyle = useMemo(() => [styles.actionButtonLabel, { color }], [color]); const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel;
return ( return (
<> <>
{iconElement} {iconElement}
@@ -77,8 +98,6 @@ function ScriptActionButton({
onPress, onPress,
testID, testID,
}: ScriptActionButtonProps): ReactElement { }: ScriptActionButtonProps): ReactElement {
const { theme } = useUnistyles();
const handlePress = useCallback( const handlePress = useCallback(
(event: GestureResponderEvent) => { (event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -89,9 +108,9 @@ function ScriptActionButton({
const renderChildren = useCallback( const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => ( ({ hovered }: { hovered?: boolean }) => (
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} theme={theme} /> <ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} />
), ),
[icon, label, theme], [icon, label],
); );
return ( return (
@@ -123,32 +142,26 @@ interface HostLinkChildrenProps {
hovered?: boolean; hovered?: boolean;
disabled: boolean; disabled: boolean;
label: string; label: string;
theme: ReturnType<typeof useUnistyles>["theme"];
} }
function HostLinkChildren({ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement {
hovered,
disabled,
label,
theme,
}: HostLinkChildrenProps): ReactElement {
const showIcon = !disabled && (hovered || isNative); const showIcon = !disabled && (hovered || isNative);
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted; const isActive = Boolean(hovered) && !disabled;
const hostLabelStyle = useMemo(() => [styles.hostLabel, { color }], [color]); const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping;
const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel;
return ( return (
<> <>
<Text style={hostLabelStyle} numberOfLines={1}> <Text style={hostLabelStyle} numberOfLines={1}>
{label} {label}
</Text> </Text>
<View style={styles.hostIconSlot}> <View style={styles.hostIconSlot}>
{showIcon ? <ExternalLink size={10} color={color} /> : null} {showIcon ? <ThemedExternalLink size={10} uniProps={colorMapping} /> : null}
</View> </View>
</> </>
); );
} }
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const { theme } = useUnistyles();
const disabled = !url; const disabled = !url;
const handlePress = useCallback( const handlePress = useCallback(
@@ -161,9 +174,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const renderChildren = useCallback( const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => ( ({ hovered }: { hovered?: boolean }) => (
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} theme={theme} /> <HostLinkChildren hovered={hovered} disabled={disabled} label={label} />
), ),
[disabled, label, theme], [disabled, label],
); );
return ( return (
@@ -181,9 +194,7 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
} }
function ExitCodeBadge({ code }: { code: number }): ReactElement { function ExitCodeBadge({ code }: { code: number }): ReactElement {
const { theme } = useUnistyles(); const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle;
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
const exitTextStyle = useMemo(() => [styles.exitBadgeText, { color }], [color]);
return ( return (
<View style={styles.exitBadge}> <View style={styles.exitBadge}>
<Text style={exitTextStyle}>exit {code}</Text> <Text style={exitTextStyle}>exit {code}</Text>
@@ -206,26 +217,24 @@ interface ScriptRowProps {
: null : null
: null; : null;
isStartPending: boolean; isStartPending: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
onStartScript: (scriptName: string) => void; onStartScript: (scriptName: string) => void;
onViewTerminal?: (terminalId: string) => void; onViewTerminal?: (terminalId: string) => void;
} }
function resolveScriptIconColor(args: { function resolveScriptIconColorMapping(args: {
theme: ReturnType<typeof useUnistyles>["theme"];
script: WorkspaceDescriptor["scripts"][number]; script: WorkspaceDescriptor["scripts"][number];
isService: boolean; isService: boolean;
isRunning: boolean; isRunning: boolean;
}): string { }): (theme: Theme) => { color: string } {
const { theme, script, isService, isRunning } = args; const { script, isService, isRunning } = args;
if (isService) { if (isService) {
if (isRunning && script.health === "healthy") return theme.colors.palette.green[500]; if (isRunning && script.health === "healthy") return greenColorMapping;
if (isRunning && script.health === "unhealthy") return theme.colors.palette.red[500]; if (isRunning && script.health === "unhealthy") return redColorMapping;
if (isRunning) return theme.colors.palette.blue[500]; if (isRunning) return blueColorMapping;
return theme.colors.foregroundMuted; return mutedColorMapping;
} }
if (isRunning) return theme.colors.palette.blue[500]; if (isRunning) return blueColorMapping;
return theme.colors.foregroundMuted; return mutedColorMapping;
} }
function ScriptRow({ function ScriptRow({
@@ -233,7 +242,6 @@ function ScriptRow({
liveTerminalIdSet, liveTerminalIdSet,
activeConnection, activeConnection,
isStartPending, isStartPending,
theme,
onStartScript, onStartScript,
onViewTerminal, onViewTerminal,
}: ScriptRowProps): ReactElement { }: ScriptRowProps): ReactElement {
@@ -268,9 +276,8 @@ function ScriptRow({
} }
} }
const iconColor = resolveScriptIconColor({ theme, script, isService, isRunning }); const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning });
const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal;
const ScriptIcon = isService ? Globe : SquareTerminal;
const showExitBadge = !isRunning && exitCode !== null; const showExitBadge = !isRunning && exitCode !== null;
const handleView = useCallback(() => { const handleView = useCallback(() => {
@@ -282,13 +289,8 @@ function ScriptRow({
}, [onStartScript, script.scriptName]); }, [onStartScript, script.scriptName]);
const scriptNameStyle = useMemo( const scriptNameStyle = useMemo(
() => [ () => (isRunning ? scriptNameActiveStyle : styles.scriptName),
styles.scriptName, [isRunning],
{
color: isRunning ? theme.colors.foreground : theme.colors.foregroundMuted,
},
],
[isRunning, theme.colors.foreground, theme.colors.foregroundMuted],
); );
let primaryAction: ReactElement | null = null; let primaryAction: ReactElement | null = null;
@@ -322,7 +324,7 @@ function ScriptRow({
style={styles.scriptItem} style={styles.scriptItem}
> >
<View style={styles.scriptHeader}> <View style={styles.scriptHeader}>
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} /> <ScriptIcon size={14} uniProps={iconColorMapping} style={styles.scriptIcon} />
<Text style={scriptNameStyle} numberOfLines={1}> <Text style={scriptNameStyle} numberOfLines={1}>
{script.scriptName} {script.scriptName}
</Text> </Text>
@@ -355,7 +357,6 @@ export function WorkspaceScriptsButton({
onViewTerminal, onViewTerminal,
hideLabels, hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null { }: WorkspaceScriptsButtonProps): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
@@ -402,6 +403,7 @@ export function WorkspaceScriptsButton({
} }
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running"); const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping;
return ( return (
<View style={styles.row}> <View style={styles.row}>
@@ -414,15 +416,9 @@ export function WorkspaceScriptsButton({
accessibilityLabel="Workspace scripts" accessibilityLabel="Workspace scripts"
> >
<View style={styles.splitButtonContent}> <View style={styles.splitButtonContent}>
<Play <ThemedPlay size={14} uniProps={triggerPlayMapping} {...playFillTransparent} />
size={14}
color={
hasAnyRunning ? theme.colors.palette.blue[500] : theme.colors.foregroundMuted
}
fill="transparent"
/>
{!hideLabels && <Text style={styles.splitButtonText}>Scripts</Text>} {!hideLabels && <Text style={styles.splitButtonText}>Scripts</Text>}
<ChevronDown size={14} color={theme.colors.foregroundMuted} /> <ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</View> </View>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -440,7 +436,6 @@ export function WorkspaceScriptsButton({
liveTerminalIdSet={liveTerminalIdSet} liveTerminalIdSet={liveTerminalIdSet}
activeConnection={activeConnection} activeConnection={activeConnection}
isStartPending={startScriptMutation.isPending} isStartPending={startScriptMutation.isPending}
theme={theme}
onStartScript={handleStartScript} onStartScript={handleStartScript}
onViewTerminal={onViewTerminal} onViewTerminal={onViewTerminal}
/> />
@@ -511,6 +506,10 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18, lineHeight: 18,
flexShrink: 1, flexShrink: 1,
minWidth: 0, minWidth: 0,
color: theme.colors.foregroundMuted,
},
scriptNameActive: {
color: theme.colors.foreground,
}, },
spacer: { spacer: {
flex: 1, flex: 1,
@@ -531,6 +530,10 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1, flexShrink: 1,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
lineHeight: 14, lineHeight: 14,
color: theme.colors.foregroundMuted,
},
hostLabelActive: {
color: theme.colors.foreground,
}, },
hostIconSlot: { hostIconSlot: {
width: 10, width: 10,
@@ -549,6 +552,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: 10, fontSize: 10,
lineHeight: 12, lineHeight: 12,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
exitBadgeTextError: {
color: theme.colors.palette.red[300],
}, },
actionButton: { actionButton: {
flexDirection: "row", flexDirection: "row",
@@ -558,5 +565,14 @@ const styles = StyleSheet.create((theme) => ({
actionButtonLabel: { actionButtonLabel: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, 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];
@@ -1,15 +1,16 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Check } from "lucide-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 invariant from "tiny-invariant";
import { SyncedLoader } from "@/components/synced-loader"; import { SyncedLoader } from "@/components/synced-loader";
import { ensurePanelsRegistered } from "@/panels/register-panels"; import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry"; import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; 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 { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import type { Theme } from "@/styles/theme";
export interface WorkspaceTabPresentation { export interface WorkspaceTabPresentation {
key: string; key: string;
@@ -103,22 +104,22 @@ interface WorkspaceTabIconProps {
statusDotBorderColor?: string; statusDotBorderColor?: string;
} }
const ThemedCheckIcon = withUnistyles(Check);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function WorkspaceTabIcon({ export function WorkspaceTabIcon({
presentation, presentation,
active = false, active = false,
size = 14, size = 14,
statusDotBorderColor, statusDotBorderColor,
}: WorkspaceTabIconProps): ReactElement { }: WorkspaceTabIconProps): ReactElement {
const { theme } = useUnistyles(); const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted; const bucket = presentation.statusBucket;
const statusDotColor = let statusDotColor: string | null = null;
presentation.statusBucket === null if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color;
? null else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
: getStatusDotColor({ else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
theme, else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
bucket: presentation.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket) const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE ? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE; : DEFAULT_STATUS_DOT_SIZE;
@@ -139,27 +140,20 @@ export function WorkspaceTabIcon({
styles.statusDot, styles.statusDot,
{ {
backgroundColor: statusDotColor ?? undefined, backgroundColor: statusDotColor ?? undefined,
borderColor: statusDotBorderColor ?? theme.colors.surface0, borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor,
width: statusDotSize, width: statusDotSize,
height: statusDotSize, height: statusDotSize,
right: statusDotOffset, right: statusDotOffset,
bottom: statusDotOffset, bottom: statusDotOffset,
}, },
], ],
[statusDotColor, statusDotBorderColor, theme.colors.surface0, statusDotSize, statusDotOffset], [statusDotColor, statusDotBorderColor, statusDotSize, statusDotOffset],
); );
if (shouldShowLoader) { if (shouldShowLoader) {
return ( return (
<View style={agentIconWrapperStyle}> <View style={agentIconWrapperStyle}>
<SyncedLoader <SyncedLoader size={size - 1} color={styles.syncedLoader.color} />
size={size - 1}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View> </View>
); );
} }
@@ -187,7 +181,6 @@ export function WorkspaceTabOptionRow({
onPress, onPress,
trailingAccessory, trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement { }: WorkspaceTabOptionRowProps): ReactElement {
const { theme } = useUnistyles();
const pressableStyle = useCallback( const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable, styles.optionMainPressable,
@@ -213,7 +206,7 @@ export function WorkspaceTabOptionRow({
</Pressable> </Pressable>
{selected ? ( {selected ? (
<View style={styles.optionTrailingSlot}> <View style={styles.optionTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} /> <ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
</View> </View>
) : null} ) : null}
{trailingAccessory ? ( {trailingAccessory ? (
@@ -238,6 +231,33 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
borderWidth: 1, 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: { optionRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
+76 -67
View File
@@ -412,74 +412,83 @@ const ghosttyDarkColors = buildDarkSemanticColors({
destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces 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 = { const commonTheme = {
spacing: { spacing: SPACING,
0: 0, fontSize: FONT_SIZE,
1: 4, lineHeight: LINE_HEIGHT,
1.5: 6, iconSize: ICON_SIZE,
2: 8, fontWeight: FONT_WEIGHT,
3: 12, borderRadius: BORDER_RADIUS,
4: 16, borderWidth: BORDER_WIDTH,
6: 24, opacity: OPACITY,
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,
},
} as const; } as const;
const darkShadow = { const darkShadow = {