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:
@@ -4,6 +4,74 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
|
||||
|
||||
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||
|
||||
## STOP — `useUnistyles()` Is Forbidden
|
||||
|
||||
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
|
||||
|
||||
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
|
||||
|
||||
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
|
||||
|
||||
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
|
||||
|
||||
### 1. `StyleSheet.create((theme) => ...)` — default
|
||||
|
||||
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
|
||||
|
||||
```tsx
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
<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
|
||||
|
||||
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,
|
||||
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 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -108,7 +108,7 @@ vi.mock("react-native-unistyles", () => {
|
||||
StyleSheet: {
|
||||
create: createPanelTestStyles,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
@@ -125,9 +126,8 @@ function buildChatAgentFromState(
|
||||
function renderChatAgentNonReadyView(args: {
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}): React.ReactElement | null {
|
||||
const { viewState, effectiveAgent, theme } = args;
|
||||
const { viewState, effectiveAgent } = args;
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
@@ -151,7 +151,7 @@ function renderChatAgentNonReadyView(args: {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -419,7 +419,6 @@ function AgentPanelBody({
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
@@ -549,7 +548,7 @@ function AgentPanelBody({
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -585,7 +584,6 @@ function ChatAgentContent({
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const panelToast = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
@@ -945,7 +943,6 @@ function ChatAgentContent({
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
theme,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
@@ -990,7 +987,7 @@ function ChatAgentContent({
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay" ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1004,7 +1001,7 @@ function ChatAgentContent({
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<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.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</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) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useWorkspaceSetupStore,
|
||||
type WorkspaceSetupSnapshot,
|
||||
@@ -62,15 +63,13 @@ function useSetupPanelDescriptor(
|
||||
type CommandStatus = "running" | "completed" | "failed";
|
||||
|
||||
function CommandStatusIcon({ status }: { status: CommandStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (status === "running") {
|
||||
return <ActivityIndicator size={14} color={theme.colors.foreground} />;
|
||||
return <ThemedActivityIndicator size={14} uniProps={foregroundColorMapping} />;
|
||||
}
|
||||
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 {
|
||||
@@ -151,7 +150,6 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
}
|
||||
|
||||
function SetupPanel() {
|
||||
const { theme } = useUnistyles();
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
@@ -231,7 +229,7 @@ function SetupPanel() {
|
||||
|
||||
{isWaiting ? (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.waitingText}>Setting up workspace...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -269,7 +267,6 @@ function SetupPanel() {
|
||||
hasError={rowState.hasError}
|
||||
processedLog={rowState.processedLog}
|
||||
errorMessage={snapshot?.error ?? null}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
);
|
||||
@@ -292,7 +289,6 @@ interface SetupCommandRowProps {
|
||||
hasError: boolean;
|
||||
processedLog: string;
|
||||
errorMessage: string | null;
|
||||
foregroundMutedColor: string;
|
||||
onToggle: (index: number, isAutoExpanded: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -305,7 +301,6 @@ function SetupCommandRow({
|
||||
hasError,
|
||||
processedLog,
|
||||
errorMessage,
|
||||
foregroundMutedColor,
|
||||
onToggle,
|
||||
}: SetupCommandRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -341,7 +336,7 @@ function SetupCommandRow({
|
||||
{command.durationMs != null ? (
|
||||
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
|
||||
) : null}
|
||||
<SetupCommandChevron showDetail={showDetail} color={foregroundMutedColor} />
|
||||
<SetupCommandChevron showDetail={showDetail} />
|
||||
</Pressable>
|
||||
{showDetail ? (
|
||||
<View style={styles.commandDetail}>
|
||||
@@ -388,12 +383,14 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = {
|
||||
useDescriptor: useSetupPanelDescriptor,
|
||||
};
|
||||
|
||||
function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) {
|
||||
function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, showDetail && styles.chevronExpanded],
|
||||
[showDetail],
|
||||
);
|
||||
return <ChevronRight size={14} color={color} style={chevronStyle} />;
|
||||
return (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} style={chevronStyle} />
|
||||
);
|
||||
}
|
||||
|
||||
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
|
||||
@@ -432,6 +429,24 @@ function TopLevelSetupError({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCheckCircle2 = withUnistyles(CheckCircle2);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.green[500],
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.red[500],
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import type {
|
||||
DraggableListDragHandleProps,
|
||||
@@ -58,39 +58,53 @@ import {
|
||||
type WorkspaceTabMenuEntry,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedRows2 = withUnistyles(Rows2);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||
}
|
||||
|
||||
function TabContextMenuItem({
|
||||
entry,
|
||||
iconColor,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
iconColor: string;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
case "rotate-cw":
|
||||
return <RotateCw size={16} color={iconColor} />;
|
||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ArrowRightToLine size={16} color={iconColor} />;
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <CopyX size={16} color={iconColor} />;
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <X size={16} color={iconColor} />;
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [entry.icon, iconColor]);
|
||||
}, [entry.icon]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
@@ -255,7 +269,6 @@ function TabChip({
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||
const middleClickRef = useMiddleClickClose(
|
||||
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
||||
@@ -392,21 +405,17 @@ function TabChip({
|
||||
>
|
||||
{({ hovered: closeHovered, pressed }) =>
|
||||
isClosingTab ? (
|
||||
<ActivityIndicator
|
||||
<ThemedActivityIndicator
|
||||
size={12}
|
||||
color={
|
||||
closeHovered || pressed
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
uniProps={
|
||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<X
|
||||
<ThemedX
|
||||
size={12}
|
||||
color={
|
||||
closeHovered || pressed
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
uniProps={
|
||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -432,11 +441,7 @@ function TabChip({
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<TabContextMenuItem
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<TabContextMenuItem key={entry.key} entry={entry} />
|
||||
),
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
@@ -473,7 +478,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabDropPreviewIndex = null,
|
||||
showPaneSplitActions = true,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
|
||||
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
||||
@@ -499,11 +503,11 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabGap: 0,
|
||||
maxTabWidth: 200,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: theme.spacing[3],
|
||||
tabHorizontalPadding: 12,
|
||||
estimatedCharWidth: 7,
|
||||
closeButtonWidth: 22,
|
||||
}),
|
||||
[tabsActionsWidth, theme.spacing],
|
||||
[tabsActionsWidth],
|
||||
);
|
||||
|
||||
const tabLabelLengths = useMemo(
|
||||
@@ -668,7 +672,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
accessibilityLabel="New agent tab"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedSquarePen size={14} uniProps={mutedColorMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -690,7 +694,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
}
|
||||
style={newTerminalActionButtonStyle}
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -712,7 +716,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
accessibilityLabel="Split pane right"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -730,7 +734,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
accessibilityLabel="Split pane down"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
Download,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { useGitActions } from "@/hooks/use-git-actions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceGitActionsProps {
|
||||
serverId: string;
|
||||
@@ -18,24 +18,31 @@ interface WorkspaceGitActionsProps {
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const ICONS = {
|
||||
commit: <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) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
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 });
|
||||
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS });
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -33,18 +34,25 @@ interface EditorMenuItemProps {
|
||||
editor: EditorTargetDescriptorPayload;
|
||||
isPreferred: boolean;
|
||||
onOpen: (editorId: EditorTargetId) => void;
|
||||
foregroundMuted: string;
|
||||
}
|
||||
|
||||
function EditorMenuItem({ editor, isPreferred, onOpen, foregroundMuted }: EditorMenuItemProps) {
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function EditorMenuItem({ editor, isPreferred, onOpen }: EditorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
|
||||
const leading = useMemo(
|
||||
() => <EditorAppIcon editorId={editor.id} size={16} color={foregroundMuted} />,
|
||||
[editor.id, foregroundMuted],
|
||||
() => <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
[editor.id],
|
||||
);
|
||||
const trailing = useMemo(
|
||||
() => (isPreferred ? <Check size={16} color={foregroundMuted} /> : undefined),
|
||||
[isPreferred, foregroundMuted],
|
||||
() => (isPreferred ? <ThemedCheckIcon size={16} uniProps={mutedColorMapping} /> : undefined),
|
||||
[isPreferred],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -63,7 +71,6 @@ export function WorkspaceOpenInEditorButton({
|
||||
cwd,
|
||||
hideLabels,
|
||||
}: WorkspaceOpenInEditorButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -174,17 +181,17 @@ export function WorkspaceOpenInEditorButton({
|
||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ActivityIndicator
|
||||
<ThemedActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
uniProps={foregroundColorMapping}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
<EditorAppIcon
|
||||
<ThemedEditorAppIcon
|
||||
editorId={primaryOption.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
uniProps={mutedColorMapping}
|
||||
/>
|
||||
{!hideLabels && <Text style={styles.splitButtonText}>Open</Text>}
|
||||
</View>
|
||||
@@ -198,7 +205,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose editor"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
<ThemedChevronDown size={16} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
@@ -212,7 +219,6 @@ export function WorkspaceOpenInEditorButton({
|
||||
editor={editor}
|
||||
isPreferred={editor.id === effectivePreferredEditorId}
|
||||
onOpen={handleOpenEditor}
|
||||
foregroundMuted={theme.colors.foregroundMuted}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
@@ -135,6 +136,32 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<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 {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -298,14 +325,10 @@ function MobileTabTrailingAccessory({
|
||||
menuTestIDBase,
|
||||
presentationLabel,
|
||||
menuEntries,
|
||||
iconColor,
|
||||
iconSizeSm,
|
||||
}: {
|
||||
menuTestIDBase: string;
|
||||
presentationLabel: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
iconColor: string;
|
||||
iconSizeSm: number;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -316,14 +339,14 @@ function MobileTabTrailingAccessory({
|
||||
hitSlop={8}
|
||||
style={mobileTabMenuTriggerStyle}
|
||||
>
|
||||
<Ellipsis size={iconSizeSm} color={iconColor} />
|
||||
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} iconColor={iconColor} />
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -333,29 +356,27 @@ function MobileTabTrailingAccessory({
|
||||
|
||||
function MobileTabDropdownMenuItem({
|
||||
entry,
|
||||
iconColor,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
iconColor: string;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
case "rotate-cw":
|
||||
return <RotateCw size={16} color={iconColor} />;
|
||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ArrowRightToLine size={16} color={iconColor} />;
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <CopyX size={16} color={iconColor} />;
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <X size={16} color={iconColor} />;
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [entry.icon, iconColor]);
|
||||
}, [entry.icon]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
@@ -408,7 +429,6 @@ function MobileWorkspaceTabOption({
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
@@ -425,9 +445,6 @@ function MobileWorkspaceTabOption({
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const iconSizeSm = theme.iconSize.sm;
|
||||
|
||||
const fallbackLabel = getFallbackTabOptionLabel(tab);
|
||||
const trailingAccessory = useMemo(
|
||||
() => (
|
||||
@@ -435,11 +452,9 @@ function MobileWorkspaceTabOption({
|
||||
menuTestIDBase={menuTestIDBase}
|
||||
presentationLabel={fallbackLabel}
|
||||
menuEntries={menuEntries}
|
||||
iconColor={iconColor}
|
||||
iconSizeSm={iconSizeSm}
|
||||
/>
|
||||
),
|
||||
[menuTestIDBase, fallbackLabel, menuEntries, iconColor, iconSizeSm],
|
||||
[menuTestIDBase, fallbackLabel, menuEntries],
|
||||
);
|
||||
|
||||
const renderPresentation = useCallback(
|
||||
@@ -483,7 +498,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
}: MobileWorkspaceTabSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const tabIndexByKey = useMemo(() => {
|
||||
@@ -572,7 +586,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
</View>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
|
||||
<Combobox
|
||||
@@ -730,14 +744,9 @@ function WorkspaceHeaderMenuTriggerIcon({
|
||||
open: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = isMobile ? EllipsisVertical : Ellipsis;
|
||||
return (
|
||||
<Icon
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
);
|
||||
const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
|
||||
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
|
||||
return <Icon size={16} uniProps={colorMapping} />;
|
||||
}
|
||||
|
||||
function WorkspaceHeaderMenu({
|
||||
@@ -939,7 +948,6 @@ interface RenderWorkspaceContentInput {
|
||||
paneId: string | null;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
foregroundMutedColor: string;
|
||||
}
|
||||
|
||||
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
|
||||
@@ -953,13 +961,12 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
buildMobilePaneContentModel,
|
||||
foregroundMutedColor,
|
||||
} = input;
|
||||
|
||||
if (showMissingWorkspaceDescriptor) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={foregroundMutedColor} />
|
||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -975,7 +982,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
if (!activeTabDescriptor && !hasHydratedAgents) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={foregroundMutedColor} />
|
||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1115,9 +1122,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const _insets = useSafeAreaInsets();
|
||||
const mainBackgroundColor = theme.colors.surfaceWorkspace;
|
||||
const toast = useToast();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
@@ -2562,7 +2567,6 @@ function WorkspaceScreenContent({
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
buildMobilePaneContentModel,
|
||||
foregroundMutedColor: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const buildDesktopPaneContentModel = useCallback(
|
||||
@@ -2659,27 +2663,12 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { backgroundColor: mainBackgroundColor }],
|
||||
[mainBackgroundColor],
|
||||
);
|
||||
const containerStyle = containerWithWorkspaceBackgroundStyle;
|
||||
|
||||
const menuNewAgentIcon = useMemo(
|
||||
() => <SquarePen size={16} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
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 menuNewAgentIcon = MENU_NEW_AGENT_ICON;
|
||||
const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
|
||||
const menuCopyIcon = MENU_COPY_ICON;
|
||||
const menuSettingsIcon = MENU_SETTINGS_ICON;
|
||||
|
||||
const headerRight = useMemo(
|
||||
() => (
|
||||
@@ -2721,12 +2710,10 @@ function WorkspaceScreenContent({
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const iconColor = active
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<>
|
||||
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
|
||||
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
@@ -2766,9 +2753,9 @@ function WorkspaceScreenContent({
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const color =
|
||||
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return <PanelRight size={theme.iconSize.md} color={color} />;
|
||||
const colorMapping =
|
||||
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
return <ThemedPanelRight size={16} uniProps={colorMapping} />;
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
) : null}
|
||||
@@ -2786,12 +2773,16 @@ function WorkspaceScreenContent({
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const color =
|
||||
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const colorMapping =
|
||||
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
return isGitCheckout ? (
|
||||
<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>
|
||||
@@ -2812,10 +2803,6 @@ function WorkspaceScreenContent({
|
||||
isExplorerOpen,
|
||||
explorerToggleAccessibilityState,
|
||||
explorerToggleStyle,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.lg,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3039,6 +3026,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
containerWorkspaceBackground: {
|
||||
backgroundColor: theme.colors.surfaceWorkspace,
|
||||
},
|
||||
threePaneRow: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -3333,4 +3323,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const containerWithWorkspaceBackgroundStyle = [
|
||||
styles.container,
|
||||
styles.containerWorkspaceBackground,
|
||||
];
|
||||
|
||||
const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
|
||||
|
||||
@@ -43,7 +43,17 @@ vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<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", () => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GestureResponderEvent } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
@@ -17,6 +17,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type ScriptActionIcon = "start" | "view";
|
||||
|
||||
@@ -39,28 +40,48 @@ interface ScriptActionButtonProps {
|
||||
testID: string;
|
||||
}
|
||||
|
||||
const ThemedPlay = withUnistyles(Play);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const mutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const blueColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.blue[500],
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.green[500],
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.red[500],
|
||||
});
|
||||
const playFillTransparent = { fill: "transparent" };
|
||||
|
||||
interface ScriptActionButtonChildrenProps {
|
||||
hovered?: boolean;
|
||||
icon: ScriptActionIcon;
|
||||
label: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}
|
||||
|
||||
function ScriptActionButtonChildren({
|
||||
hovered,
|
||||
icon,
|
||||
label,
|
||||
theme,
|
||||
}: ScriptActionButtonChildrenProps): ReactElement {
|
||||
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconProps = { size: 10, color };
|
||||
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
const iconElement =
|
||||
icon === "view" ? (
|
||||
<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 (
|
||||
<>
|
||||
{iconElement}
|
||||
@@ -77,8 +98,6 @@ function ScriptActionButton({
|
||||
onPress,
|
||||
testID,
|
||||
}: ScriptActionButtonProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -89,9 +108,9 @@ function ScriptActionButton({
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => (
|
||||
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} theme={theme} />
|
||||
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} />
|
||||
),
|
||||
[icon, label, theme],
|
||||
[icon, label],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -123,32 +142,26 @@ interface HostLinkChildrenProps {
|
||||
hovered?: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}
|
||||
|
||||
function HostLinkChildren({
|
||||
hovered,
|
||||
disabled,
|
||||
label,
|
||||
theme,
|
||||
}: HostLinkChildrenProps): ReactElement {
|
||||
function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement {
|
||||
const showIcon = !disabled && (hovered || isNative);
|
||||
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const hostLabelStyle = useMemo(() => [styles.hostLabel, { color }], [color]);
|
||||
const isActive = Boolean(hovered) && !disabled;
|
||||
const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping;
|
||||
const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel;
|
||||
return (
|
||||
<>
|
||||
<Text style={hostLabelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.hostIconSlot}>
|
||||
{showIcon ? <ExternalLink size={10} color={color} /> : null}
|
||||
{showIcon ? <ThemedExternalLink size={10} uniProps={colorMapping} /> : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const disabled = !url;
|
||||
|
||||
const handlePress = useCallback(
|
||||
@@ -161,9 +174,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => (
|
||||
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} theme={theme} />
|
||||
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} />
|
||||
),
|
||||
[disabled, label, theme],
|
||||
[disabled, label],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -181,9 +194,7 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
}
|
||||
|
||||
function ExitCodeBadge({ code }: { code: number }): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
|
||||
const exitTextStyle = useMemo(() => [styles.exitBadgeText, { color }], [color]);
|
||||
const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle;
|
||||
return (
|
||||
<View style={styles.exitBadge}>
|
||||
<Text style={exitTextStyle}>exit {code}</Text>
|
||||
@@ -206,26 +217,24 @@ interface ScriptRowProps {
|
||||
: null
|
||||
: null;
|
||||
isStartPending: boolean;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
onStartScript: (scriptName: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
function resolveScriptIconColor(args: {
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
function resolveScriptIconColorMapping(args: {
|
||||
script: WorkspaceDescriptor["scripts"][number];
|
||||
isService: boolean;
|
||||
isRunning: boolean;
|
||||
}): string {
|
||||
const { theme, script, isService, isRunning } = args;
|
||||
}): (theme: Theme) => { color: string } {
|
||||
const { script, isService, isRunning } = args;
|
||||
if (isService) {
|
||||
if (isRunning && script.health === "healthy") return theme.colors.palette.green[500];
|
||||
if (isRunning && script.health === "unhealthy") return theme.colors.palette.red[500];
|
||||
if (isRunning) return theme.colors.palette.blue[500];
|
||||
return theme.colors.foregroundMuted;
|
||||
if (isRunning && script.health === "healthy") return greenColorMapping;
|
||||
if (isRunning && script.health === "unhealthy") return redColorMapping;
|
||||
if (isRunning) return blueColorMapping;
|
||||
return mutedColorMapping;
|
||||
}
|
||||
if (isRunning) return theme.colors.palette.blue[500];
|
||||
return theme.colors.foregroundMuted;
|
||||
if (isRunning) return blueColorMapping;
|
||||
return mutedColorMapping;
|
||||
}
|
||||
|
||||
function ScriptRow({
|
||||
@@ -233,7 +242,6 @@ function ScriptRow({
|
||||
liveTerminalIdSet,
|
||||
activeConnection,
|
||||
isStartPending,
|
||||
theme,
|
||||
onStartScript,
|
||||
onViewTerminal,
|
||||
}: ScriptRowProps): ReactElement {
|
||||
@@ -268,9 +276,8 @@ function ScriptRow({
|
||||
}
|
||||
}
|
||||
|
||||
const iconColor = resolveScriptIconColor({ theme, script, isService, isRunning });
|
||||
|
||||
const ScriptIcon = isService ? Globe : SquareTerminal;
|
||||
const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning });
|
||||
const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal;
|
||||
const showExitBadge = !isRunning && exitCode !== null;
|
||||
|
||||
const handleView = useCallback(() => {
|
||||
@@ -282,13 +289,8 @@ function ScriptRow({
|
||||
}, [onStartScript, script.scriptName]);
|
||||
|
||||
const scriptNameStyle = useMemo(
|
||||
() => [
|
||||
styles.scriptName,
|
||||
{
|
||||
color: isRunning ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||
},
|
||||
],
|
||||
[isRunning, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
() => (isRunning ? scriptNameActiveStyle : styles.scriptName),
|
||||
[isRunning],
|
||||
);
|
||||
|
||||
let primaryAction: ReactElement | null = null;
|
||||
@@ -322,7 +324,7 @@ function ScriptRow({
|
||||
style={styles.scriptItem}
|
||||
>
|
||||
<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}>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
@@ -355,7 +357,6 @@ export function WorkspaceScriptsButton({
|
||||
onViewTerminal,
|
||||
hideLabels,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||
@@ -402,6 +403,7 @@ export function WorkspaceScriptsButton({
|
||||
}
|
||||
|
||||
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
|
||||
const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -414,15 +416,9 @@ export function WorkspaceScriptsButton({
|
||||
accessibilityLabel="Workspace scripts"
|
||||
>
|
||||
<View style={styles.splitButtonContent}>
|
||||
<Play
|
||||
size={14}
|
||||
color={
|
||||
hasAnyRunning ? theme.colors.palette.blue[500] : theme.colors.foregroundMuted
|
||||
}
|
||||
fill="transparent"
|
||||
/>
|
||||
<ThemedPlay size={14} uniProps={triggerPlayMapping} {...playFillTransparent} />
|
||||
{!hideLabels && <Text style={styles.splitButtonText}>Scripts</Text>}
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -440,7 +436,6 @@ export function WorkspaceScriptsButton({
|
||||
liveTerminalIdSet={liveTerminalIdSet}
|
||||
activeConnection={activeConnection}
|
||||
isStartPending={startScriptMutation.isPending}
|
||||
theme={theme}
|
||||
onStartScript={handleStartScript}
|
||||
onViewTerminal={onViewTerminal}
|
||||
/>
|
||||
@@ -511,6 +506,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scriptNameActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
@@ -531,6 +530,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hostLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostIconSlot: {
|
||||
width: 10,
|
||||
@@ -549,6 +552,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
exitBadgeTextError: {
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
@@ -558,5 +565,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
actionButtonLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
actionButtonLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered];
|
||||
const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive];
|
||||
const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive];
|
||||
const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError];
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export interface WorkspaceTabPresentation {
|
||||
key: string;
|
||||
@@ -103,22 +104,22 @@ interface WorkspaceTabIconProps {
|
||||
statusDotBorderColor?: string;
|
||||
}
|
||||
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export function WorkspaceTabIcon({
|
||||
presentation,
|
||||
active = false,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
}: WorkspaceTabIconProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const statusDotColor =
|
||||
presentation.statusBucket === null
|
||||
? null
|
||||
: getStatusDotColor({
|
||||
theme,
|
||||
bucket: presentation.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
|
||||
const bucket = presentation.statusBucket;
|
||||
let statusDotColor: string | null = null;
|
||||
if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color;
|
||||
else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
@@ -139,27 +140,20 @@ export function WorkspaceTabIcon({
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor ?? undefined,
|
||||
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||
borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
],
|
||||
[statusDotColor, statusDotBorderColor, theme.colors.surface0, statusDotSize, statusDotOffset],
|
||||
[statusDotColor, statusDotBorderColor, statusDotSize, statusDotOffset],
|
||||
);
|
||||
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
<SyncedLoader
|
||||
size={size - 1}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
<SyncedLoader size={size - 1} color={styles.syncedLoader.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -187,7 +181,6 @@ export function WorkspaceTabOptionRow({
|
||||
onPress,
|
||||
trailingAccessory,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.optionMainPressable,
|
||||
@@ -213,7 +206,7 @@ export function WorkspaceTabOptionRow({
|
||||
</Pressable>
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
{trailingAccessory ? (
|
||||
@@ -238,6 +231,33 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusDotBorderDefault: {
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
statusDotRunning: {
|
||||
color: theme.colors.palette.blue[500],
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: theme.colors.palette.green[500],
|
||||
},
|
||||
iconActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
iconInactive: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
syncedLoader: {
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -412,74 +412,83 @@ const ghosttyDarkColors = buildDarkSemanticColors({
|
||||
destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces
|
||||
});
|
||||
|
||||
export const SPACING = {
|
||||
0: 0,
|
||||
1: 4,
|
||||
1.5: 6,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
20: 80,
|
||||
24: 96,
|
||||
32: 128,
|
||||
} as const;
|
||||
|
||||
export const FONT_SIZE = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
"2xl": 22,
|
||||
"3xl": 26,
|
||||
"4xl": 34,
|
||||
} as const;
|
||||
|
||||
export const LINE_HEIGHT = {
|
||||
diff: 22,
|
||||
} as const;
|
||||
|
||||
export const ICON_SIZE = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
} as const;
|
||||
|
||||
export const FONT_WEIGHT = {
|
||||
normal: "normal" as const,
|
||||
medium: "500" as const,
|
||||
semibold: "600" as const,
|
||||
bold: "bold" as const,
|
||||
} as const;
|
||||
|
||||
export const BORDER_RADIUS = {
|
||||
none: 0,
|
||||
sm: 2,
|
||||
base: 4,
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
"2xl": 16,
|
||||
full: 9999,
|
||||
} as const;
|
||||
|
||||
export const BORDER_WIDTH = {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
} as const;
|
||||
|
||||
export const OPACITY = {
|
||||
0: 0,
|
||||
50: 0.5,
|
||||
100: 1,
|
||||
} as const;
|
||||
|
||||
const commonTheme = {
|
||||
spacing: {
|
||||
0: 0,
|
||||
1: 4,
|
||||
1.5: 6,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
20: 80,
|
||||
24: 96,
|
||||
32: 128,
|
||||
},
|
||||
|
||||
fontSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
"2xl": 22,
|
||||
"3xl": 26,
|
||||
"4xl": 34,
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
diff: 22,
|
||||
},
|
||||
|
||||
iconSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
},
|
||||
|
||||
fontWeight: {
|
||||
normal: "normal" as const,
|
||||
medium: "500" as const,
|
||||
semibold: "600" as const,
|
||||
bold: "bold" as const,
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
none: 0,
|
||||
sm: 2,
|
||||
base: 4,
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
"2xl": 16,
|
||||
full: 9999,
|
||||
},
|
||||
|
||||
borderWidth: {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
|
||||
opacity: {
|
||||
0: 0,
|
||||
50: 0.5,
|
||||
100: 1,
|
||||
},
|
||||
spacing: SPACING,
|
||||
fontSize: FONT_SIZE,
|
||||
lineHeight: LINE_HEIGHT,
|
||||
iconSize: ICON_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
borderRadius: BORDER_RADIUS,
|
||||
borderWidth: BORDER_WIDTH,
|
||||
opacity: OPACITY,
|
||||
} as const;
|
||||
|
||||
const darkShadow = {
|
||||
|
||||
Reference in New Issue
Block a user