chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)

This commit is contained in:
Mohamed Boudra
2026-04-24 01:28:12 +07:00
parent 339fc6b316
commit 16a48db75b
32 changed files with 3786 additions and 1998 deletions

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Keyboard } from "react-native";
import { View, Text, Pressable, Keyboard, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -166,6 +166,26 @@ const MODE_ICONS = {
ShieldOff,
} as const;
function alwaysTrue() {
return true;
}
function modeBadgeStyle({ pressed, hovered }: PressableStateCallbackType) {
return [styles.modeBadge, hovered && styles.modeBadgeHovered, pressed && styles.modeBadgePressed];
}
function modeIconBadgeStyle({ pressed, hovered }: PressableStateCallbackType) {
return [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
];
}
function sheetSelectStyle({ pressed }: PressableStateCallbackType) {
return [styles.sheetSelect, pressed && styles.sheetSelectPressed];
}
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: {
@@ -261,10 +281,6 @@ function ControlledStatusBar({
Boolean(thinkingOptions?.length) ||
Boolean(features?.length);
if (!hasAnyControl) {
return null;
}
const modelDisabled = disabled;
const SEARCH_THRESHOLD = 6;
@@ -299,7 +315,7 @@ function ControlledStatusBar({
}, [modelOptions, provider]);
const effectiveProviderDefinitions = providerDefinitions;
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const canSelectProviderInModelMenu = canSelectModelProvider ?? alwaysTrue;
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions],
@@ -342,12 +358,140 @@ function ControlledStatusBar({
[onDropdownClose],
);
const handleSelectorPress = useCallback(
(selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector);
},
[handleOpenChange, openSelector],
const handleProviderPress = useCallback(() => {
handleOpenChange("provider")(openSelector !== "provider");
}, [handleOpenChange, openSelector]);
const handleThinkingPress = useCallback(() => {
handleOpenChange("thinking")(openSelector !== "thinking");
}, [handleOpenChange, openSelector]);
const handleModePress = useCallback(() => {
handleOpenChange("mode")(openSelector !== "mode");
}, [handleOpenChange, openSelector]);
const handleProviderOpenChange = useMemo(() => handleOpenChange("provider"), [handleOpenChange]);
const handleThinkingOpenChange = useMemo(() => handleOpenChange("thinking"), [handleOpenChange]);
const handleModeOpenChange = useMemo(() => handleOpenChange("mode"), [handleOpenChange]);
const handleProviderSelect = useCallback(
(id: string) => onSelectProvider?.(id),
[onSelectProvider],
);
const handleThinkingSelect = useCallback(
(id: string) => onSelectThinkingOption?.(id),
[onSelectThinkingOption],
);
const handleModeSelect = useCallback((id: string) => onSelectMode?.(id), [onSelectMode]);
const handleDesktopModelSelect = useCallback(
(selectedProviderId: string, modelId: string) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
},
[onSelectModel, provider],
);
const providerPressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "provider") && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
],
[canSelectProvider, disabled, openSelector],
);
const thinkingPressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "thinking") && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
],
[canSelectThinking, disabled, openSelector],
);
const modePressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
],
[canSelectMode, disabled, openSelector],
);
const handleOpenPrefs = useCallback(() => {
Keyboard.dismiss();
setPrefsOpen(true);
}, []);
const handleClosePrefs = useCallback(() => {
setPrefsOpen(false);
}, []);
const prefsButtonStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
],
[],
);
const handleSheetModelSelect = useCallback(
(selectedProviderId: string, modelId: string) => {
if (onSelectProviderAndModel) {
onSelectProviderAndModel(selectedProviderId, modelId);
return;
}
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
},
[onSelectModel, onSelectProvider, onSelectProviderAndModel, provider],
);
const sheetThinkingPressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectThinking) && styles.disabledSheetSelect,
],
[canSelectThinking, disabled],
);
const sheetModePressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectMode) && styles.disabledSheetSelect,
],
[canSelectMode, disabled],
);
const renderSheetModelTrigger = useCallback(
({ selectedModelLabel }: { selectedModelLabel: string }) => (
<View
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
pointerEvents="none"
testID="agent-preferences-model"
>
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
),
[ProviderIcon, modelDisabled, theme.colors.foregroundMuted, theme.iconSize.md],
);
if (!hasAnyControl) {
return null;
}
return (
<View style={styles.container}>
@@ -359,13 +503,8 @@ function ControlledStatusBar({
ref={providerAnchorRef}
collapsable={false}
disabled={disabled || !canSelectProvider}
onPress={() => handleSelectorPress("provider")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "provider") && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
onPress={handleProviderPress}
style={providerPressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-provider-selector"
@@ -376,10 +515,10 @@ function ControlledStatusBar({
<Combobox
options={comboboxProviderOptions}
value={selectedProviderId ?? ""}
onSelect={(id) => onSelectProvider?.(id)}
onSelect={handleProviderSelect}
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
onOpenChange={handleProviderOpenChange}
anchorRef={providerAnchorRef}
desktopPlacement="top-start"
/>
@@ -401,11 +540,7 @@ function ControlledStatusBar({
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
}}
onSelect={handleDesktopModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
@@ -429,13 +564,8 @@ function ControlledStatusBar({
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => handleSelectorPress("thinking")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "thinking") && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
onPress={handleThinkingPress}
style={thinkingPressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select thinking option (${displayThinking})`}
testID="agent-thinking-selector"
@@ -452,10 +582,10 @@ function ControlledStatusBar({
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ""}
onSelect={(id) => onSelectThinkingOption?.(id)}
onSelect={handleThinkingSelect}
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
onOpenChange={handleThinkingOpenChange}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
/>
@@ -470,13 +600,8 @@ function ControlledStatusBar({
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
onPress={handleModePress}
style={modePressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
@@ -495,10 +620,10 @@ function ControlledStatusBar({
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)}
onSelect={handleModeSelect}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
onOpenChange={handleModeOpenChange}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
@@ -506,113 +631,22 @@ function ControlledStatusBar({
</>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
{features?.map((feature) => (
<DesktopFeatureItem
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleOpenChange}
onSetFeature={onSetFeature}
/>
))}
</>
) : (
<>
<Pressable
onPress={() => {
Keyboard.dismiss();
setPrefsOpen(true);
}}
style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]}
onPress={handleOpenPrefs}
style={prefsButtonStyle}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
@@ -628,7 +662,7 @@ function ControlledStatusBar({
<AdaptiveModalSheet
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
onClose={handleClosePrefs}
testID="agent-preferences-sheet"
>
{canSelectModel ? (
@@ -639,38 +673,14 @@ function ControlledStatusBar({
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (onSelectProviderAndModel) {
onSelectProviderAndModel(selectedProviderId, modelId);
} else {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}
}}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
pointerEvents="none"
testID="agent-preferences-model"
>
{ProviderIcon ? (
<ProviderIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
)}
renderTrigger={renderSheetModelTrigger}
/>
</View>
) : null}
@@ -679,15 +689,11 @@ function ControlledStatusBar({
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
onOpenChange={handleThinkingOpenChange}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectThinking) && styles.disabledSheetSelect,
]}
style={sheetThinkingPressableStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
@@ -698,13 +704,12 @@ function ControlledStatusBar({
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((thinking) => (
<DropdownMenuItem
<ThinkingMenuItem
key={thinking.id}
thinking={thinking}
selected={thinking.id === selectedThinkingOptionId}
onSelect={() => onSelectThinkingOption?.(thinking.id)}
>
{thinking.label}
</DropdownMenuItem>
onSelectThinkingOption={onSelectThinkingOption}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -713,17 +718,10 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
>
<DropdownMenu open={openSelector === "mode"} onOpenChange={handleModeOpenChange}>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectMode) && styles.disabledSheetSelect,
]}
style={sheetModePressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
@@ -735,101 +733,31 @@ function ControlledStatusBar({
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.label}
</DropdownMenuItem>
);
})}
{modeOptions.map((mode) => (
<ModeMenuItem
key={mode.id}
mode={mode}
provider={provider}
providerDefinitions={providerDefinitions}
selected={mode.id === selectedModeId}
onSelectMode={onSelectMode}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
{features?.map((feature) => (
<SheetFeatureItem
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleOpenChange}
onSetFeature={onSetFeature}
/>
))}
</AdaptiveModalSheet>
</>
)}
@@ -837,6 +765,310 @@ function ControlledStatusBar({
);
}
function DesktopFeatureItem({
feature,
disabled,
openSelector,
handleOpenChange,
onSetFeature,
}: {
feature: AgentFeature;
disabled: boolean;
openSelector: StatusSelector | null;
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void;
onSetFeature?: (featureId: string, value: unknown) => void;
}) {
const { theme } = useUnistyles();
const featureSelector: StatusSelector = `feature-${feature.id}`;
const handleFeatureOpenChange = useMemo(
() => handleOpenChange(featureSelector),
[handleOpenChange, featureSelector],
);
const handleTogglePress = useCallback(() => {
if (feature.type === "toggle") {
onSetFeature?.(feature.id, !feature.value);
}
}, [feature, onSetFeature]);
const handleSelectOption = useCallback(
(optionId: string) => {
onSetFeature?.(feature.id, optionId);
},
[feature.id, onSetFeature],
);
const togglePressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
],
[disabled],
);
const selectPressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === featureSelector) && styles.modeBadgePressed,
disabled && styles.disabledBadge,
],
[disabled, openSelector, featureSelector],
);
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={handleTogglePress}
style={togglePressableStyle}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu open={openSelector === featureSelector} onOpenChange={handleFeatureOpenChange}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={selectPressableStyle}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{selectedOption?.label ?? feature.label}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<FeatureOptionMenuItem
key={option.id}
option={option}
selected={option.id === feature.value}
onSelect={handleSelectOption}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
}
function SheetFeatureItem({
feature,
disabled,
openSelector,
handleOpenChange,
onSetFeature,
}: {
feature: AgentFeature;
disabled: boolean;
openSelector: StatusSelector | null;
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void;
onSetFeature?: (featureId: string, value: unknown) => void;
}) {
const { theme } = useUnistyles();
const featureSelector: StatusSelector = `feature-${feature.id}`;
const handleFeatureOpenChange = useMemo(
() => handleOpenChange(featureSelector),
[handleOpenChange, featureSelector],
);
const handleTogglePress = useCallback(() => {
if (feature.type === "toggle") {
onSetFeature?.(feature.id, !feature.value);
}
}, [feature, onSetFeature]);
const handleSelectOption = useCallback(
(optionId: string) => {
onSetFeature?.(feature.id, optionId);
},
[feature.id, onSetFeature],
);
const togglePressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
],
[disabled],
);
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={handleTogglePress}
style={togglePressableStyle}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
>
<DropdownMenuTrigger
disabled={disabled}
style={togglePressableStyle}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>{selectedOption?.label ?? feature.label}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<FeatureOptionMenuItem
key={option.id}
option={option}
selected={option.id === feature.value}
onSelect={handleSelectOption}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
}
function FeatureOptionMenuItem({
option,
selected,
onSelect,
}: {
option: { id: string; label: string };
selected: boolean;
onSelect: (optionId: string) => void;
}) {
const handleSelect = useCallback(() => {
onSelect(option.id);
}, [onSelect, option.id]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{option.label}
</DropdownMenuItem>
);
}
function ThinkingMenuItem({
thinking,
selected,
onSelectThinkingOption,
}: {
thinking: StatusOption;
selected: boolean;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
}) {
const handleSelect = useCallback(() => {
onSelectThinkingOption?.(thinking.id);
}, [onSelectThinkingOption, thinking.id]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{thinking.label}
</DropdownMenuItem>
);
}
function ModeMenuItem({
mode,
provider,
providerDefinitions,
selected,
onSelectMode,
}: {
mode: StatusOption;
provider: string;
providerDefinitions: AgentProviderDefinition[];
selected: boolean;
onSelectMode?: (modeId: string) => void;
}) {
const { theme } = useUnistyles();
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const handleSelect = useCallback(() => {
onSelectMode?.(mode.id);
}, [mode.id, onSelectMode]);
return (
<DropdownMenuItem
selected={selected}
onSelect={handleSelect}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.label}
</DropdownMenuItem>
);
}
const EMPTY_MODES: AgentMode[] = [];
export const AgentStatusBar = memo(function AgentStatusBar({

View File

@@ -7,8 +7,16 @@ import {
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
@@ -281,9 +289,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[],
);
function scrollToBottom() {
const scrollToBottom = useCallback(() => {
viewportRef.current?.scrollToBottom("jump-to-bottom");
}
}, []);
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
@@ -318,6 +326,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[assistantBlockGap, looseGap, tightGap],
);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
},
[streamRenderStrategy],
);
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -325,21 +351,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
items: StreamItem[],
seamAboveItem: StreamItem | null = null,
) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(item.id);
} else {
next.delete(item.id);
}
return next;
});
};
switch (item.kind) {
case "user_message": {
const aboveItem =
@@ -412,12 +423,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCall
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
@@ -447,7 +459,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return (
<ToolCall
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.name}
error={data.error}
status={data.status}
@@ -455,20 +469,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
const data = payload.data;
return (
<ToolCall
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
@@ -493,7 +507,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[handleInlinePathPress, agent.cwd, streamRenderStrategy],
[handleInlinePathPress, agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
);
const renderStreamItem = useCallback(
@@ -519,17 +533,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const getTurnContent = () =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy: streamRenderStrategy,
items,
startIndex: index,
});
return (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
{content}
{isEndOfAssistantTurn ? <TurnCopyButton getContent={getTurnContent} /> : null}
{isEndOfAssistantTurn ? (
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
</View>
);
},
@@ -786,6 +800,100 @@ function WorkingIndicator() {
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
> {
itemId: string;
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
}
function ToolCallSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
...rest
}: ToolCallSlotProps) {
const handleExpandedChange = useCallback(
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
[onInlineDetailsExpandedChangeByItemId, itemId],
);
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
function PermissionActionButton({
action,
isRespondingAction,
isResponding,
textColor,
iconColor,
isDanger,
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],
);
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>{action.label}</Text>
</View>
)}
</Pressable>
);
}
function PermissionRequestCard({
permission,
client,
@@ -940,31 +1048,19 @@ function PermissionRequestCard({
: `permission-request-action-${action.id}`;
return (
<Pressable
<PermissionActionButton
key={action.id}
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
Icon={Icon}
testID={testID}
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => handleActionPress(action)}
disabled={isResponding}
>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>
{action.label}
</Text>
</View>
)}
</Pressable>
theme={theme}
onPress={handleActionPress}
/>
);
})}
</View>

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useMemo, useState } from "react";
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -34,14 +34,18 @@ export function AttachmentPill({
() => [styles.closeButton, !showRemove && styles.closeButtonHidden],
[showRemove],
);
const handleBodyHoverIn = useCallback(() => setIsBodyHovered(true), []);
const handleBodyHoverOut = useCallback(() => setIsBodyHovered(false), []);
const handleCloseHoverIn = useCallback(() => setIsCloseHovered(true), []);
const handleCloseHoverOut = useCallback(() => setIsCloseHovered(false), []);
return (
<View style={styles.wrapper}>
<Pressable
testID={testID}
onPress={onOpen}
disabled={disabled}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
onHoverIn={handleBodyHoverIn}
onHoverOut={handleBodyHoverOut}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
@@ -51,8 +55,8 @@ export function AttachmentPill({
<Pressable
onPress={onRemove}
disabled={disabled}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
onHoverIn={handleCloseHoverIn}
onHoverOut={handleCloseHoverOut}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={removeAccessibilityLabel}

View File

@@ -6,6 +6,7 @@ import {
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -19,6 +20,38 @@ const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = [];
function noop() {}
function favoriteButtonStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.favoriteButton,
Boolean(hovered) && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
];
}
function drillDownRowStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.drillDownRow,
Boolean(hovered) && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
];
}
function backButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.backButton,
Boolean(hovered) && styles.backButtonHovered,
pressed && styles.backButtonPressed,
];
}
import { getProviderIcon } from "@/components/provider-icons";
import {
buildModelRows,
@@ -185,11 +218,7 @@ function ModelRow({
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={({ pressed, hovered }) => [
styles.favoriteButton,
hovered && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
]}
style={favoriteButtonStyle}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
@@ -214,6 +243,41 @@ function ModelRow({
);
}
interface SelectableModelRowProps {
row: SelectorModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
elevated?: boolean;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}
function SelectableModelRow({
row,
isSelected,
isFavorite,
disabled,
elevated,
onSelect,
onToggleFavorite,
}: SelectableModelRowProps) {
const handlePress = useCallback(() => {
onSelect(row.provider, row.modelId);
}, [onSelect, row.provider, row.modelId]);
return (
<ModelRow
row={row}
isSelected={isSelected}
isFavorite={isFavorite}
disabled={disabled}
elevated={elevated}
onPress={handlePress}
onToggleFavorite={onToggleFavorite}
/>
);
}
function FavoritesSection({
favoriteRows,
selectedProvider,
@@ -243,14 +307,14 @@ function FavoritesSection({
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
{favoriteRows.map((row) => (
<ModelRow
<SelectableModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
elevated
onPress={() => onSelect(row.provider, row.modelId)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
))}
@@ -258,8 +322,39 @@ function FavoritesSection({
);
}
interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
onDrillDown: (providerId: string, providerLabel: string) => void;
}
function GroupProviderButton({
providerId,
providerLabel,
rowCount,
onDrillDown,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
onDrillDown(providerId, providerLabel);
}, [onDrillDown, providerId, providerLabel]);
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{rowCount} {rowCount === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
);
}
function GroupedProviderRows({
providerDefinitions,
groupedRows,
selectedProvider,
selectedModel,
@@ -270,7 +365,6 @@ function GroupedProviderRows({
onDrillDown,
viewKind,
}: {
providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
selectedProvider: string;
selectedModel: string;
@@ -281,15 +375,9 @@ function GroupedProviderRows({
onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) {
const { theme } = useUnistyles();
return (
<View>
{groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find(
(definition) => definition.id === group.providerId,
);
const ProvIcon = getProviderIcon(group.providerId);
const isInline = viewKind === "provider";
return (
@@ -298,35 +386,24 @@ function GroupedProviderRows({
{isInline ? (
<>
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<ModelRow
<SelectableModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
<GroupProviderButton
providerId={group.providerId}
providerLabel={group.providerLabel}
rowCount={group.rows.length}
onDrillDown={onDrillDown}
/>
)}
</View>
);
@@ -453,7 +530,6 @@ function SelectorContent({
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
@@ -493,14 +569,7 @@ function ProviderBackButton({
}
return (
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<Pressable onPress={onBack} style={backButtonStyle}>
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
@@ -626,20 +695,38 @@ export function CombinedModelSelector({
return () => cancelAnimationFrame(frame);
}, [isOpen]);
const handleTriggerPress = useCallback(() => {
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
],
[disabled, isOpen, renderTrigger],
);
const handleBackToAll = useCallback(() => {
setView({ kind: "all" });
setSearchQuery("");
}, []);
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
setView({ kind: "provider", providerId, providerLabel });
}, []);
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={() => handleOpenChange(!isOpen)}
style={({ pressed, hovered }) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
]}
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
@@ -647,7 +734,7 @@ export function CombinedModelSelector({
{renderTrigger ? (
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: () => handleOpenChange(!isOpen),
onPress: handleTriggerPress,
disabled,
isOpen,
})
@@ -666,7 +753,7 @@ export function CombinedModelSelector({
<Combobox
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]}
value=""
onSelect={() => {}}
onSelect={noop}
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
@@ -681,10 +768,7 @@ export function CombinedModelSelector({
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={() => {
setView({ kind: "all" });
setSearchQuery("");
}}
onBack={handleBackToAll}
/>
) : null}
<ProviderSearchInput
@@ -709,9 +793,7 @@ export function CombinedModelSelector({
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onDrillDown={handleDrillDown}
/>
) : (
<View style={styles.sheetLoadingState}>

View File

@@ -1,5 +1,13 @@
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
@@ -29,22 +37,130 @@ const CommandCenterRow = memo(function CommandCenterRow({
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
],
[active, theme.colors.surface1],
);
return (
<Pressable
ref={registerRow}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={onPress}
>
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
{children}
</Pressable>
);
});
interface CommandCenterRowContainerProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void;
children: ReactNode;
}
function CommandCenterRowContainer({
rowIndex,
active,
rowRefs,
onPress,
children,
}: CommandCenterRowContainerProps) {
const registerRow = useCallback(
(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
},
[rowRefs, rowIndex],
);
return (
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
{children}
</CommandCenterRow>
);
}
interface CommandCenterActionRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "action" }>;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
}
function CommandCenterActionRow({
item,
rowIndex,
active,
rowRefs,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
) : null;
return (
<CommandCenterRowContainer
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onPress={handlePress}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
<View style={styles.textContent}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
{action.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRowContainer>
);
}
interface CommandCenterAgentRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "agent" }>;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode;
}
function CommandCenterAgentRow({
rowIndex,
active,
rowRefs,
onSelect,
item,
children,
}: CommandCenterAgentRowProps) {
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
return (
<CommandCenterRowContainer
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onPress={handlePress}
>
{children}
</CommandCenterRowContainer>
);
}
export function CommandCenter() {
const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
@@ -141,50 +257,16 @@ export function CommandCenter() {
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Actions
</Text>
{actionItems.map((item, index) => {
const active = index === activeIndex;
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
) : null;
return (
<CommandCenterRow
key={`action:${action.id}`}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(index, el);
else rowRefs.current.delete(index);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? (
<View style={styles.iconSlot}>{actionIcon}</View>
) : null}
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{action.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>
);
})}
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
@@ -200,17 +282,15 @@ export function CommandCenter() {
</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItems.length + index;
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<CommandCenterRow
<CommandCenterAgentRow
key={agentKey(agent)}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
}}
active={active}
onPress={() => handleSelectItem(item)}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
@@ -237,7 +317,7 @@ export function CommandCenter() {
</View>
</View>
</View>
</CommandCenterRow>
</CommandCenterAgentRow>
);
})}
</>

View File

@@ -1,4 +1,11 @@
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
import {
View,
Pressable,
Text,
ActivityIndicator,
Image,
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -81,6 +88,42 @@ type AttachmentListUpdater =
| ComposerAttachment[]
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
function noop() {}
interface QueuedMessageRowProps {
item: QueuedMessage;
onEdit: (id: string) => void;
onSendNow: (id: string) => void;
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
const { theme } = useUnistyles();
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
const handleSendNow = useCallback(() => {
onSendNow(item.id);
}, [onSendNow, item.id]);
return (
<View style={styles.queueItem}>
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={handleSendNow}
style={[styles.queueActionButton, styles.queueSendButton]}
>
<ArrowUp size={theme.iconSize.sm} color="white" />
</Pressable>
</View>
</View>
);
}
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
@@ -89,6 +132,129 @@ function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
interface ImageAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "image" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
}
function ImageAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
}: ImageAttachmentPillProps) {
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-image-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
disabled={disabled}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
);
}
interface GithubAttachmentPillProps {
attachment: Exclude<ComposerAttachment, { kind: "image" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
}
function GithubAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
}: GithubAttachmentPillProps) {
const { theme } = useUnistyles();
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
disabled={disabled}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
#{item.number} {item.title}
</Text>
</View>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
active: boolean;
selected: boolean;
item: GitHubSearchItem;
onToggle: (item: GitHubSearchItem) => void;
}
function GithubPickerOption({
label,
testID,
active,
selected,
item,
onToggle,
}: GithubPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
return (
<ComboboxItem
testID={testID}
label={label}
selected={selected}
active={active}
onPress={handlePress}
leadingSlot={
item.kind === "pr" ? (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)
}
/>
);
}
interface ComposerProps {
agentId: string;
serverId: string;
@@ -482,23 +648,26 @@ export function Composer({
addImages(newImages);
}, [addImages, pickImages]);
function handleRemoveAttachment(index: number) {
setSelectedAttachments((prev) => {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
}
return prev.filter((_, i) => i !== index);
});
}
const handleRemoveAttachment = useCallback(
(index: number) => {
setSelectedAttachments((prev) => {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
}
return prev.filter((_, i) => i !== index);
});
},
[deleteAttachments, setSelectedAttachments],
);
function handleOpenAttachment(attachment: ComposerAttachment) {
const handleOpenAttachment = useCallback((attachment: ComposerAttachment) => {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
void openExternalUrl(attachment.item.url);
}
}, []);
useEffect(() => {
if (!isAgentRunning || !isConnected) {
@@ -616,30 +785,36 @@ export function Composer({
});
}, [agentId, hasAgent, isConnected, serverId, voice]);
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
const handleEditQueuedMessage = useCallback(
(id: string) => {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(item.attachments);
}
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(item.attachments);
},
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
);
async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
const handleSendQueuedNow = useCallback(
async (id: string) => {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));
updateQueue((current) => current.filter((q) => q.id !== id));
// Reuse the regular send path; server-side send atomically interrupts any active run.
try {
await submitMessage(item.text, item.attachments);
} catch (error) {
updateQueue((current) => [item, ...current]);
setSendError(error instanceof Error ? error.message : "Failed to send message");
}
}
// Reuse the regular send path; server-side send atomically interrupts any active run.
try {
await submitMessage(item.text, item.attachments);
} catch (error) {
updateQueue((current) => [item, ...current]);
setSendError(error instanceof Error ? error.message : "Failed to send message");
}
},
[queuedMessages, submitMessage, updateQueue],
);
const handleQueue = useCallback(
(payload: MessagePayload) => {
@@ -712,13 +887,7 @@ export function Composer({
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
style={realtimeVoiceButtonStyle}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
@@ -877,9 +1046,63 @@ export function Composer({
[onAttentionInputFocus],
);
const handleLightboxClose = useCallback(() => {
setLightboxMetadata(null);
}, []);
const handleGithubPickerOpenChange = useCallback(
(open: boolean) => {
setIsGithubPickerOpen(open);
if (!open) {
setGithubSearchQuery("");
}
},
[setGithubSearchQuery],
);
const renderGithubPickerOption = useCallback(
({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => {
const item = githubSearchItems.find((candidate) => {
return `${candidate.kind}:${candidate.number}` === option.id;
});
if (!item) {
return <View key={option.id} />;
}
const selected = selectedAttachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return (
<GithubPickerOption
key={option.id}
testID={`composer-github-option-${option.id}`}
label={option.label}
selected={selected}
active={active}
item={item}
onToggle={handleToggleGithubItem}
/>
);
},
[githubSearchItems, selectedAttachments, handleToggleGithubItem],
);
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
const realtimeVoiceButtonStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.realtimeVoiceButton as any,
(Boolean(hovered) ? styles.iconButtonHovered : undefined) as any,
(voiceButtonDisabled ? styles.buttonDisabled : undefined) as any,
],
[voiceButtonDisabled],
);
return (
<Animated.View style={[styles.container, keyboardAnimatedStyle]}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
<View style={styles.inputAreaContent}>
@@ -887,25 +1110,12 @@ export function Composer({
{queuedMessages.length > 0 && (
<View style={styles.queueContainer}>
{queuedMessages.map((item) => (
<View key={item.id} style={styles.queueItem}>
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable
onPress={() => handleEditQueuedMessage(item.id)}
style={styles.queueActionButton}
>
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={() => handleSendQueuedNow(item.id)}
style={[styles.queueActionButton, styles.queueSendButton]}
>
<ArrowUp size={theme.iconSize.sm} color="white" />
</Pressable>
</View>
</View>
<QueuedMessageRow
key={item.id}
item={item}
onEdit={handleEditQueuedMessage}
onSendNow={handleSendQueuedNow}
/>
))}
</View>
)}
@@ -933,51 +1143,25 @@ export function Composer({
{selectedAttachments.map((attachment, index) => {
if (attachment.kind === "image") {
return (
<AttachmentPill
<ImageAttachmentPill
key={`${attachment.metadata.id}-${index}`}
testID="composer-image-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
attachment={attachment}
index={index}
disabled={isComposerLocked}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
onOpen={handleOpenAttachment}
onRemove={handleRemoveAttachment}
/>
);
}
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
return (
<AttachmentPill
key={`${item.kind}:${item.number}`}
testID="composer-github-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
attachment={attachment}
index={index}
disabled={isComposerLocked}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
#{item.number} {item.title}
</Text>
</View>
</AttachmentPill>
onOpen={handleOpenAttachment}
onRemove={handleRemoveAttachment}
/>
);
})}
</View>
@@ -1025,56 +1209,18 @@ export function Composer({
<Combobox
options={githubSearchOptions}
value=""
onSelect={() => {}}
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder="Search issues and PRs..."
title="Attach issue or PR"
open={isGithubPickerOpen}
onOpenChange={(open) => {
setIsGithubPickerOpen(open);
if (!open) {
setGithubSearchQuery("");
}
}}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."}
renderOption={({ option, active }) => {
const item = githubSearchItems.find((candidate) => {
return `${candidate.kind}:${candidate.number}` === option.id;
});
if (!item) {
return <View key={option.id} />;
}
const selected = selectedAttachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return (
<ComboboxItem
key={option.id}
testID={`composer-github-option-${option.id}`}
label={option.label}
selected={selected}
active={active}
onPress={() => handleToggleGithubItem(item)}
leadingSlot={
item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)
}
/>
);
}}
renderOption={renderGithubPickerOption}
/>
</View>
</View>

View File

@@ -126,6 +126,9 @@ export function ExplorerSidebar({
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
@@ -286,7 +289,7 @@ export function ExplorerSidebar({
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("header-close-button")}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -319,7 +322,7 @@ export function ExplorerSidebar({
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
onClose={handleDesktopClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -333,6 +336,38 @@ export function ExplorerSidebar({
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
label?: string;
onTabPress: (tab: ExplorerTab) => void;
testID: string;
children?: React.ReactNode;
}
function ExplorerTabButton({
tab,
active,
label,
onTabPress,
testID,
children,
}: ExplorerTabButtonProps) {
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
return (
<Pressable
testID={testID}
style={[styles.tab, active && styles.tabActive]}
onPress={handlePress}
>
{children}
{label !== undefined ? (
<Text style={[styles.tabText, active && styles.tabTextActive]}>{label}</Text>
) : null}
</Pressable>
);
}
interface SidebarContentProps {
activeTab: ExplorerTab;
onTabPress: (tab: ExplorerTab) => void;
@@ -381,30 +416,28 @@ function SidebarContent({
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label="Changes"
onTabPress={onTabPress}
testID="explorer-tab-changes"
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
Changes
</Text>
</Pressable>
/>
)}
<Pressable
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label="Files"
onTabPress={onTabPress}
testID="explorer-tab-files"
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
Files
</Text>
</Pressable>
/>
{isGit && hasPullRequest && (
<Pressable
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
label={prTabLabel}
onTabPress={onTabPress}
testID="explorer-tab-pr"
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
onPress={() => onTabPress("pr")}
>
<GitHubIcon
size={13}
@@ -412,10 +445,7 @@ function SidebarContent({
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
{prTabLabel}
</Text>
</Pressable>
</ExplorerTabButton>
)}
</View>
<View style={styles.headerRightSection}>

View File

@@ -7,6 +7,7 @@ import {
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -61,6 +62,157 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
interface TreeRowItemProps {
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
isSelected: boolean;
loading: boolean;
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
}
function sortTriggerStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
}
function treeRowKeyExtractor(row: TreeRow) {
return row.entry.path;
}
function TreeRowItem({
entry,
depth,
isExpanded,
isSelected,
loading,
onEntryPress,
onCopyPath,
onDownloadEntry,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const isDirectory = entry.kind === "directory";
const handlePress = useCallback(() => {
onEntryPress(entry);
}, [onEntryPress, entry]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected, theme.spacing],
);
const handleCopy = useCallback(() => {
void onCopyPath(entry.path);
}, [onCopyPath, entry.path]);
const handleDownload = useCallback(() => {
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
return (
<Pressable onPress={handlePress} style={pressableStyle}>
{depth > 0 &&
Array.from({ length: depth }, (_, i) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
) : (
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
{entry.name}
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Size
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Modified
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={handleCopy}
>
Copy path
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem
leading={<Download size={14} color={theme.colors.foregroundMuted} />}
onSelect={handleDownload}
>
Download
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
);
}
interface FileExplorerPaneProps {
serverId: string;
workspaceId?: string | null;
@@ -335,94 +487,16 @@ export function FileExplorerPane({
const loading = isDirectory && isDirectoryLoading(entry.path);
return (
<Pressable
onPress={() => handleEntryPress(entry)}
style={({ hovered, pressed }) => [
styles.entryRow,
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
(hovered || pressed || isSelected) && styles.entryRowActive,
]}
>
{depth > 0 &&
Array.from({ length: depth }, (_, i) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
) : (
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
{entry.name}
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
onPressIn={(event) => event.stopPropagation?.()}
style={({ hovered, pressed, open }) => [
styles.menuButton,
(hovered || pressed || open) && styles.menuButtonActive,
]}
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Size
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Modified
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={() => {
void handleCopyPath(entry.path);
}}
>
Copy path
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem
leading={<Download size={14} color={theme.colors.foregroundMuted} />}
onSelect={() => handleDownloadEntry(entry)}
>
Download
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
<TreeRowItem
entry={entry}
depth={depth}
isExpanded={isExpanded}
isSelected={isSelected}
loading={loading}
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
/>
);
},
[
@@ -432,8 +506,6 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
theme.colors,
theme.spacing,
],
);
@@ -448,6 +520,13 @@ export function FileExplorerPane({
});
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleRetry = useCallback(() => {
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
}, [requestDirectoryListing]);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -467,15 +546,7 @@ export function FileExplorerPane({
<Text style={styles.retryButtonText}>Back</Text>
</Pressable>
) : null}
<Pressable
style={styles.retryButton}
onPress={() => {
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
}}
>
<Pressable style={styles.retryButton} onPress={handleRetry}>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
@@ -492,13 +563,7 @@ export function FileExplorerPane({
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={({ hovered, pressed }) => [
styles.sortTrigger,
(hovered || pressed) && styles.sortTriggerHovered,
]}
>
<Pressable onPress={handleSortCycle} style={sortTriggerStyle}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -506,10 +571,7 @@ export function FileExplorerPane({
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
>
@@ -527,7 +589,7 @@ export function FileExplorerPane({
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}

View File

@@ -1,5 +1,11 @@
import { useCallback, useMemo } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { useCallback, useMemo, type ReactElement } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import {
@@ -19,6 +25,45 @@ interface GitActionsSplitButtonProps {
hideLabels?: boolean;
}
interface GitActionMenuItemProps {
action: GitAction;
onSelect: (action: GitAction) => void;
trailing?: ReactElement | null;
needsSeparator?: boolean;
showSeparator?: boolean;
closeOnSelect?: boolean;
}
function GitActionMenuItem({
action,
onSelect,
trailing,
needsSeparator,
showSeparator,
closeOnSelect,
}: GitActionMenuItemProps) {
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
return (
<View>
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={trailing}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={closeOnSelect}
onSelect={handleSelect}
>
{action.label}
</DropdownMenuItem>
</View>
);
}
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
@@ -46,17 +91,31 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
const primaryDisabled = gitActions.primary?.disabled;
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
[primaryDisabled],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
],
[],
);
return (
<View style={styles.row}>
{gitActions.primary ? (
<View style={styles.splitButton}>
<Pressable
testID="changes-primary-cta"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
gitActions.primary!.disabled && styles.splitButtonPrimaryDisabled,
]}
style={primaryPressableStyle}
onPress={gitActions.primary.handler}
disabled={gitActions.primary.disabled}
accessibilityRole="button"
@@ -83,47 +142,32 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-primary-cta-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
style={caretTriggerStyle}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator =
action.id === "merge-from-base" || action.id === "archive-worktree";
return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined
}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={
action.status === "idle" &&
action.id === "pr" &&
action.label === "View PR"
}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
</View>
);
})}
{gitActions.secondary.map((action, index) => (
<GitActionMenuItem
key={action.id}
action={action}
onSelect={handleActionSelect}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined
}
needsSeparator={
action.id === "merge-from-base" || action.id === "archive-worktree"
}
showSeparator={index > 0}
closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR"
}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -142,20 +186,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{gitActions.menu.map((action) => (
<DropdownMenuItem
<GitActionMenuItem
key={action.id}
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
action={action}
onSelect={handleActionSelect}
closeOnSelect={false}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
/>
))}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -10,6 +10,7 @@ import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type PressableStateCallbackType,
TextStyle,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
@@ -87,6 +88,29 @@ function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
}
function diffModeTriggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.diffModeTrigger,
Boolean(hovered) && styles.diffModeTriggerHovered,
(pressed || Boolean(open)) && styles.diffModeTriggerPressed,
];
}
function expandAllButtonStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
}
type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps {
@@ -430,46 +454,51 @@ const DiffFileHeader = memo(function DiffFileHeader({
onToggle(file.path);
}, [file.path, onToggle]);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
},
[file.path, onHeaderHeightChange],
);
const handlePressIn = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
pressHandledRef.current = false;
pressInRef.current = {
ts: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
}, []);
const handlePressOut = useCallback(
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
if (isNative && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current) {
const durationMs = Date.now() - pressInRef.current.ts;
const dx = event.nativeEvent.pageX - pressInRef.current.pageX;
const dy = event.nativeEvent.pageY - pressInRef.current.pageY;
const distance = Math.hypot(dx, dy);
if (durationMs <= 500 && distance <= 12) {
toggleExpanded();
}
}
},
[toggleExpanded],
);
return (
<View
style={[styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded]}
onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
onLayout={handleLayout}
testID={testID}
>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={({ pressed }) => [styles.fileHeader, pressed && styles.fileHeaderPressed]}
style={fileHeaderPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={(event) => {
pressHandledRef.current = false;
pressInRef.current = {
ts: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
}}
onPressOut={(event) => {
if (
isNative &&
!pressHandledRef.current &&
layoutYRef.current === 0 &&
pressInRef.current
) {
const durationMs = Date.now() - pressInRef.current.ts;
const dx = event.nativeEvent.pageX - pressInRef.current.pageX;
const dy = event.nativeEvent.pageY - pressInRef.current.pageY;
const distance = Math.hypot(dx, dy);
// Sticky headers on Android can emit pressIn/pressOut without onPress.
// Treat short, low-movement interactions as taps.
if (durationMs <= 500 && distance <= 12) {
toggleExpanded();
}
}
}}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
@@ -512,13 +541,18 @@ function DiffFileBody({
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
setBodyWidth(event.nativeEvent.layout.width);
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
},
[file.path, onBodyHeightChange],
);
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
setBodyWidth(event.nativeEvent.layout.width);
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
onLayout={handleLayout}
testID={testID}
>
{(() => {
@@ -658,6 +692,60 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const handleSelectUncommitted = useCallback(() => {
setDiffModeOverride("uncommitted");
}, []);
const handleSelectBase = useCallback(() => {
setDiffModeOverride("base");
}, []);
const handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
}, [handleLayoutChange]);
const handleLayoutSplit = useCallback(() => {
handleLayoutChange("split");
}, [handleLayoutChange]);
const unifiedToggleStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.toggleButton,
styles.toggleButtonGroupStart,
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
],
[changesPreferences.layout],
);
const splitToggleStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.toggleButton,
styles.toggleButtonGroupEnd,
changesPreferences.layout === "split" && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
],
[changesPreferences.layout],
);
const hideWhitespaceToggleStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.expandAllButton,
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
],
[changesPreferences.hideWhitespace],
);
const wrapLinesToggleStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.expandAllButton,
wrapLines && styles.toggleButtonSelected,
(Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
],
[wrapLines],
);
const {
status,
isLoading: isStatusLoading,
@@ -1327,11 +1415,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<View style={styles.diffStatusInner}>
<DropdownMenu>
<DropdownMenuTrigger
style={({ hovered, pressed, open }) => [
styles.diffModeTrigger,
hovered && styles.diffModeTriggerHovered,
(pressed || open) && styles.diffModeTriggerPressed,
]}
style={diffModeTriggerStyle}
testID="changes-diff-status"
accessibilityRole="button"
accessibilityLabel="Diff mode"
@@ -1345,7 +1429,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<DropdownMenuItem
testID="changes-diff-mode-uncommitted"
selected={diffMode === "uncommitted"}
onSelect={() => setDiffModeOverride("uncommitted")}
onSelect={handleSelectUncommitted}
>
Uncommitted
</DropdownMenuItem>
@@ -1354,7 +1438,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
testID="changes-diff-mode-committed"
selected={diffMode === "base"}
description={committedDiffDescription}
onSelect={() => setDiffModeOverride("base")}
onSelect={handleSelectBase}
>
Committed
</DropdownMenuItem>
@@ -1369,13 +1453,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
accessibilityRole="button"
accessibilityLabel="Unified diff"
testID="changes-layout-unified"
onPress={() => handleLayoutChange("unified")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupStart,
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleLayoutUnified}
style={unifiedToggleStyle}
>
<AlignJustify
size={14}
@@ -1397,13 +1476,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
accessibilityRole="button"
accessibilityLabel="Side-by-side diff"
testID="changes-layout-split"
onPress={() => handleLayoutChange("split")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupEnd,
changesPreferences.layout === "split" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleLayoutSplit}
style={splitToggleStyle}
>
<Columns2
size={14}
@@ -1427,11 +1501,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
accessibilityRole="button"
accessibilityLabel="Hide whitespace"
testID="changes-toggle-whitespace"
style={({ hovered, pressed }) => [
styles.expandAllButton,
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
style={hideWhitespaceToggleStyle}
onPress={handleToggleHideWhitespace}
>
<Pilcrow
@@ -1452,14 +1522,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
wrapLines && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<Pressable style={wrapLinesToggleStyle} onPress={handleToggleWrapLines}>
<WrapText
size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
@@ -1474,13 +1537,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
<Pressable style={expandAllButtonStyle} onPress={handleToggleExpandAll}>
{allExpanded ? (
<ListChevronsDownUp
size={isMobile ? 18 : 14}

View File

@@ -18,6 +18,9 @@ import {
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -288,6 +291,44 @@ export const LeftSidebar = memo(function LeftSidebar({
);
});
interface HostPickerTriggerProps {
triggerRef: React.Ref<View>;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
hostOptionsEmpty: boolean;
hostStatusDotStyle: StyleProp<ViewStyle>;
activeHostLabel: string;
}
function HostPickerTrigger({
triggerRef,
setIsHostPickerOpen,
hostOptionsEmpty,
hostStatusDotStyle,
activeHostLabel,
}: HostPickerTriggerProps) {
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
Boolean(hovered) && styles.hostTriggerHovered,
],
[],
);
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
return (
<Pressable
ref={triggerRef}
style={pressableStyle}
onPress={handlePress}
disabled={hostOptionsEmpty}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
);
}
function HostSwitchOption({
serverId,
label,
@@ -526,20 +567,13 @@ function MobileSidebar({
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
@@ -719,20 +753,13 @@ function DesktopSidebar({
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>

View File

@@ -826,13 +826,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
};
}, [getWebTextArea]);
function setBoundedInputHeight(nextHeight: number) {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
inputHeightRef.current = bounded;
setInputHeight(bounded);
onHeightChange?.(bounded);
}
const setBoundedInputHeight = useCallback(
(nextHeight: number) => {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
inputHeightRef.current = bounded;
setInputHeight(bounded);
onHeightChange?.(bounded);
},
[onHeightChange],
);
useComposerHeightMirror({
value,
@@ -842,29 +845,33 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onHeight: setBoundedInputHeight,
});
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
if (isWeb) return;
setBoundedInputHeight(event.nativeEvent.contentSize.height);
}
const handleContentSizeChange = useCallback(
(event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
if (isWeb) return;
setBoundedInputHeight(event.nativeEvent.contentSize.height);
},
[setBoundedInputHeight],
);
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
start,
end,
textareaScrollTop: textarea?.scrollTop ?? null,
textareaClientHeight: textarea?.clientHeight ?? null,
textareaScrollHeight: textarea?.scrollHeight ?? null,
});
}
onSelectionChangeCallback?.({ start, end });
}
const handleSelectionChange = useCallback(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
start,
end,
textareaScrollTop: textarea?.scrollTop ?? null,
textareaClientHeight: textarea?.clientHeight ?? null,
textareaScrollHeight: textarea?.scrollHeight ?? null,
});
}
onSelectionChangeCallback?.({ start, end });
},
[getWebTextArea, onSelectionChangeCallback],
);
const shouldHandleDesktopSubmit = isWeb;
@@ -939,6 +946,41 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[investigationComponentId, onChangeText],
);
const handleInputFocus = useCallback(() => {
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [onFocusChange]);
const attachButtonStyle = useCallback(
({ hovered }: { hovered?: boolean }) => [
styles.attachButton,
Boolean(hovered) && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
],
[isConnected, disabled],
);
const voiceButtonStyle = useCallback(
({ hovered }: { hovered?: boolean }) => [
styles.voiceButton,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
],
[isDictating, isDictationStartEnabled],
);
const handleRealtimeVoiceStop = useCallback(() => {
void handleStopRealtimeVoice();
}, [handleStopRealtimeVoice]);
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
@@ -955,16 +997,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
style={[
styles.textInput,
isWeb
@@ -1006,11 +1040,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityLabel="Add attachment"
accessibilityRole="button"
testID="message-input-attach-button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
style={attachButtonStyle}
>
{({ hovered }) => (
<View
@@ -1070,12 +1100,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
? "Stop dictation"
: "Start dictation"
}
style={({ hovered }) => [
styles.voiceButton,
hovered && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
style={voiceButtonStyle}
>
{({ hovered }) =>
isDictating ? (
@@ -1168,9 +1193,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
isMuted={voice.isMuted}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={() => {
void handleStopRealtimeVoice();
}}
onStop={handleRealtimeVoiceStop}
/>
) : null}
</Animated.View>

View File

@@ -375,6 +375,10 @@ export const UserMessage = memo(function UserMessage({
const hasImages = images.length > 0;
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
const handleHoverOut = useCallback(() => setMessageHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
return (
<View
style={[
@@ -388,8 +392,8 @@ export const UserMessage = memo(function UserMessage({
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={() => setMessageHovered(true)}
onHoverOut={() => setMessageHovered(false)}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -414,7 +418,7 @@ export const UserMessage = memo(function UserMessage({
</View>
{hasText ? (
<TurnCopyButton
getContent={() => message}
getContent={getMessageContent}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
@@ -717,6 +721,25 @@ function AssistantMarkdownImage({
);
}
interface InlinePathChipProps {
content: string;
parsed: InlinePathTarget;
onPress: (target: InlinePathTarget) => void;
}
function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) {
const handlePress = useCallback(() => onPress(parsed), [onPress, parsed]);
return (
<Text
onPress={handlePress}
selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
);
}
function MarkdownLink({
href,
style,
@@ -729,9 +752,12 @@ function MarkdownLink({
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
const handlePress = useCallback(() => onPress(href), [onPress, href]);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
if (isNative) {
return (
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
<Text accessibilityRole="link" onPress={handlePress} style={style}>
{children}
</Text>
);
@@ -740,9 +766,9 @@ function MarkdownLink({
return (
<Pressable
accessibilityRole="link"
onPress={() => onPress(href)}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
</Pressable>
@@ -848,11 +874,14 @@ export const TurnCopyButton = memo(function TurnCopyButton({
};
}, []);
const handleHoverIn = useCallback(() => onHoverChange?.(true), [onHoverChange]);
const handleHoverOut = useCallback(() => onHoverChange?.(false), [onHoverChange]);
return (
<Pressable
onPress={handleCopy}
onHoverIn={() => onHoverChange?.(true)}
onHoverOut={() => onHoverChange?.(false)}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
@@ -1247,16 +1276,14 @@ export const AssistantMessage = memo(function AssistantMessage({
const parsed =
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
if (parsed) {
if (parsed && onInlinePathPress) {
return (
<Text
<InlinePathChip
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={isWeb ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
content={content}
parsed={parsed}
onPress={onInlinePathPress}
/>
);
}
@@ -1564,13 +1591,13 @@ export const ActivityLog = memo(function ActivityLog({
const config = typeConfig[type];
const IconComponent = config.Icon;
const handlePress = () => {
const handlePress = useCallback(() => {
if (type === "artifact" && artifactId && onArtifactClick) {
onArtifactClick(artifactId);
} else if (metadata) {
setIsExpanded(!isExpanded);
setIsExpanded((prev) => !prev);
}
};
}, [type, artifactId, onArtifactClick, metadata]);
const displayMessage =
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
@@ -1828,6 +1855,19 @@ const ExpandableBadge = memo(function ExpandableBadge({
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => {
setIsHovered(false);
setIsPressed(false);
}, []);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]);
const handleDetailHoverOut = useCallback(
() => onDetailHoverChange?.(false),
[onDetailHoverChange],
);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
);
@@ -2053,17 +2093,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
<View style={containerStyle} testID={testID}>
<Pressable
onPress={isInteractive ? onToggle : undefined}
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
onHoverOut={
isInteractive
? () => {
setIsHovered(false);
setIsPressed(false);
}
: undefined
}
onPressIn={isInteractive ? () => setIsPressed(true) : undefined}
onPressOut={isInteractive ? () => setIsPressed(false) : undefined}
onHoverIn={isInteractive ? handleHoverIn : undefined}
onHoverOut={isInteractive ? handleHoverOut : undefined}
onPressIn={isInteractive ? handlePressIn : undefined}
onPressOut={isInteractive ? handlePressOut : undefined}
disabled={!isInteractive}
accessibilityRole={isInteractive ? "button" : undefined}
accessibilityState={accessibilityState}
@@ -2128,8 +2161,8 @@ const ExpandableBadge = memo(function ExpandableBadge({
<Pressable
ref={detailWrapperRef}
style={expandableBadgeStylesheet.detailWrapper}
onHoverIn={() => onDetailHoverChange?.(true)}
onHoverOut={() => onDetailHoverChange?.(false)}
onHoverIn={handleDetailHoverIn}
onHoverOut={handleDetailHoverOut}
>
{detailContent}
</Pressable>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -24,11 +24,31 @@ import type {
PrState,
} from "@/utils/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable];
}
function activityPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.activityRow, Boolean(hovered) && styles.hoverable];
}
export function PrPane({ data }: { data: PrPaneData }) {
const { theme } = useUnistyles();
const [checksOpen, setChecksOpen] = useState(true);
const [reviewsOpen, setReviewsOpen] = useState(true);
const handleOpenPrUrl = useCallback(() => {
void openExternalUrl(data.url);
}, [data.url]);
const handleToggleChecks = useCallback(() => {
setChecksOpen((o) => !o);
}, []);
const handleToggleReviews = useCallback(() => {
setReviewsOpen((o) => !o);
}, []);
const passed = data.checks.filter((c) => c.status === "success").length;
const failed = data.checks.filter((c) => c.status === "failure").length;
const pending = data.checks.filter((c) => c.status === "pending").length;
@@ -49,7 +69,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
return (
<View style={styles.root}>
<Pressable onPress={() => void openExternalUrl(data.url)} style={styles.header}>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
@@ -74,7 +94,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
<Section
title="Checks"
open={checksOpen}
onToggle={() => setChecksOpen((o) => !o)}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
@@ -105,7 +125,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
<Section
title="Reviews"
open={reviewsOpen}
onToggle={() => setReviewsOpen((o) => !o)}
onToggle={handleToggleReviews}
summary={
<>
<SummaryPill
@@ -187,11 +207,11 @@ function SummaryPill({
}
function CheckRow({ check }: { check: PrPaneCheck }) {
const handlePress = useCallback(() => {
void openExternalUrl(check.url);
}, [check.url]);
return (
<Pressable
onPress={() => void openExternalUrl(check.url)}
style={({ hovered }) => [styles.row, hovered && styles.hoverable]}
>
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.rowTitle} numberOfLines={1}>
{check.name}
@@ -216,11 +236,11 @@ function CheckStatusIcon({ status }: { status: CheckStatus }) {
function ActivityRow({ item }: { item: PrPaneActivity }) {
const verb = getActivityVerb(item);
const handlePress = useCallback(() => {
void openExternalUrl(item.url);
}, [item.url]);
return (
<Pressable
onPress={() => void openExternalUrl(item.url)}
style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]}
>
<Pressable onPress={handlePress} style={activityPressableStyle}>
<View style={[styles.avatar, { backgroundColor: item.avatarColor }]}>
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
</View>

View File

@@ -1,5 +1,12 @@
import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
@@ -63,6 +70,110 @@ interface QuestionFormCardProps {
const IS_WEB = isWeb;
interface QuestionOptionRowProps {
qIndex: number;
optIndex: number;
option: QuestionOption;
isSelected: boolean;
multiSelect: boolean;
isResponding: boolean;
onToggle: (qIndex: number, optIndex: number, multiSelect: boolean) => void;
}
function QuestionOptionRow({
qIndex,
optIndex,
option,
isSelected,
multiSelect,
isResponding,
onToggle,
}: QuestionOptionRowProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(qIndex, optIndex, multiSelect);
}, [onToggle, qIndex, optIndex, multiSelect]);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionItem,
(Boolean(hovered) || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
],
[isSelected, theme.colors.surface2],
);
return (
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{option.label}
</Text>
{option.description ? (
<Text style={[styles.optionDescription, { color: theme.colors.foregroundMuted }]}>
{option.description}
</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
}
interface QuestionOtherInputProps {
qIndex: number;
value: string;
isResponding: boolean;
onChange: (qIndex: number, text: string) => void;
onSubmit: () => void;
}
function QuestionOtherInput({
qIndex,
value,
isResponding,
onChange,
onSubmit,
}: QuestionOtherInputProps) {
const { theme } = useUnistyles();
const handleChange = useCallback(
(text: string) => {
onChange(qIndex, text);
},
[onChange, qIndex],
);
return (
<TextInput
style={[
styles.otherInput,
{
borderColor: value.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
// @ts-expect-error - outlineStyle is web-only
IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" },
]}
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={handleChange}
onSubmitEditing={onSubmit}
editable={!isResponding}
blurOnSubmit={false}
/>
);
}
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
@@ -110,22 +221,19 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
}, []);
if (!questions) {
return null;
}
const allAnswered =
questions?.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
}) ?? false;
const allAnswered = questions.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
});
function handleSubmit() {
if (!allAnswered || isResponding) return;
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
setRespondingAction("submit");
const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
const q = questions![i];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
@@ -141,14 +249,58 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
behavior: "allow",
updatedInput: { ...permission.request.input, answers },
});
}
}, [
questions,
allAnswered,
isResponding,
selections,
otherTexts,
onRespond,
permission.request.input,
]);
function handleDeny() {
const handleDeny = useCallback(() => {
setRespondingAction("dismiss");
onRespond({
behavior: "deny",
message: "Dismissed by user",
});
}, [onRespond]);
const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
);
const submitDisabled = !allAnswered || isResponding;
const submitButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
opacity: submitDisabled ? 0.5 : 1,
},
pressed && !submitDisabled ? styles.optionItemPressed : null,
],
[
submitDisabled,
theme.colors.surface2,
theme.colors.surface1,
theme.colors.border,
theme.colors.borderAccent,
],
);
if (!questions) {
return null;
}
return (
@@ -174,84 +326,32 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
return (
<Pressable
key={optIndex}
style={({ pressed, hovered = false }) => [
styles.optionItem,
(hovered || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
]}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
<Text
style={[
styles.optionDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
})}
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={optIndex}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
<TextInput
style={[
styles.otherInput,
{
borderColor:
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
// @ts-expect-error - outlineStyle is web-only
IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" },
]}
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
onSubmitEditing={handleSubmit}
editable={!isResponding}
blurOnSubmit={false}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
</View>
);
})}
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
]}
onPress={handleDeny}
disabled={isResponding}
>
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
@@ -264,23 +364,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
)}
</Pressable>
<Pressable
style={({ pressed, hovered = false }) => {
const disabled = !allAnswered || isResponding;
return [
styles.actionButton,
{
backgroundColor:
hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
opacity: disabled ? 0.5 : 1,
},
pressed && !disabled ? styles.optionItemPressed : null,
];
}}
onPress={handleSubmit}
disabled={!allAnswered || isResponding}
>
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (

View File

@@ -8,6 +8,7 @@ import {
StatusBar,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query";
@@ -226,6 +227,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
[hint.url],
);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
return (
<Pressable
accessibilityRole="link"
@@ -233,9 +237,9 @@ export function PrBadge({ hint }: { hint: PrHint }) {
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={prBadgePressableStyle}
>
{isHovered ? (
<ExternalLink size={12} color={activeColor} />
@@ -249,6 +253,24 @@ export function PrBadge({ hint }: { hint: PrHint }) {
);
}
function prBadgePressableStyle({ pressed }: PressableStateCallbackType) {
return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed];
}
function projectKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function noop() {}
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
@@ -514,20 +536,30 @@ function NewWorktreeButton({
const { theme } = useUnistyles();
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.projectIconActionButton,
!visible && styles.projectIconActionButtonHidden,
(Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
],
[visible, loading],
);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
return (
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
style={({ hovered, pressed }) => [
styles.projectIconActionButton,
!visible && styles.projectIconActionButtonHidden,
(hovered || pressed) && !loading && styles.projectIconActionButtonHovered,
]}
onPress={(event) => {
event.stopPropagation();
onPress();
}}
style={pressableStyle}
onPress={handlePress}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new workspace for ${displayName}`}
@@ -828,6 +860,20 @@ function ProjectHeaderRow({
onPress();
}, [interaction.didLongPressRef, onPress]);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
],
[isDragging, selected, isHovered],
);
const rowChildren = (
<>
<View style={styles.projectRowLeft}>
@@ -867,10 +913,7 @@ function ProjectHeaderRow({
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.projectKebabButton,
hovered && styles.projectKebabButtonHovered,
]}
style={projectKebabStyle}
accessibilityRole="button"
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${project.projectKey}`}
@@ -911,18 +954,12 @@ function ProjectHeaderRow({
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
style={projectRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -940,17 +977,11 @@ function ProjectHeaderRow({
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
style={({ pressed }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
style={projectRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -1008,6 +1039,20 @@ function WorkspaceRowInner({
onPress();
}, [interaction.didLongPressRef, onPress]);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const workspaceRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
],
[isDragging, selected, isHovered],
);
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
@@ -1021,21 +1066,15 @@ function WorkspaceRowInner({
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={{ selected }}
style={({ pressed }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
style={workspaceRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
@@ -1075,10 +1114,7 @@ function WorkspaceRowInner({
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
style={workspaceKebabStyle}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
@@ -1594,6 +1630,59 @@ function FlattenedProjectRow({
);
}
interface WorkspaceRowItemProps {
workspace: SidebarWorkspaceEntry;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
isCreating?: boolean;
selectionEnabled: boolean;
serverId: string | null;
currentPathname: string | null;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
function WorkspaceRowItem({
workspace,
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
isCreating = false,
selectionEnabled,
serverId,
currentPathname,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
}: WorkspaceRowItemProps) {
const handlePress = useCallback(() => {
if (!serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname });
}, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]);
return (
<WorkspaceRow
workspace={workspace}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={canCopyBranchName}
isCreating={isCreating}
selectionEnabled={selectionEnabled}
onPress={handlePress}
drag={drag ?? noop}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
}
function WorkspaceRow({
workspace,
shortcutNumber,
@@ -1674,7 +1763,7 @@ function ProjectBlock({
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onToggleCollapsed: () => void;
onToggleCollapsed: (projectKey: string) => void;
onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onWorktreeCreated?: (workspaceId: string) => void;
@@ -1714,22 +1803,18 @@ function ProjectBlock({
},
) => {
return (
<WorkspaceRow
<WorkspaceRowItem
workspace={item}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === "git"}
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
onPress={() => {
if (!serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
}}
drag={input?.drag ?? (() => {})}
isDragging={input?.isDragging ?? false}
serverId={serverId}
currentPathname={currentPathname}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
);
@@ -1814,6 +1899,22 @@ function ProjectBlock({
})();
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
const flattenedRowWorkspaceId =
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
const handleFlattenedRowPress = useCallback(() => {
if (!serverId || !flattenedRowWorkspaceId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, flattenedRowWorkspaceId, {
currentPathname,
});
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress, currentPathname]);
const handleToggleCollapsed = useCallback(() => {
onToggleCollapsed(project.projectKey);
}, [onToggleCollapsed, project.projectKey]);
return (
<View style={styles.projectBlock}>
{rowModel.kind === "workspace_link" ? (
@@ -1822,15 +1923,7 @@ function ProjectBlock({
displayName={displayName}
iconDataUri={iconDataUri}
rowModel={rowModel}
onPress={() => {
if (!serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
currentPathname,
});
}}
onPress={handleFlattenedRowPress}
serverId={serverId}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
@@ -1853,7 +1946,7 @@ function ProjectBlock({
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={onToggleCollapsed}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
@@ -2126,7 +2219,7 @@ export function SidebarWorkspaceList({
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef}
onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated}

View File

@@ -873,6 +873,37 @@ function SplitPaneView({
};
}, [stableOnFocusPane, pane.id]);
const paneId = pane.id;
const handleCloseTabsToLeft = useCallback(
(tabId: string) => onCloseTabsToLeft(tabId, paneTabs),
[onCloseTabsToLeft, paneTabs],
);
const handleCloseTabsToRight = useCallback(
(tabId: string) => onCloseTabsToRight(tabId, paneTabs),
[onCloseTabsToRight, paneTabs],
);
const handleCloseOtherTabs = useCallback(
(tabId: string) => onCloseOtherTabs(tabId, paneTabs),
[onCloseOtherTabs, paneTabs],
);
const handleReorderTabs = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
onReorderTabsInPane(
paneId,
nextTabs.map((tab) => tab.tabId),
);
},
[onReorderTabsInPane, paneId],
);
const handleSplitRight = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }),
[onSplitPaneEmpty, paneId],
);
const handleSplitDown = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }),
[onSplitPaneEmpty, paneId],
);
return (
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={[styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }]}>
@@ -890,19 +921,14 @@ function SplitPaneView({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(
pane.id,
nextTabs.map((tab) => tab.tabId),
);
}}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={

View File

@@ -1,6 +1,15 @@
"use dom";
import { useEffect, useMemo, useRef, useState, type Ref } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
} from "react";
import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
@@ -568,7 +577,7 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showTerminalContextMenu = useCallback(() => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
@@ -579,7 +588,42 @@ export default function TerminalEmulator({
kind: "terminal",
hasSelection,
});
};
}, []);
const handleRootPointerDown = useCallback(() => {
runtimeRef.current?.focus();
}, []);
const handleRootContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
showTerminalContextMenu();
},
[showTerminalContextMenu],
);
const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset;
const handleScrollbarPointerDown = useCallback(
(event: ReactPointerEvent) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
},
[scrollbarMaxOffset, viewportMetrics.offset],
);
const handleScrollbarPointerEnter = useCallback(() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}, [isScrollVisible, isDraggingScrollbar]);
const handleScrollbarPointerLeave = useCallback(() => {
setIsHandleHovered(false);
}, []);
return (
<div
@@ -598,13 +642,8 @@ export default function TerminalEmulator({
overscrollBehavior: "none",
touchAction: "pan-y",
}}
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
onPointerDown={handleRootPointerDown}
onContextMenu={handleRootContextMenu}
>
<div
ref={hostRef}
@@ -653,26 +692,9 @@ export default function TerminalEmulator({
transitionTimingFunction: "linear",
pointerEvents: handleVisible ? "auto" : "none",
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(
viewportMetrics.offset,
0,
scrollbarGeometry.maxScrollOffset,
);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
}}
onPointerEnter={() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}}
onPointerLeave={() => {
setIsHandleHovered(false);
}}
onPointerDown={handleScrollbarPointerDown}
onPointerEnter={handleScrollbarPointerEnter}
onPointerLeave={handleScrollbarPointerLeave}
>
<div
style={{

View File

@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
@@ -83,6 +90,54 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
interface ModifierButtonProps {
modifier: keyof ModifierState;
active: boolean;
onToggle: (modifier: keyof ModifierState) => void;
}
function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) {
const handlePress = useCallback(() => onToggle(modifier), [onToggle, modifier]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
active && styles.keyButtonActive,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[active],
);
return (
<Pressable testID={`terminal-key-${modifier}`} onPress={handlePress} style={pressableStyle}>
<Text style={[styles.keyButtonText, active && styles.keyButtonTextActive]}>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>
);
}
interface VirtualKeyButtonProps {
id: string;
label: string;
keyValue: string;
onSend: (key: string) => void;
}
function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps) {
const handlePress = useCallback(() => onSend(keyValue), [onSend, keyValue]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[],
);
return (
<Pressable testID={`terminal-key-${id}`} onPress={handlePress} style={pressableStyle}>
<Text style={styles.keyButtonText}>{label}</Text>
</Pressable>
);
}
export function TerminalPane({
serverId,
cwd,
@@ -548,6 +603,16 @@ export function TerminalPane({
[keyboardPaddingStyle],
);
const handleSwipeRight = useCallback(() => {
if (!swipeGesturesEnabled) return;
showMobileAgentList();
}, [swipeGesturesEnabled, showMobileAgentList]);
const handleSwipeLeft = useCallback(() => {
if (!swipeGesturesEnabled) return;
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
@@ -578,18 +643,8 @@ export function TerminalPane({
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onSwipeRight={() => {
if (!swipeGesturesEnabled) {
return;
}
showMobileAgentList();
}}
onSwipeLeft={() => {
if (!swipeGesturesEnabled) {
return;
}
onOpenFileExplorer();
}}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
@@ -623,39 +678,22 @@ export function TerminalPane({
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.keyboardRow}>
{(Object.keys(MODIFIER_LABELS) as Array<keyof ModifierState>).map((modifier) => (
<Pressable
<ModifierButton
key={modifier}
testID={`terminal-key-${modifier}`}
onPress={() => toggleModifier(modifier)}
style={({ hovered, pressed }) => [
styles.keyButton,
modifiers[modifier] && styles.keyButtonActive,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text
style={[
styles.keyButtonText,
modifiers[modifier] && styles.keyButtonTextActive,
]}
>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>
modifier={modifier}
active={modifiers[modifier]}
onToggle={toggleModifier}
/>
))}
{KEY_BUTTONS.map((button) => (
<Pressable
<VirtualKeyButton
key={button.id}
testID={`terminal-key-${button.id}`}
onPress={() => sendVirtualKey(button.key)}
style={({ hovered, pressed }) => [
styles.keyButton,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={styles.keyButtonText}>{button.label}</Text>
</Pressable>
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
</View>
</ScrollView>

View File

@@ -1,5 +1,14 @@
import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import {
ScrollView,
Text,
View,
Pressable,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
@@ -34,6 +43,79 @@ function removeBoltGlyphs(value?: string): string | undefined {
return cleaned.length > 0 ? cleaned : undefined;
}
interface AutocompleteRowProps {
index: number;
option: AutocompleteOption;
isSelected: boolean;
mutedColor: string;
onSelect: (option: AutocompleteOption) => void;
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
}
function AutocompleteRow({
index,
option,
isSelected,
mutedColor,
onSelect,
onRowLayout,
}: AutocompleteRowProps) {
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDescription = removeBoltGlyphs(option.description);
const isFileOrDir = option.kind === "directory" || option.kind === "file";
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onRowLayout(index, event),
[index, onRowLayout],
);
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
(Boolean(hovered) || pressed || isSelected) && styles.itemActive,
],
[isSelected],
);
return (
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={mutedColor} />
) : (
<File size={14} color={mutedColor} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
)}
</Pressable>
);
}
export function Autocomplete({
options,
selectedIndex,
@@ -109,6 +191,10 @@ export function Autocomplete({
[ensureActiveItemVisible],
);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
@@ -172,65 +258,23 @@ export function Autocomplete({
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDescription = removeBoltGlyphs(option.description);
const isFileOrDir = option.kind === "directory" || option.kind === "file";
return (
<Pressable
key={option.id}
onLayout={(event) => handleRowLayout(index, event)}
onPress={() => onSelect(option)}
style={({ hovered = false, pressed }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
)}
</Pressable>
);
})}
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
mutedColor={theme.colors.foregroundMuted}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
</View>
</View>

View File

@@ -22,7 +22,9 @@ import {
StatusBar,
Text,
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -295,6 +297,42 @@ export function ContextMenuTrigger({
[ctx.triggerRef, triggerRef],
);
const propsOnLongPress = props.onLongPress;
const handleLongPress = useCallback(
(event: GestureResponderEvent) => {
if (isWeb) {
propsOnLongPress?.(event);
return;
}
openAtEvent(event);
propsOnLongPress?.(event);
},
[propsOnLongPress, openAtEvent],
);
const handleContextMenu = useCallback(
(event: unknown) => {
if (isNative) {
return;
}
const e: any = event;
e?.preventDefault?.();
e?.stopPropagation?.();
openAtEvent(event as GestureResponderEvent);
},
[openAtEvent],
);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
},
[style, ctx.open],
);
return (
<Pressable
{...props}
@@ -302,30 +340,10 @@ export function ContextMenuTrigger({
collapsable={false}
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={(event) => {
if (isWeb) {
props.onLongPress?.(event);
return;
}
openAtEvent(event);
props.onLongPress?.(event);
}}
onLongPress={handleLongPress}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => {
if (isNative) {
return;
}
const e: any = event;
e?.preventDefault?.();
e?.stopPropagation?.();
openAtEvent(event);
}}
style={({ pressed, hovered = false }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
}}
onContextMenu={handleContextMenu}
style={pressableStyle}
>
{children}
</Pressable>
@@ -651,32 +669,39 @@ export function ContextMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
const handleItemPress = useCallback(() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}, [isDisabled, closeOnSelect, setOpen, onSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
],
[selected, selectedVariant, isDisabled],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}}
style={({ pressed, hovered }) => [
styles.item,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
]}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>

View File

@@ -178,6 +178,30 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router],
);
const handleOpenPaseoSite = useCallback(() => {
void openExternalUrl("https://paseo.sh");
}, []);
const handleOpenSettings = useCallback(() => {
router.push("/settings");
}, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile, serverId }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
},
[onHostAdded, finishOnboarding],
);
const actions: WelcomeAction[] = isWeb
? [
{
@@ -186,7 +210,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
@@ -194,7 +218,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
onPress: handleOpenPasteLink,
},
]
: [
@@ -204,7 +228,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
onPress: handleScanQr,
},
{
key: "direct-connection",
@@ -212,7 +236,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
@@ -220,7 +244,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
onPress: handleOpenPasteLink,
},
];
@@ -247,10 +271,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
@@ -287,7 +308,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={() => router.push("/settings")}
onPress={handleOpenSettings}
style={styles.settingsButton}
testID="welcome-open-settings"
>
@@ -298,20 +319,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
@@ -22,25 +22,40 @@ export function DesktopPermissionsSection() {
sendTestNotification,
} = useDesktopPermissions();
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const handleRefreshPress = useCallback(() => {
void refreshPermissions();
}, [refreshPermissions]);
const handleRequestNotifications = useCallback(() => {
void requestPermission("notifications");
}, [requestPermission]);
const handleRequestMicrophone = useCallback(() => {
void requestPermission("microphone");
}, [requestPermission]);
const handleSendTestNotification = useCallback(() => {
void sendTestNotification();
}, [sendTestNotification]);
if (!isDesktopApp) {
return null;
}
const isBusy = isRefreshing || requestingPermission !== null;
const notificationsGranted = snapshot?.notifications.state === "granted";
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const refreshButton = (
<Button
variant="ghost"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
onPress={() => {
void refreshPermissions();
}}
onPress={handleRefreshPress}
disabled={isBusy}
accessibilityLabel="Refresh desktop permissions"
>
@@ -55,15 +70,11 @@ export function DesktopPermissionsSection() {
title="Notifications"
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={() => {
void requestPermission("notifications");
}}
onRequest={handleRequestNotifications}
extraActionLabel="Test"
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={() => {
void sendTestNotification();
}}
onExtraAction={handleSendTestNotification}
/>
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
<DesktopPermissionRow
@@ -71,9 +82,7 @@ export function DesktopPermissionsSection() {
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={() => {
void requestPermission("microphone");
}}
onRequest={handleRequestMicrophone}
/>
</View>
</SettingsSection>

View File

@@ -230,6 +230,16 @@ export function LocalDaemonSection() {
});
}, [cliStatusOutput]);
const handleOpenAdvancedSettings = useCallback(
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
[],
);
const handleRunCliStatus = useCallback(() => {
void handleOpenCliStatus();
}, [handleOpenCliStatus]);
const handleCloseLogsModal = useCallback(() => setIsLogsModalOpen(false), []);
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
if (!showSection) {
return null;
}
@@ -241,7 +251,7 @@ export function LocalDaemonSection() {
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
onPress={handleOpenAdvancedSettings}
accessibilityLabel="Open advanced daemon settings"
>
Advanced settings
@@ -364,7 +374,7 @@ export function LocalDaemonSection() {
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
onPress={handleRunCliStatus}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
@@ -385,7 +395,7 @@ export function LocalDaemonSection() {
<AdaptiveModalSheet
visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
onClose={handleCloseLogsModal}
title="Daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={["70%", "92%"]}
@@ -402,7 +412,7 @@ export function LocalDaemonSection() {
<AdaptiveModalSheet
visible={isCliStatusModalOpen}
onClose={() => setIsCliStatusModalOpen(false)}
onClose={handleCloseCliStatusModal}
title="Daemon status"
testID="daemon-cli-status-dialog"
snapPoints={["60%", "85%"]}
@@ -412,7 +422,7 @@ export function LocalDaemonSection() {
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliStatusModalOpen(false)}>
<Button variant="outline" size="sm" onPress={handleCloseCliStatusModal}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliStatus}>

View File

@@ -274,30 +274,77 @@ vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
}));
vi.mock("@/components/composer", () => ({
Composer: ({
onSubmitMessage,
submitBehavior,
submitIcon,
isSubmitLoading,
value,
onChangeText,
attachments,
onChangeAttachments,
}: {
onSubmitMessage: (payload: {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}) => void;
submitBehavior?: "clear" | "preserve-and-lock";
submitIcon?: "arrow" | "return";
isSubmitLoading?: boolean;
value: string;
onChangeText: (text: string) => void;
interface ComposerMockProps {
onSubmitMessage: (payload: {
text: string;
attachments: ComposerAttachment[];
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}) => (
cwd: string;
}) => void;
submitBehavior?: "clear" | "preserve-and-lock";
submitIcon?: "arrow" | "return";
isSubmitLoading?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}
interface AttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
attachments: ComposerAttachment[];
isDisabled: boolean;
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}
function ComposerMockAttachmentPill({
attachment,
attachments,
isDisabled,
onChangeAttachments,
}: AttachmentPillProps) {
const handleRemove = React.useCallback(() => {
onChangeAttachments(
attachments.filter(
(candidate) =>
candidate.kind !== attachment.kind || candidate.item.number !== attachment.item.number,
),
);
}, [attachment, attachments, onChangeAttachments]);
return (
<div data-testid="composer-github-attachment-pill">
#{attachment.item.number} {attachment.item.title}
<button
type="button"
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${attachment.item.number}`}
disabled={isDisabled}
onClick={handleRemove}
>
Remove
</button>
</div>
);
}
function ComposerMock({
onSubmitMessage,
submitBehavior,
submitIcon,
isSubmitLoading,
value,
onChangeText,
attachments,
onChangeAttachments,
}: ComposerMockProps) {
const isDisabled = submitBehavior === "preserve-and-lock" && Boolean(isSubmitLoading);
const handleTextareaChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => onChangeText(event.currentTarget.value),
[onChangeText],
);
const handleSubmit = React.useCallback(
() => onSubmitMessage({ text: value, attachments, cwd: "/repo" }),
[onSubmitMessage, value, attachments],
);
return (
<div
data-testid="test-composer"
data-submit-behavior={submitBehavior}
@@ -305,54 +352,33 @@ vi.mock("@/components/composer", () => ({
>
<textarea
aria-label="Message agent..."
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
disabled={isDisabled}
value={value}
onChange={(event) => onChangeText(event.currentTarget.value)}
onChange={handleTextareaChange}
/>
<button
type="button"
data-testid="message-input-attach-button"
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
>
<button type="button" data-testid="message-input-attach-button" disabled={isDisabled}>
Attach
</button>
{attachments.map((attachment) =>
attachment.kind === "github_pr" || attachment.kind === "github_issue" ? (
<div
data-testid="composer-github-attachment-pill"
<ComposerMockAttachmentPill
key={`${attachment.kind}-${attachment.item.number}`}
>
#{attachment.item.number} {attachment.item.title}
<button
type="button"
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${
attachment.item.number
}`}
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
onClick={() =>
onChangeAttachments(
attachments.filter(
(candidate) =>
candidate.kind !== attachment.kind ||
candidate.item.number !== attachment.item.number,
),
)
}
>
Remove
</button>
</div>
attachment={attachment}
attachments={attachments}
isDisabled={isDisabled}
onChangeAttachments={onChangeAttachments}
/>
) : null,
)}
<button
type="button"
data-testid="test-composer-submit"
onClick={() => onSubmitMessage({ text: value, attachments, cwd: "/repo" })}
>
<button type="button" data-testid="test-composer-submit" onClick={handleSubmit}>
Submit
</button>
</div>
),
);
}
vi.mock("@/components/composer", () => ({
Composer: ComposerMock,
}));
vi.mock("@/components/composer-attachments", () => ({
@@ -404,6 +430,48 @@ vi.mock("@/components/ui/tooltip", () => ({
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
function ComboboxOptionButton({
option,
onSelect,
}: {
option: { id: string; label: string };
onSelect: (id: string) => void;
}) {
const handleClick = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
return (
<button type="button" onClick={handleClick}>
{option.label}
</button>
);
}
function ComboboxOptionRenderWrapper({
option,
onSelect,
renderOption,
}: {
option: { id: string; label: string };
onSelect: (id: string) => void;
renderOption: (input: {
option: { id: string; label: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
}) {
const handlePress = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
return (
<>
{renderOption({
option,
selected: false,
active: false,
onPress: handlePress,
})}
</>
);
}
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
open,
@@ -426,18 +494,14 @@ vi.mock("@/components/ui/combobox", () => ({
<div data-testid="ref-picker-combobox">
{options.map((option) =>
renderOption ? (
<React.Fragment key={option.id}>
{renderOption({
option,
selected: false,
active: false,
onPress: () => onSelect(option.id),
})}
</React.Fragment>
<ComboboxOptionRenderWrapper
key={option.id}
option={option}
onSelect={onSelect}
renderOption={renderOption}
/>
) : (
<button type="button" key={option.id} onClick={() => onSelect(option.id)}>
{option.label}
</button>
<ComboboxOptionButton key={option.id} option={option} onSelect={onSelect} />
),
)}
</div>

View File

@@ -1,6 +1,13 @@
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import { Alert, Pressable, ScrollView, Text, View } from "react-native";
import {
Alert,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -133,6 +140,22 @@ function ThemeSwatch({ color, size }: { color: string; size: number }) {
);
}
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
}
function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered];
}
function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return [
sidebarStyles.item,
Boolean(hovered) && sidebarStyles.itemHovered,
sidebarStyles.itemSelected,
];
}
const THEME_LABELS: Record<AppSettings["theme"], string> = {
light: "Light",
dark: "Dark",
@@ -153,6 +176,35 @@ interface GeneralSectionProps {
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
interface ThemeMenuItemProps {
themeValue: AppSettings["theme"];
selected: boolean;
iconSize: number;
iconColor: string;
onChange: (theme: AppSettings["theme"]) => void;
}
function ThemeMenuItem({
themeValue,
selected,
iconSize,
iconColor,
onChange,
}: ThemeMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(themeValue);
}, [onChange, themeValue]);
return (
<DropdownMenuItem
selected={selected}
onSelect={handleSelect}
leading={<ThemeIcon theme={themeValue} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[themeValue]}
</DropdownMenuItem>
);
}
function GeneralSection({
settings,
handleThemeChange,
@@ -170,34 +222,32 @@ function GeneralSection({
<Text style={settingsStyles.rowTitle}>Theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [styles.themeTrigger, pressed && { opacity: 0.85 }]}
>
<DropdownMenuTrigger style={themeTriggerStyle}>
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
<Text style={styles.themeTriggerText}>{THEME_LABELS[settings.theme]}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{(["light", "dark", "auto"] as const).map((t) => (
<DropdownMenuItem
<ThemeMenuItem
key={t}
themeValue={t}
selected={settings.theme === t}
onSelect={() => handleThemeChange(t)}
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[t]}
</DropdownMenuItem>
iconSize={iconSize}
iconColor={iconColor}
onChange={handleThemeChange}
/>
))}
<DropdownMenuSeparator />
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
<DropdownMenuItem
<ThemeMenuItem
key={t}
themeValue={t}
selected={settings.theme === t}
onSelect={() => handleThemeChange(t)}
leading={<ThemeIcon theme={t} size={iconSize} color={iconColor} />}
>
{THEME_LABELS[t]}
</DropdownMenuItem>
iconSize={iconSize}
iconColor={iconColor}
onChange={handleThemeChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -237,6 +287,9 @@ function DiagnosticsSection({
playbackTestResult,
handlePlaybackTest,
}: DiagnosticsSectionProps) {
const handlePlayPress = useCallback(() => {
void handlePlaybackTest();
}, [handlePlaybackTest]);
return (
<SettingsSection title="Diagnostics">
<View style={settingsStyles.card}>
@@ -250,7 +303,7 @@ function DiagnosticsSection({
<Button
variant="secondary"
size="sm"
onPress={() => void handlePlaybackTest()}
onPress={handlePlayPress}
disabled={!voiceAudioEngine || isPlaybackTestRunning}
>
{isPlaybackTestRunning ? "Playing..." : "Play test"}
@@ -431,6 +484,86 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
);
}
interface SidebarSectionButtonProps {
itemId: SettingsSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
isSelected: boolean;
onSelect: (section: SettingsSectionSlug) => void;
}
function SidebarSectionButton({
itemId,
label,
icon: IconComponent,
isSelected,
onSelect,
}: SidebarSectionButtonProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(itemId);
}, [onSelect, itemId]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
onPress={handlePress}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[sidebarStyles.label, isSelected && { color: theme.colors.foreground }]}
numberOfLines={1}
>
{label}
</Text>
</Pressable>
);
}
interface SidebarHostItemProps {
serverId: string;
label: string;
isSelected: boolean;
isLocal: boolean;
onSelect: (serverId: string) => void;
}
function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(serverId);
}, [onSelect, serverId]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
onPress={handlePress}
testID={`settings-host-entry-${serverId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<Server
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[sidebarStyles.label, isSelected && { color: theme.colors.foreground }]}
numberOfLines={1}
>
{label}
</Text>
{isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
Local
</Text>
) : null}
</Pressable>
);
}
interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
@@ -489,80 +622,35 @@ function SettingsSidebar({
/>
) : null}
<View style={sidebarStyles.list}>
{items.map((item) => {
const isSelected = selectedSectionId === item.id;
const IconComponent = item.icon;
return (
<Pressable
key={item.id}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
onPress={() => onSelectSection(item.id)}
style={({ hovered = false }) => [
sidebarStyles.item,
hovered && sidebarStyles.itemHovered,
isSelected && sidebarStyles.itemSelected,
]}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[sidebarStyles.label, isSelected && { color: theme.colors.foreground }]}
numberOfLines={1}
>
{item.label}
</Text>
</Pressable>
);
})}
{items.map((item) => (
<SidebarSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
))}
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
{sortedHosts.map((host) => {
const isSelected = selectedServerId === host.serverId;
const isLocal = localServerId !== null && host.serverId === localServerId;
return (
<Pressable
key={host.serverId}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
onPress={() => onSelectHost(host.serverId)}
testID={`settings-host-entry-${host.serverId}`}
style={({ hovered = false }) => [
sidebarStyles.item,
hovered && sidebarStyles.itemHovered,
isSelected && sidebarStyles.itemSelected,
]}
>
<Server
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[sidebarStyles.label, isSelected && { color: theme.colors.foreground }]}
numberOfLines={1}
>
{host.label}
</Text>
{isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
Local
</Text>
) : null}
</Pressable>
);
})}
{sortedHosts.map((host) => (
<SidebarHostItem
key={host.serverId}
serverId={host.serverId}
label={host.label}
isSelected={selectedServerId === host.serverId}
isLocal={localServerId !== null && host.serverId === localServerId}
onSelect={onSelectHost}
/>
))}
<Pressable
accessibilityRole="button"
accessibilityLabel="Add host"
onPress={onAddHost}
testID="settings-add-host"
style={({ hovered = false }) => [
sidebarStyles.item,
hovered && sidebarStyles.itemHovered,
]}
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
@@ -660,6 +748,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
setIsAddHostMethodVisible(true);
}, []);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}, []);
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostRoute(serverId);
@@ -813,14 +911,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}}
onPasteLink={() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}}
onDirectConnection={handleSelectDirectConnection}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal

