mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user