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
@@ -18,7 +18,7 @@ import {
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -84,6 +84,7 @@ import {
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -156,7 +157,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -303,9 +303,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
const looseGap = theme.spacing[4]; // 16px
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
@@ -891,16 +891,33 @@ function ToolCallSlot({
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 {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
isPrimary: boolean;
Icon: typeof ThemedCheckIcon;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
@@ -908,37 +925,21 @@ function PermissionActionButton({
action,
isRespondingAction,
isResponding,
textColor,
iconColor,
isDanger,
isPrimary,
Icon,
testID,
theme,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger],
);
const optionTextStyle = useMemo(
() => [permissionStyles.optionText, { color: textColor }],
[textColor],
);
const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Icon size={14} uniProps={colorMapping} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
@@ -953,7 +954,6 @@ function PermissionRequestCard({
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
@@ -1069,10 +1069,6 @@ function PermissionRequestCard({
[handleResponse],
);
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
@@ -1080,24 +1076,6 @@ function PermissionRequestCard({
],
[isMobile],
);
const cardContainerStyle = useMemo(
() => [
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const cardTitleStyle = useMemo(
() => [permissionStyles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cardDescriptionStyle = useMemo(
() => [permissionStyles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (request.kind === "question") {
return (
@@ -1111,18 +1089,15 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={questionTextStyle}>
<Text testID="permission-request-question" style={permissionStyles.question}>
How would you like to proceed?
</Text>
<View style={optionsContainerStyle}>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
@@ -1135,12 +1110,9 @@ function PermissionRequestCard({
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
isPrimary={isPrimary}
Icon={Icon}
testID={testID}
theme={theme}
onPress={handleActionPress}
/>
);
@@ -1162,10 +1134,10 @@ function PermissionRequestCard({
}
return (
<View style={cardContainerStyle}>
<Text style={cardTitleStyle}>{title}</Text>
<View style={permissionStyles.container}>
<Text style={permissionStyles.title}>{title}</Text>
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null}
{description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
@@ -1311,14 +1283,18 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[2],
@@ -1330,6 +1306,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
},
optionsContainer: {
gap: theme.spacing[2],
@@ -1346,6 +1323,11 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
alignItems: "center",
borderWidth: theme.borderWidth[1],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
optionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
optionButtonPressed: {
opacity: 0.9,
@@ -1358,9 +1340,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
optionText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
optionTextPrimary: {
color: theme.colors.foreground,
},
}));
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
@@ -188,6 +188,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
+28 -26
View File
@@ -7,7 +7,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import {
@@ -40,7 +40,7 @@ import {
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
@@ -92,8 +92,8 @@ type AttachmentListUpdater =
function noop() {}
function resolveComposerButtonIconSize(theme: Theme): number {
return isWeb ? theme.iconSize.md : theme.iconSize.lg;
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
}
function resolveIsComposerLocked(
@@ -585,7 +585,6 @@ interface QueuedMessageRowProps {
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
const { theme } = useUnistyles();
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
@@ -599,10 +598,10 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
</Text>
<View style={styles.queueActions}>
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable>
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
<ArrowUp size={theme.iconSize.sm} color="white" />
<ArrowUp size={ICON_SIZE.sm} color="white" />
</Pressable>
</View>
</View>
@@ -668,7 +667,6 @@ function GithubAttachmentPill({
onOpen,
onRemove,
}: GithubAttachmentPillProps) {
const { theme } = useUnistyles();
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => {
@@ -689,9 +687,9 @@ function GithubAttachmentPill({
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{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>
<Text style={styles.githubPillText} numberOfLines={1}>
@@ -719,18 +717,17 @@ function GithubPickerOption({
item,
onToggle,
}: GithubPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
<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 (
<ComboboxItem
@@ -872,7 +869,6 @@ interface ComposerVoiceModeButtonProps {
realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
theme: Theme;
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
}
@@ -911,7 +907,6 @@ function ComposerVoiceModeButton({
isConnected,
isVoiceSwitching,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? (
@@ -922,10 +917,10 @@ function ComposerVoiceModeButton({
if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />;
}
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return <AudioLines size={buttonIconSize} color={color} />;
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
},
[buttonIconSize, isVoiceSwitching, theme.colors.foreground, theme.colors.foregroundMuted],
[buttonIconSize, isVoiceSwitching],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -978,8 +973,7 @@ export function Composer({
inputWrapperStyle,
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = resolveComposerButtonIconSize(theme);
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1430,7 +1424,6 @@ export function Composer({
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitchingValue}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
theme={theme}
voiceToggleKeys={voiceToggleKeys}
cancelButton={cancelButton}
/>
@@ -1447,7 +1440,6 @@ export function Composer({
isVoiceModeForAgent,
isVoiceSwitchingValue,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
],
);
@@ -1491,7 +1483,7 @@ export function Composer({
{
id: "image",
label: "Add image",
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
},
@@ -1499,13 +1491,13 @@ export function Composer({
{
id: "github",
label: "Add issue or PR",
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
[handlePickImage],
);
const handleToggleGithubItem = useCallback(
@@ -1860,3 +1852,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
})) as unknown as Record<string, object>;
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: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
withUnistyles: <T,>(component: T) => component,
}));
vi.mock("@/constants/platform", () => ({
+23 -30
View File
@@ -18,8 +18,8 @@ import {
useMemo,
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
@@ -195,18 +195,15 @@ function AttachButtonIcon({
hovered,
onAttachButtonRef,
buttonIconSize,
foreground,
foregroundMuted,
}: {
hovered: boolean;
onAttachButtonRef: ((node: View | null) => void) | undefined;
buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) {
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return (
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
<Plus size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />
<ThemedPlus size={buttonIconSize} uniProps={colorMapping} />
</View>
);
}
@@ -278,23 +275,20 @@ function VoiceButtonIcon({
isDictating,
isMutedRealtime,
buttonIconSize,
foreground,
foregroundMuted,
}: {
hovered: boolean;
isDictating: boolean;
isMutedRealtime: boolean;
buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) {
if (isDictating) {
return <Square size={buttonIconSize} color="white" fill="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
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"]>;
@@ -1365,8 +1359,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onHeightChange,
inputWrapperStyle,
} = resolveMessageInputProps(props);
const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId);
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
@@ -1892,11 +1885,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
hovered={Boolean(hovered)}
onAttachButtonRef={onAttachButtonRef}
buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[onAttachButtonRef, buttonIconSize, theme.colors.foreground, theme.colors.foregroundMuted],
[onAttachButtonRef, buttonIconSize],
);
const renderVoiceButtonIcon = useCallback(
@@ -1906,18 +1897,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictating={isDictating}
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[
isDictating,
isRealtimeVoiceForCurrentAgent,
voice?.isMuted,
buttonIconSize,
theme.colors.foreground,
theme.colors.foregroundMuted,
],
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
);
return (
@@ -1926,12 +1908,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<TextInput
<ThemedTextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel="Message agent..."
onFocus={handleInputFocus}
onBlur={handleInputBlur}
@@ -2159,3 +2141,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
bottom: 0,
},
})) 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,
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -102,6 +103,29 @@ export type { InlinePathTarget } from "@/utils/inline-path";
type MarkdownStyles = Record<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 {
message: string;
images?: UserMessageImageAttachment[];
@@ -154,6 +178,26 @@ interface 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 = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% {
@@ -381,7 +425,13 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const { theme } = useUnistyles();
useMessageRenderTracker("UserMessage", `${(message ?? "").slice(0, 16)}`, {
message,
images,
isFirstInGroup,
isLastInGroup,
disableOuterSpacing,
});
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
@@ -398,12 +448,12 @@ export const UserMessage = memo(function UserMessage({
() => [
userMessageStylesheet.container,
!resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null,
isLastInGroup ? userMessageStylesheet.containerLastInGroup : null,
!isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null,
],
],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup, theme.spacing],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup],
);
const imagePreviewContainerStyle = useMemo(
() => [
@@ -628,7 +678,6 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { theme } = useUnistyles();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
@@ -636,10 +685,10 @@ function AssistantMarkdownImage({
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
marginTop: hasLeadingContent ? 16 : 0,
marginBottom: 0,
}),
[hasLeadingContent, theme],
[hasLeadingContent],
);
const query = useQuery({
@@ -1224,7 +1273,6 @@ function AssistantMessageBlockContainer({
interface MemoizedMarkdownBlockProps {
text: string;
styles: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
@@ -1232,14 +1280,13 @@ interface MemoizedMarkdownBlockProps {
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
text,
styles,
rules,
parser,
onLinkPress,
}: MemoizedMarkdownBlockProps) {
return (
<MarkdownWithStableRenderer
style={styles}
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
@@ -1247,7 +1294,7 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</MarkdownWithStableRenderer>
</ThemedMarkdown>
);
});
@@ -1333,13 +1380,23 @@ export const AssistantMessage = memo(function AssistantMessage({
disableOuterSpacing,
spacing = "default",
}: AssistantMessageProps) {
const { theme } = useUnistyles();
useMessageRenderTracker(
"AssistantMessage",
`${serverId ?? "?"}/${(message ?? "").slice(0, 16)}`,
{
message,
onInlinePathPress,
workspaceRoot,
serverId,
client,
disableOuterSpacing,
spacing,
},
);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default",
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
@@ -1602,11 +1659,10 @@ export const AssistantMessage = memo(function AssistantMessage({
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? theme.spacing[3] : 0}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
>
<MemoizedMarkdownBlock
text={block}
styles={markdownStyles}
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleLinkPress}
@@ -1656,7 +1712,6 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp,
disableOuterSpacing,
}: SpeakMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo(
() => [
@@ -1669,7 +1724,7 @@ export const SpeakMessage = memo(function SpeakMessage({
return (
<View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}>
<MicVocal size={14} color={theme.colors.foregroundMuted} />
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
@@ -1927,7 +1982,6 @@ interface TodoListItemRowProps {
}
function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
const { theme: todoUnistylesTheme } = useUnistyles();
const badgeStyle = useMemo(
() => [
todoListCardStylesheet.radioBadge,
@@ -1944,7 +1998,9 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
return (
<View style={todoListCardStylesheet.itemRow}>
<View style={badgeStyle}>
{completed ? <Check size={12} color={todoUnistylesTheme.colors.primaryForeground} /> : null}
{completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : null}
</View>
<Text style={textStyle}>{text}</Text>
</View>
@@ -1996,7 +2052,6 @@ export const TodoListCard = memo(function TodoListCard({
items,
disableOuterSpacing,
}: TodoListCardProps) {
const { theme: _unistylesTheme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
@@ -2158,7 +2213,6 @@ function ExpandableBadgeLabelRow({
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
const { theme } = useUnistyles();
return (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -2188,9 +2242,9 @@ function ExpandableBadgeLabelRow({
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<FileSymlink
<ThemedFileSymlinkIcon
size={14}
color={isOpenFileHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
@@ -2217,52 +2271,42 @@ function ExpandableBadgeLabelRow({
);
}
function resolveExpandableBadgeIconColor({
function renderExpandableBadgeIcon({
isError,
isActive,
theme,
ThemedIcon,
}: {
isError: boolean;
isActive: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (isError) return theme.colors.destructive;
if (isActive) return theme.colors.foreground;
return theme.colors.mutedForeground;
}
function renderExpandableBadgeIcon({
isError,
iconColor,
icon,
}: {
isError: boolean;
iconColor: string;
icon?: ComponentType<{ size?: number; color?: string }>;
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
}): ReactNode {
if (isError) {
return <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />;
return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
}
if (icon) {
const IconComponent = icon;
return <IconComponent size={12} color={iconColor} />;
if (ThemedIcon) {
return (
<ThemedIcon
size={12}
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
/>
);
}
return null;
}
function renderExpandableBadgeIconSlot({
showChevron,
chevronColor,
chevronStyle,
iconNode,
}: {
showChevron: boolean;
chevronColor: string;
chevronStyle: StyleProp<ViewStyle>;
iconNode: ReactNode;
}): ReactNode {
if (showChevron) {
return <ChevronRight size={14} color={chevronColor} style={chevronStyle} />;
return (
<ThemedChevronRightIcon size={14} style={chevronStyle} uniProps={foregroundColorMapping} />
);
}
return iconNode;
}
@@ -2385,7 +2429,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
disableOuterSpacing,
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
@@ -2614,11 +2657,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
[isExpanded],
);
const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme });
const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon });
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered && !isIconHovered,
chevronColor: theme.colors.foreground,
chevronStyle,
iconNode,
});
@@ -2743,6 +2785,21 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsExpandedChange,
onOpenFilePath,
}: ToolCallProps) {
useMessageRenderTracker("ToolCall", `${toolName}/${status}`, {
toolName,
args,
result,
error,
status,
detail,
cwd,
metadata,
isLastInSequence,
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
});
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -9,6 +9,7 @@ import {
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query";
@@ -24,7 +25,8 @@ import {
} from "react";
import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
@@ -85,7 +87,8 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -125,17 +128,47 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
function getWorkspacePrIconColor(
theme: ReturnType<typeof useUnistyles>["theme"],
state: PrHint["state"],
) {
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return theme.colors.palette.purple[500];
return purpleColorMapping;
case "open":
return theme.colors.palette.green[500];
return greenColorMapping;
case "closed":
return theme.colors.palette.red[500];
return redColorMapping;
}
}
@@ -223,10 +256,7 @@ function useSidebarWorkspaceEntry(
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -243,10 +273,8 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const prBadgeTextStyle = useMemo(
() => [prBadgeStyles.text, { color: activeColor }],
[activeColor],
);
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
return (
<Pressable
@@ -260,11 +288,11 @@ export function PrBadge({ hint }: { hint: PrHint }) {
style={prBadgePressableStyle}
>
{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}
</Text>
</Pressable>
@@ -302,15 +330,16 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
},
textHovered: {
color: theme.colors.foreground,
},
}));
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
const { theme } = useUnistyles();
const color = theme.colors.palette.red[500];
const textStyle = useMemo(() => [checksBadgeStyles.text, { color }], [color]);
if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length;
@@ -320,8 +349,8 @@ function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | n
return (
<View style={checksBadgeStyles.badge}>
<GitHubIcon size={10} color={color} />
<Text style={textStyle}>{label}</Text>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{label}</Text>
</View>
);
}
@@ -336,6 +365,7 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.palette.red[500],
},
}));
@@ -348,13 +378,12 @@ function WorkspaceStatusIndicator({
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const { theme } = useUnistyles();
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
@@ -362,14 +391,7 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
@@ -377,18 +399,18 @@ function WorkspaceStatusIndicator({
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
let KindIcon: typeof Monitor | typeof FolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = null;
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -399,11 +421,10 @@ function WorkspaceStatusIndicator({
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColor={dotColor}
borderColor={theme.colors.surface0}
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
@@ -413,29 +434,26 @@ function WorkspaceStatusIndicator({
}
function StatusDotOverlay({
dotColor,
borderColor,
dotColorStyle,
size,
offset,
}: {
dotColor: string;
borderColor: string;
dotColorStyle: ViewStyle;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
backgroundColor: dotColor,
borderColor,
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColor, borderColor, size, offset],
[dotColorStyle, size, offset],
);
return <View style={overlayStyle} />;
}
@@ -455,7 +473,6 @@ function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace;
@@ -488,7 +505,6 @@ function ProjectLeadingVisual({
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
theme={theme}
/>
);
}
@@ -503,7 +519,6 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup,
onRemoveProject,
removeProjectStatus,
theme,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -514,7 +529,6 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup: () => void;
onRemoveProject?: () => void;
removeProjectStatus: "idle" | "pending" | "success";
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
return (
@@ -535,7 +549,6 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
theme={theme}
onRemoveProject={onRemoveProject}
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({
projectKey,
theme,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onRemoveProject: () => void;
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(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
@@ -587,7 +595,7 @@ function ProjectKebabMenu({
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`}
>
{renderTriggerIcon}
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
@@ -601,7 +609,7 @@ function ProjectKebabMenu({
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={removeProjectLeadingIcon}
leading={trash2LeadingIcon}
status={removeProjectStatus}
pendingLabel="Removing..."
onSelect={onRemoveProject}
@@ -622,7 +630,6 @@ function WorkspaceRowRightGroup({
isCreating,
showShortcutBadge,
shortcutNumber,
theme,
archiveLabel,
archiveStatus,
archivePendingLabel,
@@ -639,7 +646,6 @@ function WorkspaceRowRightGroup({
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
theme: ReturnType<typeof useUnistyles>["theme"];
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
@@ -654,9 +660,9 @@ function WorkspaceRowRightGroup({
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{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>
) : null}
@@ -664,7 +670,6 @@ function WorkspaceRowRightGroup({
{showKebab && onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
theme={theme}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
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({
workspaceKey,
theme,
onCopyPath,
onCopyBranchName,
onArchive,
@@ -713,7 +705,6 @@ function WorkspaceKebabMenu({
archiveShortcutKeys,
}: {
workspaceKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onArchive: () => void;
@@ -722,28 +713,10 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string;
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(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[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 (
<DropdownMenu>
<DropdownMenuTrigger
@@ -753,7 +726,7 @@ function WorkspaceKebabMenu({
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderTriggerIcon}
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
@@ -813,19 +786,17 @@ function ProjectLeadingVisualStatus({
isArchiving,
shouldShowSyncedLoader,
activeWorkspace,
theme,
}: {
iconDataUri: string | null;
placeholderInitial: string;
isArchiving: boolean;
shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
@@ -833,14 +804,7 @@ function ProjectLeadingVisualStatus({
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
@@ -848,16 +812,12 @@ function ProjectLeadingVisualStatus({
if (activeWorkspace.statusBucket === "needs_input") {
return (
<View style={styles.projectLeadingVisualSlot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket);
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -869,10 +829,9 @@ function ProjectLeadingVisualStatus({
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
{dotColor ? (
{dotColorStyle ? (
<StatusDotOverlay
dotColor={dotColor}
borderColor={theme.colors.surface0}
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
@@ -906,7 +865,6 @@ function NewWorktreeButton({
testID: string;
showShortcutHint?: boolean;
}) {
const { theme } = useUnistyles();
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
@@ -940,12 +898,12 @@ function NewWorktreeButton({
>
{({ hovered, pressed }) =>
loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
) : (
<FolderPlus
<ThemedFolderPlus
size={15}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
uniProps={
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
}
/>
)
@@ -1205,7 +1163,6 @@ function ProjectHeaderRow({
removeProjectStatus = "idle",
dragHandleProps,
}: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const handleBeginWorkspaceSetup = useCallback(() => {
@@ -1275,7 +1232,6 @@ function ProjectHeaderRow({
onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
theme={theme}
/>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
@@ -1351,7 +1307,6 @@ function WorkspaceRowInner({
onCopyPath,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
@@ -1447,7 +1402,6 @@ function WorkspaceRowInner({
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
theme={theme}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
@@ -2912,4 +2866,35 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,
},
statusDotFailed: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.surface0,
},
statusDotRunning: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.surface0,
},
statusDotAttention: {
backgroundColor: theme.colors.palette.green[500],
borderColor: theme.colors.surface0,
},
}));
function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null {
switch (bucket) {
case "needs_input":
return styles.statusDotNeedsInput;
case "failed":
return styles.statusDotFailed;
case "running":
return styles.statusDotRunning;
case "attention":
return styles.statusDotAttention;
case "done":
return null;
}
}
@@ -92,6 +92,36 @@ function isScrollContainerOverscrolledPastBottom(
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 }) {
const {
segments,
@@ -105,6 +135,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollEnabled,
isMobileBreakpoint,
} = props;
useRenderTracker("WebStreamViewport", props.agentId, {
agentId: props.agentId,
segments,
"segments.historyMounted": segments.historyMounted,
"segments.historyVirtualized": segments.historyVirtualized,
"segments.liveHead": segments.liveHead,
boundary,
renderers,
"renderers.renderHistoryMountedRow": renderers.renderHistoryMountedRow,
"renderers.renderHistoryVirtualizedRow": renderers.renderHistoryVirtualizedRow,
"renderers.renderLiveHeadRow": renderers.renderLiveHeadRow,
"renderers.renderLiveAuxiliary": renderers.renderLiveAuxiliary,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
scrollEnabled,
isMobileBreakpoint,
});
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
@@ -164,6 +214,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8,
});
useEffect(() => {
vlog(props.agentId, "effect:virtualizer-adjust setup fired", {});
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
@@ -370,6 +421,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]);
useLayoutEffect(() => {
vlog(props.agentId, "effect:activation-reset fired", { activationKey, isActivationReady });
if (!isActivationReady) {
return;
}
@@ -391,9 +443,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.clearTimeout(timeout);
};
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom, props.agentId]);
useEffect(() => {
vlog(props.agentId, "effect:stick-on-segments-change fired", {
followOutput: followOutputRef.current,
});
if (!followOutputRef.current) {
return;
}
@@ -403,16 +458,28 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
segments.historyMounted,
segments.historyVirtualized,
segments.liveHead,
props.agentId,
]);
useEffect(() => {
vlog(props.agentId, "effect:stick-on-virtual-total fired", {
followOutput: followOutputRef.current,
shouldUseVirtualizer,
virtualTotalSize,
});
if (!followOutputRef.current || !shouldUseVirtualizer) {
return;
}
scheduleStickToBottom();
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]);
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize, props.agentId]);
useEffect(() => {
vlog(props.agentId, "effect:metrics-on-segments-change fired", {
mounted: segments.historyMounted.length,
virtualized: segments.historyVirtualized.length,
liveHead: segments.liveHead.length,
virtualTotalSize,
});
updateScrollMetrics();
}, [
segments.historyMounted.length,
@@ -420,9 +487,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
segments.liveHead.length,
updateScrollMetrics,
virtualTotalSize,
props.agentId,
]);
useEffect(() => {
vlog(props.agentId, "effect:resize-observer setup fired", {});
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
if (!scrollContainer || typeof ResizeObserver === "undefined") {
@@ -456,6 +525,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
vlog(props.agentId, "effect:scroll-listeners setup fired", {});
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
@@ -604,6 +674,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId]);
useEffect(() => {
vlog(props.agentId, "effect:viewportRef-handle setup fired", {});
const handle: StreamViewportHandle = {
scrollToBottom: () => {
setFollowOutput(true);
@@ -642,8 +713,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
]);
const contentContainerStyle = useMemo(
(): CSSProperties => ({
const contentContainerStyle = useMemo((): CSSProperties => {
vlog(props.agentId, "memo:contentContainerStyle rebuilt", { isMobileBreakpoint });
return {
display: "flex",
flexDirection: "column",
minHeight: "100%",
@@ -652,27 +724,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
paddingLeft: isMobileBreakpoint ? 8 : 16,
paddingRight: isMobileBreakpoint ? 8 : 16,
boxSizing: "border-box",
}),
[isMobileBreakpoint],
);
const scrollContainerStyle = useMemo(
(): CSSProperties => ({
};
}, [isMobileBreakpoint, props.agentId]);
const scrollContainerStyle = useMemo((): CSSProperties => {
vlog(props.agentId, "memo:scrollContainerStyle rebuilt", { scrollEnabled });
return {
flex: 1,
minHeight: 0,
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain",
}),
[scrollEnabled],
);
const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({
};
}, [scrollEnabled, props.agentId]);
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
vlog(props.agentId, "memo:virtualRowsContainerStyle rebuilt", { virtualTotalSize });
return {
position: "relative",
width: "100%",
height: virtualTotalSize,
}),
[virtualTotalSize],
);
};
}, [virtualTotalSize, props.agentId]);
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
@@ -685,29 +756,41 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[],
);
const mountedHistoryRows = useMemo(
() =>
segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
)),
[renderHistoryMountedRow, segments.historyMounted],
);
const liveHeadRows = useMemo(
() =>
segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
)),
[renderLiveHeadRow, segments.liveHead],
);
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]);
const mountedHistoryRows = useMemo(() => {
vlog(props.agentId, "memo:mountedHistoryRows rebuilt", {
count: segments.historyMounted.length,
});
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
));
}, [renderHistoryMountedRow, segments.historyMounted, props.agentId]);
const liveHeadRows = useMemo(() => {
vlog(props.agentId, "memo:liveHeadRows rebuilt", { count: segments.liveHead.length });
return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
}, [renderLiveHeadRow, segments.liveHead, props.agentId]);
const liveAuxiliary = useMemo(() => {
vlog(props.agentId, "memo:liveAuxiliary rebuilt", {});
return renderLiveAuxiliary();
}, [renderLiveAuxiliary, props.agentId]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
!boundary.hasLiveHead &&
!liveAuxiliary;
vlog(props.agentId, "render summary", {
virtualRowsRendered: virtualRows.length,
mountedHistoryRowsRendered: segments.historyMounted.length,
liveHeadRowsRendered: segments.liveHead.length,
shouldUseVirtualizer,
virtualTotalSize,
shouldRenderEmpty,
});
return (
<>
<div
@@ -8,11 +8,20 @@ import {
type ReactElement,
type ReactNode,
} 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal";
@@ -207,7 +216,6 @@ function WorkspaceHoverCardContent({
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -297,7 +305,7 @@ function WorkspaceHoverCardContent({
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} theme={theme} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
</>
) : null}
</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({
checks,
theme,
hovered,
}: {
checks: NonNullable<PrHint["checks"]>;
theme: ReturnType<typeof useUnistyles>["theme"];
hovered: boolean;
}) {
const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length;
let badgeColor: string;
let badgeLabel: string;
let dotStyle: StyleProp<ViewStyle>;
let statusTextStyle: StyleProp<TextStyle>;
if (failed > 0) {
badgeColor = theme.colors.palette.red[500];
badgeLabel = `${failed} failed`;
dotStyle = styles.checksDotFailed;
statusTextStyle = styles.checksStatusTextFailed;
} else if (pending > 0) {
badgeColor = theme.colors.palette.amber[500];
badgeLabel = `${pending} running`;
dotStyle = styles.checksDotPending;
statusTextStyle = styles.checksStatusTextPending;
} else {
badgeColor = theme.colors.palette.green[500];
badgeLabel = `${checks.length} passed`;
dotStyle = styles.checksDotPassed;
statusTextStyle = styles.checksStatusTextPassed;
}
const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const labelStyle = useMemo(
() => [styles.checksSummaryLabel, hovered && styles.checksSummaryLabelHovered],
[hovered],
);
const dotStyle = useMemo(() => [styles.checksDot, { backgroundColor: badgeColor }], [badgeColor]);
const statusTextStyle = useMemo(
() => [styles.checksStatusText, { color: badgeColor }],
[badgeColor],
);
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
return (
<>
{hovered ? (
<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>
<View style={styles.checksSummaryCounts}>
@@ -362,38 +370,32 @@ function ChecksSummaryContent({
function ChecksSummaryPressable({
checks,
url,
theme,
}: {
checks: NonNullable<PrHint["checks"]>;
url: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const handlePress = useCallback(() => {
void openExternalUrl(`${url}/checks`);
}, [url]);
const pressableStyle = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => [
styles.checksSummaryRow,
Boolean(hovered) && styles.listRowHovered,
],
[],
);
const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} theme={theme} hovered={Boolean(hovered)} />
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
),
[checks, theme],
[checks],
);
return (
<Pressable style={pressableStyle} onPress={handlePress}>
<Pressable style={checksSummaryPressableStyle} onPress={handlePress}>
{renderChildren}
</Pressable>
);
}
function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) {
return [styles.checksSummaryRow, hovered && styles.listRowHovered];
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
@@ -467,13 +469,42 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
justifyContent: "flex-end",
},
checksDot: {
checksDotFailed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.red[500],
},
checksStatusText: {
checksDotPending: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.amber[500],
},
checksDotPassed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.green[500],
},
checksStatusTextFailed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
checksStatusTextPending: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.amber[500],
},
checksStatusTextPassed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[500],
},
}));
const checksSummaryLabelHoveredCombined = [
styles.checksSummaryLabel,
styles.checksSummaryLabelHovered,
];
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
@@ -140,7 +140,6 @@ function buildCreateAgentOptions({
}
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
@@ -349,10 +348,6 @@ export function WorkspaceSetupDialog() {
addImagesRef.current = addImages;
}, []);
const composerInputWrapperStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const statusControlsWithDisabled = useMemo(
() =>
@@ -415,7 +410,7 @@ export function WorkspaceSetupDialog() {
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
inputWrapperStyle={composerInputWrapperStyle}
inputWrapperStyle={styles.composerInputWrapper}
onAddImages={handleAddImagesCallback}
/>
</View>
@@ -463,4 +458,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
lineHeight: 20,
},
composerInputWrapper: {
backgroundColor: theme.colors.surface2,
},
}));