View File

@@ -29,6 +29,11 @@ import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section
const RESTART_CONFIRMATION_MESSAGE =
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
const INJECT_TOOLS_OPTIONS = [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
];
function formatHostConnectionLabel(connection: HostConnection): string {
if (connection.type === "relay") {
return `Relay (${connection.relayEndpoint})`;
@@ -205,13 +210,19 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
setIsEditing(false);
}, [host.label, isSaving]);
const handleStartEdit = useCallback(() => {
setDraftLabel(host.label ?? "");
setIsEditing(true);
}, [host.label]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
return (
<>
<Pressable
onPress={() => {
setDraftLabel(host.label ?? "");
setIsEditing(true);
}}
onPress={handleStartEdit}
hitSlop={8}
style={styles.identityEditButton}
accessibilityRole="button"
@@ -237,7 +248,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
onSubmitEditing={() => void handleSave()}
onSubmitEditing={handleSavePress}
style={styles.renameInput}
testID="host-page-label-input"
/>
@@ -254,7 +265,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
<Button
size="sm"
style={{ flex: 1 }}
onPress={() => void handleSave()}
onPress={handleSavePress}
disabled={isSaving}
testID="host-page-label-save"
>
@@ -277,6 +288,35 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
} | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
const handleRequestRemove = useCallback((connection: HostConnection) => {
setPendingRemoveConnection({
connectionId: connection.id,
title: formatHostConnectionLabel(connection),
});
}, []);
const handleCloseConfirm = useCallback(() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}, [isRemovingConnection]);
const handleCancelConfirm = useCallback(() => {
setPendingRemoveConnection(null);
}, []);
const handleConfirmRemove = useCallback(() => {
if (!pendingRemoveConnection) return;
const { connectionId } = pendingRemoveConnection;
setIsRemovingConnection(true);
void removeConnection(host.serverId, connectionId)
.then(() => setPendingRemoveConnection(null))
.catch((error) => {
console.error("[HostPage] Failed to remove connection", error);
Alert.alert("Error", "Unable to remove connection");
})
.finally(() => setIsRemovingConnection(false));
}, [pendingRemoveConnection, removeConnection, host.serverId]);
return (
<SettingsSection title="Connections">
<View style={settingsStyles.card} testID="host-page-connections-card">
@@ -290,12 +330,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
latencyMs={probe?.status === "available" ? probe.latencyMs : undefined}
latencyLoading={!probe || probe.status === "pending"}
latencyError={probe?.status === "unavailable"}
onRemove={() => {
setPendingRemoveConnection({
connectionId: conn.id,
title: formatHostConnectionLabel(conn),
});
}}
onRemove={handleRequestRemove}
/>
);
})}
@@ -305,10 +340,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
<AdaptiveModalSheet
title="Remove connection"
visible
onClose={() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}}
onClose={handleCloseConfirm}
testID="remove-connection-confirm-modal"
>
<Text style={styles.confirmText}>
@@ -319,7 +351,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => setPendingRemoveConnection(null)}
onPress={handleCancelConfirm}
disabled={isRemovingConnection}
>
Cancel
@@ -328,17 +360,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
const { connectionId } = pendingRemoveConnection;
setIsRemovingConnection(true);
void removeConnection(host.serverId, connectionId)
.then(() => setPendingRemoveConnection(null))
.catch((error) => {
console.error("[HostPage] Failed to remove connection", error);
Alert.alert("Error", "Unable to remove connection");
})
.finally(() => setIsRemovingConnection(false));
}}
onPress={handleConfirmRemove}
disabled={isRemovingConnection}
testID="remove-connection-confirm"
>
@@ -364,7 +386,7 @@ function ConnectionRow({
latencyMs: number | null | undefined;
latencyLoading: boolean;
latencyError: boolean;
onRemove: () => void;
onRemove: (connection: HostConnection) => void;
}) {
const { theme } = useUnistyles();
const title = formatHostConnectionLabel(connection);
@@ -377,6 +399,10 @@ function ConnectionRow({
})();
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
const handlePressRemove = useCallback(() => {
onRemove(connection);
}, [onRemove, connection]);
return (
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
@@ -389,7 +415,7 @@ function ConnectionRow({
variant="ghost"
size="sm"
textStyle={{ color: theme.colors.destructive }}
onPress={onRemove}
onPress={handlePressRemove}
>
Remove
</Button>
@@ -545,6 +571,17 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const handleValueChange = useCallback(
(value: string) => {
void patchConfig({
mcp: {
injectIntoAgents: value === "on",
},
});
},
[patchConfig],
);
if (!isConnected) return null;
return (
@@ -559,17 +596,8 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
<SegmentedControl
size="sm"
value={config?.mcp.injectIntoAgents === false ? "off" : "on"}
onValueChange={(value) => {
void patchConfig({
mcp: {
injectIntoAgents: value === "on",
},
});
}}
options={[
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
]}
onValueChange={handleValueChange}
options={INJECT_TOOLS_OPTIONS}
/>
</View>
</View>
@@ -580,11 +608,14 @@ function PairDeviceRow() {
const { theme } = useUnistyles();
const [isModalOpen, setIsModalOpen] = useState(false);
const handleOpen = useCallback(() => setIsModalOpen(true), []);
const handleClose = useCallback(() => setIsModalOpen(false), []);
return (
<View style={settingsStyles.card}>
<Pressable
style={settingsStyles.row}
onPress={() => setIsModalOpen(true)}
onPress={handleOpen}
accessibilityRole="button"
testID="host-page-pair-device-row"
>
@@ -599,7 +630,7 @@ function PairDeviceRow() {
<PairDeviceModal
visible={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={handleClose}
testID="host-page-pair-device-card"
/>
</View>
@@ -612,6 +643,26 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
const [isConfirming, setIsConfirming] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const handleOpenConfirm = useCallback(() => setIsConfirming(true), []);
const handleCloseConfirm = useCallback(() => {
if (isRemoving) return;
setIsConfirming(false);
}, [isRemoving]);
const handleCancel = useCallback(() => setIsConfirming(false), []);
const handleConfirmRemove = useCallback(() => {
setIsRemoving(true);
void removeHost(host.serverId)
.then(() => {
setIsConfirming(false);
onRemoved?.();
})
.catch((error) => {
console.error("[HostPage] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
})
.finally(() => setIsRemoving(false));
}, [host.serverId, onRemoved, removeHost]);
return (
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
<View style={settingsStyles.card}>
@@ -627,7 +678,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
size="sm"
leftIcon={<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />}
textStyle={{ color: theme.colors.destructive }}
onPress={() => setIsConfirming(true)}
onPress={handleOpenConfirm}
testID="host-page-remove-host-button"
>
Remove
@@ -639,10 +690,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
<AdaptiveModalSheet
title="Remove host"
visible
onClose={() => {
if (isRemoving) return;
setIsConfirming(false);
}}
onClose={handleCloseConfirm}
testID="remove-host-confirm-modal"
>
<Text style={styles.confirmText}>
@@ -653,7 +701,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => setIsConfirming(false)}
onPress={handleCancel}
disabled={isRemoving}
>
Cancel
@@ -662,19 +710,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
setIsRemoving(true);
void removeHost(host.serverId)
.then(() => {
setIsConfirming(false);
onRemoved?.();
})
.catch((error) => {
console.error("[HostPage] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
})
.finally(() => setIsRemoving(false));
}}
onPress={handleConfirmRemove}
disabled={isRemoving}
testID="remove-host-confirm"
>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
@@ -47,6 +47,55 @@ function ShortcutSequence({
return <Shortcut chord={displayChord} />;
}
interface ShortcutRowContainerProps {
row: KeyboardShortcutHelpRow;
bindingId: string | null;
overrideCombo: string | undefined;
isCapturing: boolean;
capturedCombos: string[];
heldModifiers: string | null;
onStartCapture: (bindingId: string) => void;
onSaveCapture: () => void;
onCancelCapture: () => void;
onRemoveOverride: (bindingId: string) => void;
}
function ShortcutRowContainer({
row,
bindingId,
overrideCombo,
isCapturing,
capturedCombos,
heldModifiers,
onStartCapture,
onSaveCapture,
onCancelCapture,
onRemoveOverride,
}: ShortcutRowContainerProps) {
const handleRebind = useCallback(() => {
if (bindingId) onStartCapture(bindingId);
}, [bindingId, onStartCapture]);
const handleReset = useCallback(() => {
if (bindingId) onRemoveOverride(bindingId);
}, [bindingId, onRemoveOverride]);
return (
<ShortcutRow
row={row}
bindingId={bindingId}
overrideCombo={overrideCombo}
isCapturing={isCapturing}
capturedCombos={capturedCombos}
heldModifiers={heldModifiers}
onRebind={handleRebind}
onDone={onSaveCapture}
onCancel={onCancelCapture}
onReset={handleReset}
/>
);
}
function ShortcutRow({
row,
bindingId,
@@ -120,33 +169,36 @@ export function KeyboardShortcutsSection() {
const isDesktopApp = getIsElectronRuntime();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
useEffect(() => {
if (!isFocused && capturingBindingId !== null) {
cancelCapture();
}
}, [isFocused]);
function cancelCapture() {
const cancelCapture = useCallback(() => {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
}, [setCapturingShortcut]);
function startCapture(bindingId: string) {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
const startCapture = useCallback(
(bindingId: string) => {
setCapturedCombos([]);
setHeldModifiers(null);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
},
[setCapturingShortcut],
);
function saveCapture() {
const saveCapture = useCallback(() => {
if (capturingBindingId === null || capturedCombos.length === 0) {
return;
}
void setOverride(capturingBindingId, capturedCombos.join(" "));
cancelCapture();
}
}, [capturingBindingId, capturedCombos, setOverride, cancelCapture]);
useEffect(() => {
if (!isFocused && capturingBindingId !== null) {
cancelCapture();
}
}, [isFocused, capturingBindingId, cancelCapture]);
useEffect(() => {
if (isNative) return;
@@ -184,6 +236,12 @@ export function KeyboardShortcutsSection() {
};
}, [setCapturingShortcut]);
const handleResetAll = useCallback(() => void resetAll(), [resetAll]);
const handleRemoveOverride = useCallback(
(bindingId: string) => void removeOverride(bindingId),
[removeOverride],
);
if (isNative) {
return (
<SettingsSection title="Shortcuts">
@@ -195,7 +253,7 @@ export function KeyboardShortcutsSection() {
}
const resetAllButton = hasOverrides ? (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
<Button variant="ghost" size="sm" onPress={handleResetAll}>
Reset all
</Button>
) : undefined;
@@ -219,7 +277,7 @@ export function KeyboardShortcutsSection() {
return (
<View key={row.id}>
<ShortcutRow
<ShortcutRowContainer
row={row}
bindingId={bindingId}
overrideCombo={overrideCombo}
@@ -228,16 +286,10 @@ export function KeyboardShortcutsSection() {
capturingBindingId === bindingId ? capturedCombos : EMPTY_CAPTURED_COMBOS
}
heldModifiers={capturingBindingId === bindingId ? heldModifiers : null}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);
}
}}
onDone={saveCapture}
onCancel={cancelCapture}
onReset={() => {
if (bindingId) void removeOverride(bindingId);
}}
onStartCapture={startCapture}
onSaveCapture={saveCapture}
onCancelCapture={cancelCapture}
onRemoveOverride={handleRemoveOverride}
/>
{index < section.rows.length - 1 && <View style={styles.separator} />}
</View>

View File

@@ -14,6 +14,7 @@ import {
Text,
View,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import {
CopyX,
@@ -29,6 +30,7 @@ import {
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import type { DraggableRenderItemInfo } from "@/components/draggable-list.types";
import { isNative, isWeb } from "@/constants/platform";
import {
ContextMenu,
@@ -56,6 +58,14 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function tabKeyExtractor(tab: WorkspaceDesktopTabRowItem) {
return `${tab.tab.key}:${tab.tab.kind}`;
}
export interface WorkspaceDesktopTabRowItem {
tab: WorkspaceTabDescriptor;
isActive: boolean;
@@ -185,6 +195,64 @@ function TabChip({
} as const)
: undefined;
const tabChipStyle = useCallback(
() => [
styles.tab,
isWeb && isDragging && ({ cursor: "grabbing" } as const),
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
],
[isDragging, resolvedTabWidth],
);
const handleTabHoverIn = useCallback(() => {
setHovered(true);
setHoveredTabKey(tab.key);
}, [setHoveredTabKey, tab.key]);
const handleTabHoverOut = useCallback(() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, tab.key]);
const handleNavigateTab = useCallback(() => {
onNavigateTab(tab.tabId);
}, [onNavigateTab, tab.tabId]);
const handleCloseButtonPressIn = useCallback((event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
}, []);
const handleCloseButtonHoverIn = useCallback(() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
const handleCloseButtonHoverOut = useCallback(() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
const handleCloseButtonPress = useCallback(
(event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
},
[onCloseTab, tab.tabId],
);
const closeButtonStyle = useCallback(
({ hovered: isButtonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(Boolean(isButtonHovered) || pressed) && styles.tabCloseButtonActive,
],
[],
);
return (
<View ref={middleClickRef}>
<ContextMenu key={tab.key}>
@@ -196,29 +264,11 @@ function TabChip({
testID={`workspace-tab-${tab.key}`}
triggerRef={dragHandleProps?.setActivatorNodeRef as any}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
isWeb && isDragging && ({ cursor: "grabbing" } as const),
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
]}
onHoverIn={() => {
setHovered(true);
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}}
onPressIn={() => {
onNavigateTab(tab.tabId);
}}
onPress={() => {
onNavigateTab(tab.tabId);
}}
style={tabChipStyle}
onHoverIn={handleTabHoverIn}
onHoverOut={handleTabHoverOut}
onPressIn={handleNavigateTab}
onPress={handleNavigateTab}
accessibilityRole="button"
accessibilityLabel={tooltipLabel}
accessibilityState={{ selected: isActive }}
@@ -266,26 +316,11 @@ function TabChip({
{...(closeButtonDragBlockers as any)}
testID={closeButtonTestId}
disabled={isClosingTab}
onPressIn={(event) => {
event.stopPropagation?.();
}}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
onHoverOut={handleCloseButtonHoverOut}
onPress={handleCloseButtonPress}
style={closeButtonStyle}
>
{({ hovered, pressed }) =>
isClosingTab ? (
@@ -445,6 +480,108 @@ export function WorkspaceDesktopTabsRow({
metrics: layoutMetrics,
});
const handleDragEnd = useCallback(
(nextTabs: WorkspaceDesktopTabRowItem[]) => {
onReorderTabs(nextTabs.map((tab) => tab.tab));
},
[onReorderTabs],
);
const getTabDragData = useMemo(() => {
if (!paneId) return undefined;
return (tab: WorkspaceDesktopTabRowItem) => ({
kind: "workspace-tab" as const,
paneId,
tabId: tab.tab.tabId,
});
}, [paneId]);
const handleCreateAgentTab = useCallback(() => {
onCreateDraftTab({ paneId });
}, [onCreateDraftTab, paneId]);
const handleCreateTerminal = useCallback(() => {
onCreateTerminalTab({ paneId });
}, [onCreateTerminalTab, paneId]);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const newTerminalActionButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.newTabActionButton,
terminalDisabled && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
],
[terminalDisabled],
);
const renderTab = useCallback(
({
item,
index,
dragHandleProps,
isActive,
}: DraggableRenderItemInfo<WorkspaceDesktopTabRowItem>) => {
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true;
const showDropIndicatorBefore = activeDragTabId !== null && tabDropPreviewIndex === index;
const showDropIndicatorAfter =
activeDragTabId !== null &&
tabDropPreviewIndex === tabs.length &&
index === tabs.length - 1;
return (
<ResolvedDesktopTabChip
key={`${item.tab.key}:${item.tab.kind}`}
item={item}
isFocused={isFocused}
isDragging={isActive}
index={index}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
/>
);
},
[
activeDragTabId,
isFocused,
layout.closeButtonPolicy,
layout.items,
normalizedServerId,
normalizedWorkspaceId,
onCloseOtherTabs,
onCloseTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCopyAgentId,
onCopyResumeCommand,
onNavigateTab,
onReloadAgent,
setHoveredCloseTabKey,
setHoveredTabKey,
tabDropPreviewIndex,
tabs.length,
],
);
return (
<View
style={styles.tabsContainer}
@@ -466,75 +603,24 @@ export function WorkspaceDesktopTabsRow({
>
<SortableInlineList
data={tabs}
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
keyExtractor={tabKeyExtractor}
useDragHandle
disabled={!externalDndContext && tabs.length < 2}
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
onDragEnd={handleDragEnd}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
getItemData={
paneId
? (tab) => ({
kind: "workspace-tab",
paneId,
tabId: tab.tab.tabId,
})
: undefined
}
renderItem={({ item, index, dragHandleProps, isActive }) => {
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true;
const showDropIndicatorBefore =
activeDragTabId !== null && tabDropPreviewIndex === index;
const showDropIndicatorAfter =
activeDragTabId !== null &&
tabDropPreviewIndex === tabs.length &&
index === tabs.length - 1;
return (
<ResolvedDesktopTabChip
key={`${item.tab.key}:${item.tab.kind}`}
item={item}
isFocused={isFocused}
isDragging={isActive}
index={index}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
/>
);
}}
getItemData={getTabDragData}
renderItem={renderTab}
/>
</ScrollView>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => onCreateDraftTab({ paneId })}
onPress={handleCreateAgentTab}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
style={newTabActionButtonStyle}
>
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
@@ -550,18 +636,13 @@ export function WorkspaceDesktopTabsRow({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal"
onPress={() => onCreateTerminalTab({ paneId })}
disabled={disableCreateTerminal || isWaitingOnTerminalReadiness}
onPress={handleCreateTerminal}
disabled={terminalDisabled}
accessibilityRole="button"
accessibilityLabel={
isWaitingOnTerminalReadiness ? "Preparing terminal tab" : "New terminal tab"
}
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(disableCreateTerminal || isWaitingOnTerminalReadiness) &&
styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
style={newTerminalActionButtonStyle}
>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
@@ -583,10 +664,7 @@ export function WorkspaceDesktopTabsRow({
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel="Split pane right"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
style={newTabActionButtonStyle}
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
@@ -604,10 +682,7 @@ export function WorkspaceDesktopTabsRow({
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel="Split pane down"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
style={newTabActionButtonStyle}
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>

View File

@@ -1,5 +1,11 @@
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import {
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -23,6 +29,27 @@ interface WorkspaceOpenInEditorButtonProps {
hideLabels?: boolean;
}
interface EditorMenuItemProps {
editor: EditorTargetDescriptorPayload;
isPreferred: boolean;
onOpen: (editorId: EditorTargetId) => void;
foregroundMuted: string;
}
function EditorMenuItem({ editor, isPreferred, onOpen, foregroundMuted }: EditorMenuItemProps) {
const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
return (
<DropdownMenuItem
testID={`workspace-open-in-editor-item-${editor.id}`}
leading={<EditorAppIcon editorId={editor.id} size={16} color={foregroundMuted} />}
trailing={isPreferred ? <Check size={16} color={foregroundMuted} /> : undefined}
onSelect={handleSelect}
>
{editor.label}
</DropdownMenuItem>
);
}
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
@@ -100,6 +127,28 @@ export function WorkspaceOpenInEditorButton({
[openMutation, updatePreferredEditor],
);
const primaryPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
openMutation.isPending && styles.splitButtonPrimaryDisabled,
],
[openMutation.isPending],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
],
[],
);
const primaryId = primaryOption?.id;
const handlePrimaryPress = useCallback(() => {
if (primaryId) handleOpenEditor(primaryId);
}, [primaryId, handleOpenEditor]);
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
return null;
}
@@ -109,12 +158,8 @@ export function WorkspaceOpenInEditorButton({
<View style={styles.splitButton}>
<Pressable
testID="workspace-open-in-editor-primary"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
openMutation.isPending && styles.splitButtonPrimaryDisabled,
]}
onPress={() => handleOpenEditor(primaryOption.id)}
style={primaryPressableStyle}
onPress={handlePrimaryPress}
disabled={openMutation.isPending}
accessibilityRole="button"
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
@@ -140,10 +185,7 @@ export function WorkspaceOpenInEditorButton({
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-open-in-editor-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
style={caretTriggerStyle}
accessibilityRole="button"
accessibilityLabel="Choose editor"
>
@@ -156,25 +198,13 @@ export function WorkspaceOpenInEditorButton({
testID="workspace-open-in-editor-menu"
>
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
<DropdownMenuItem
<EditorMenuItem
key={editor.id}
testID={`workspace-open-in-editor-item-${editor.id}`}
leading={
<EditorAppIcon
editorId={editor.id}
size={16}
color={theme.colors.foregroundMuted}
/>
}
trailing={
editor.id === effectivePreferredEditorId ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : undefined
}
onSelect={() => handleOpenEditor(editor.id)}
>
{editor.label}
</DropdownMenuItem>
editor={editor}
isPreferred={editor.id === effectivePreferredEditorId}
onOpen={handleOpenEditor}
foregroundMuted={theme.colors.foregroundMuted}
/>
))}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -275,6 +275,19 @@ function WorkspaceDocumentTitleEffect({
return null;
}
function noop() {}
function mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) {
return [
styles.mobileTabMenuTrigger,
(Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive,
];
}
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
}
function MobileWorkspaceTabOption({
tab,
tabIndex,
@@ -344,10 +357,7 @@ function MobileWorkspaceTabOption({
accessibilityRole="button"
accessibilityLabel={`Open menu for ${presentation.label}`}
hitSlop={8}
style={({ open, pressed }) => [
styles.mobileTabMenuTrigger,
(open || pressed) && styles.mobileTabMenuTriggerActive,
]}
style={mobileTabMenuTriggerStyle}
>
<Ellipsis size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
@@ -429,6 +439,67 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
return map;
}, [tabs]);
const handleOpenSwitcher = useCallback(() => {
Keyboard.dismiss();
setIsOpen(true);
}, []);
const renderTabOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
},
[
tabByKey,
tabIndexByKey,
tabs.length,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
],
);
return (
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
<Pressable
@@ -436,11 +507,8 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
testID="workspace-tab-switcher-trigger"
accessibilityRole="button"
accessibilityLabel={`Switch tabs (${tabs.length} open)`}
style={({ pressed }) => [styles.switcherTrigger, pressed && styles.switcherTriggerPressed]}
onPress={() => {
Keyboard.dismiss();
setIsOpen(true);
}}
style={switcherTriggerStyle}
onPress={handleOpenSwitcher}
>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
@@ -462,35 +530,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
renderOption={({ option, selected, active, onPress }) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
}}
renderOption={renderTabOption}
/>
</View>
);
@@ -945,6 +985,16 @@ function WorkspaceScreenContent({
});
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const hasDiffStat = Boolean(workspaceDescriptor?.diffStat);
const explorerToggleStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.sourceControlButton,
hasDiffStat && styles.sourceControlButtonWithStats,
(Boolean(hovered) || Boolean(pressed) || isExplorerOpen) && styles.sourceControlButtonHovered,
],
[hasDiffStat, isExplorerOpen],
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && canOpenExplorerFromAgentView,
onOpen: openExplorerForWorkspace,
@@ -2378,12 +2428,7 @@ function WorkspaceScreenContent({
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
style={({ hovered, pressed }) => [
styles.sourceControlButton,
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
(hovered || pressed || isExplorerOpen) &&
styles.sourceControlButtonHovered,
]}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
@@ -2520,8 +2565,8 @@ function WorkspaceScreenContent({
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={() => {}}
onSplitDown={() => {}}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}

View File

@@ -1,4 +1,5 @@
import { Fragment, useMemo, type ReactElement } from "react";
import { Fragment, useCallback, useMemo, type ReactElement } from "react";
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";
@@ -38,6 +39,35 @@ interface ScriptActionButtonProps {
testID: string;
}
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 iconElement =
icon === "view" ? (
<SquareTerminal {...iconProps} />
) : (
<Play {...iconProps} fill="transparent" />
);
return (
<>
{iconElement}
<Text style={[styles.actionButtonLabel, { color }]}>{label}</Text>
</>
);
}
function ScriptActionButton({
accessibilityLabel,
disabled,
@@ -48,6 +78,21 @@ function ScriptActionButton({
}: ScriptActionButtonProps): ReactElement {
const { theme } = useUnistyles();
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => (
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} theme={theme} />
),
[icon, label, theme],
);
return (
<Pressable
accessibilityRole="button"
@@ -55,29 +100,10 @@ function ScriptActionButton({
testID={testID}
hitSlop={4}
disabled={disabled}
onPress={(event) => {
event.stopPropagation();
onPress();
}}
onPress={handlePress}
style={styles.actionButton}
>
{({ hovered }) => {
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconProps = { size: 10, color };
let iconElement: ReactElement;
if (icon === "view") {
iconElement = <SquareTerminal {...iconProps} />;
} else {
iconElement = <Play {...iconProps} fill="transparent" />;
}
return (
<>
{iconElement}
<Text style={[styles.actionButtonLabel, { color }]}>{label}</Text>
</>
);
}}
{renderChildren}
</Pressable>
);
}
@@ -92,36 +118,62 @@ interface HostLinkProps {
scriptName: string;
}
interface HostLinkChildrenProps {
hovered?: boolean;
disabled: boolean;
label: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}
function HostLinkChildren({
hovered,
disabled,
label,
theme,
}: HostLinkChildrenProps): ReactElement {
const showIcon = !disabled && (hovered || isNative);
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Text style={[styles.hostLabel, { color }]} numberOfLines={1}>
{label}
</Text>
<View style={styles.hostIconSlot}>
{showIcon ? <ExternalLink size={10} color={color} /> : null}
</View>
</>
);
}
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const { theme } = useUnistyles();
const disabled = !url;
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
if (url) void openExternalUrl(url);
},
[url],
);
const renderChildren = useCallback(
({ hovered }: { hovered?: boolean }) => (
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} theme={theme} />
),
[disabled, label, theme],
);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Open ${scriptName} at ${label}`}
disabled={disabled}
hitSlop={2}
onPress={(event) => {
event.stopPropagation();
if (url) void openExternalUrl(url);
}}
onPress={handlePress}
style={styles.hostRow}
>
{({ hovered }) => {
const showIcon = !disabled && (hovered || isNative);
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Text style={[styles.hostLabel, { color }]} numberOfLines={1}>
{label}
</Text>
<View style={styles.hostIconSlot}>
{showIcon ? <ExternalLink size={10} color={color} /> : null}
</View>
</>
);
}}
{renderChildren}
</Pressable>
);
}
@@ -142,6 +194,147 @@ interface HostLink {
url: string | null;
}
interface ScriptRowProps {
script: WorkspaceDescriptor["scripts"][number];
liveTerminalIdSet: Set<string>;
activeConnection: ReturnType<typeof useHostRuntimeSnapshot> extends infer R
? R extends { activeConnection: infer A }
? A
: null
: null;
isStartPending: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
onStartScript: (scriptName: string) => void;
onViewTerminal?: (terminalId: string) => void;
}
function ScriptRow({
script,
liveTerminalIdSet,
activeConnection,
isStartPending,
theme,
onStartScript,
onViewTerminal,
}: ScriptRowProps): ReactElement {
const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service";
const exitCode = script.exitCode ?? null;
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null;
const liveTerminalId =
script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null;
const hostLinks: HostLink[] = [];
if (isService && isRunning) {
const routedUrl = script.proxyUrl ?? serviceLink.labelUrl;
if (routedUrl) {
hostLinks.push({
key: "proxy",
label: stripUrlProtocol(routedUrl),
url: serviceOpenUrl,
});
}
if (script.port !== null) {
const localhostLabel = `localhost:${script.port}`;
const alreadyShown = hostLinks.some((l) => l.label === localhostLabel);
if (!alreadyShown) {
hostLinks.push({
key: "localhost",
label: localhostLabel,
url: `http://localhost:${script.port}`,
});
}
}
}
let iconColor = theme.colors.foregroundMuted;
if (isService) {
if (isRunning && script.health === "healthy") {
iconColor = theme.colors.palette.green[500];
} else if (isRunning && script.health === "unhealthy") {
iconColor = theme.colors.palette.red[500];
} else if (isRunning) {
iconColor = theme.colors.palette.blue[500];
}
} else if (isRunning) {
iconColor = theme.colors.palette.blue[500];
}
const ScriptIcon = isService ? Globe : SquareTerminal;
const showExitBadge = !isRunning && exitCode !== null;
const handleView = useCallback(() => {
if (liveTerminalId) onViewTerminal?.(liveTerminalId);
}, [liveTerminalId, onViewTerminal]);
const handleRun = useCallback(() => {
onStartScript(script.scriptName);
}, [onStartScript, script.scriptName]);
let primaryAction: ReactElement | null = null;
if (isRunning && liveTerminalId) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`View ${script.scriptName} terminal`}
testID={`workspace-scripts-view-${script.scriptName}`}
icon="view"
label="View"
onPress={handleView}
/>
);
} else if (!isRunning) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={isStartPending}
icon="start"
label="Run"
onPress={handleRun}
/>
);
}
return (
<View
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityLabel={`${script.scriptName} script`}
style={styles.scriptItem}
>
<View style={styles.scriptHeader}>
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
<Text
style={[
styles.scriptName,
{
color: isRunning ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{script.scriptName}
</Text>
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
<View style={styles.spacer} />
{primaryAction}
</View>
{hostLinks.length > 0 ? (
<View style={styles.hostList}>
{hostLinks.map((link) => (
<HostLinkRow
key={link.key}
label={link.label}
url={link.url}
scriptName={script.scriptName}
/>
))}
</View>
) : null}
</View>
);
}
export function WorkspaceScriptsButton({
serverId,
workspaceId,
@@ -180,6 +373,19 @@ export function WorkspaceScriptsButton({
},
});
const triggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonPrimary,
(hovered || pressed || open) && styles.splitButtonPrimaryHovered,
],
[],
);
const handleStartScript = useCallback(
(scriptName: string) => startScriptMutation.mutate(scriptName),
[startScriptMutation],
);
if (scripts.length === 0) {
return null;
}
@@ -192,10 +398,7 @@ export function WorkspaceScriptsButton({
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-scripts-button"
style={({ hovered, pressed, open }) => [
styles.splitButtonPrimary,
(hovered || pressed || open) && styles.splitButtonPrimaryHovered,
]}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel="Workspace scripts"
>
@@ -218,123 +421,20 @@ export function WorkspaceScriptsButton({
testID="workspace-scripts-menu"
>
<View style={styles.scriptList}>
{scripts.map((script, index) => {
const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service";
const exitCode = script.exitCode ?? null;
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null;
const liveTerminalId =
script.terminalId && liveTerminalIdSet.has(script.terminalId)
? script.terminalId
: null;
const hostLinks: HostLink[] = [];
if (isService && isRunning) {
const routedUrl = script.proxyUrl ?? serviceLink.labelUrl;
if (routedUrl) {
hostLinks.push({
key: "proxy",
label: stripUrlProtocol(routedUrl),
url: serviceOpenUrl,
});
}
if (script.port !== null) {
const localhostLabel = `localhost:${script.port}`;
const alreadyShown = hostLinks.some((l) => l.label === localhostLabel);
if (!alreadyShown) {
hostLinks.push({
key: "localhost",
label: localhostLabel,
url: `http://localhost:${script.port}`,
});
}
}
}
let iconColor = theme.colors.foregroundMuted;
if (isService) {
if (isRunning && script.health === "healthy") {
iconColor = theme.colors.palette.green[500];
} else if (isRunning && script.health === "unhealthy") {
iconColor = theme.colors.palette.red[500];
} else if (isRunning) {
iconColor = theme.colors.palette.blue[500];
}
} else if (isRunning) {
iconColor = theme.colors.palette.blue[500];
}
const ScriptIcon = isService ? Globe : SquareTerminal;
const showExitBadge = !isRunning && exitCode !== null;
let primaryAction: ReactElement | null = null;
if (isRunning && liveTerminalId) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`View ${script.scriptName} terminal`}
testID={`workspace-scripts-view-${script.scriptName}`}
icon="view"
label="View"
onPress={() => onViewTerminal?.(liveTerminalId)}
/>
);
} else if (!isRunning) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={startScriptMutation.isPending}
icon="start"
label="Run"
onPress={() => startScriptMutation.mutate(script.scriptName)}
/>
);
}
return (
<Fragment key={script.scriptName}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<View
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityLabel={`${script.scriptName} script`}
style={styles.scriptItem}
>
<View style={styles.scriptHeader}>
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
<Text
style={[
styles.scriptName,
{
color: isRunning
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{script.scriptName}
</Text>
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
<View style={styles.spacer} />
{primaryAction}
</View>
{hostLinks.length > 0 ? (
<View style={styles.hostList}>
{hostLinks.map((link) => (
<HostLinkRow
key={link.key}
label={link.label}
url={link.url}
scriptName={script.scriptName}
/>
))}
</View>
) : null}
</View>
</Fragment>
);
})}
{scripts.map((script, index) => (
<Fragment key={script.scriptName}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<ScriptRow
script={script}
liveTerminalIdSet={liveTerminalIdSet}
activeConnection={activeConnection}
isStartPending={startScriptMutation.isPending}
theme={theme}
onStartScript={handleStartScript}
onViewTerminal={onViewTerminal}
/>
</Fragment>
))}
</View>
</DropdownMenuContent>
</DropdownMenu>