diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 7a9cbe712..62179eb19 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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( () => (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 }) => ( + + {ProviderIcon ? ( + + ) : null} + {selectedModelLabel} + + + ), + [ProviderIcon, modelDisabled, theme.colors.foregroundMuted, theme.iconSize.md], + ); + + if (!hasAnyControl) { + return null; + } return ( @@ -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({ 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({ 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({ 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 ( - - - 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}`} - > - - - - - {getFeatureTooltip(feature)} - - - ); - } - if (feature.type === "select") { - const FeatureIcon = getFeatureIcon(feature.icon); - const selectedOption = feature.options.find((o) => o.id === feature.value); - return ( - - - - [ - 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}`} - > - - - {selectedOption?.label ?? feature.label} - - - - - - {getFeatureTooltip(feature)} - - - - {feature.options.map((option) => ( - onSetFeature?.(feature.id, option.id)} - > - {option.label} - - ))} - - - ); - } - return null; - })} + {features?.map((feature) => ( + + ))} ) : ( <> { - 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({ 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 }) => ( - - {ProviderIcon ? ( - - ) : null} - {selectedModelLabel} - - - )} + renderTrigger={renderSheetModelTrigger} /> ) : null} @@ -679,15 +689,11 @@ function ControlledStatusBar({ [ - 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({ {thinkingOptions.map((thinking) => ( - onSelectThinkingOption?.(thinking.id)} - > - {thinking.label} - + onSelectThinkingOption={onSelectThinkingOption} + /> ))} @@ -713,17 +718,10 @@ function ControlledStatusBar({ {modeOptions && modeOptions.length > 0 ? ( - + [ - 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({ - {modeOptions.map((mode) => { - const visuals = getModeVisuals(provider, mode.id, providerDefinitions); - const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; - return ( - onSelectMode?.(mode.id)} - leading={} - > - {mode.label} - - ); - })} + {modeOptions.map((mode) => ( + + ))} ) : null} - {features?.map((feature) => { - if (feature.type === "toggle") { - const FeatureIcon = getFeatureIcon(feature.icon); - return ( - - 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}`} - > - - {feature.label} - {feature.value ? "On" : "Off"} - - - ); - } - if (feature.type === "select") { - const selectedOption = feature.options.find((o) => o.id === feature.value); - return ( - - - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - disabled && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel={getFeatureTooltip(feature)} - testID={`agent-feature-${feature.id}`} - > - - {selectedOption?.label ?? feature.label} - - - - - {feature.options.map((option) => ( - onSetFeature?.(feature.id, option.id)} - > - {option.label} - - ))} - - - - ); - } - return null; - })} + {features?.map((feature) => ( + + ))} )} @@ -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 ( + + + + + + + + {getFeatureTooltip(feature)} + + + ); + } + + if (feature.type === "select") { + const FeatureIcon = getFeatureIcon(feature.icon); + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + + + + + {selectedOption?.label ?? feature.label} + + + + + {getFeatureTooltip(feature)} + + + + {feature.options.map((option) => ( + + ))} + + + ); + } + + 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 ( + + + + {feature.label} + {feature.value ? "On" : "Off"} + + + ); + } + + if (feature.type === "select") { + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + + + {selectedOption?.label ?? feature.label} + + + + {feature.options.map((option) => ( + + ))} + + + + ); + } + + 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 ( + + {option.label} + + ); +} + +function ThinkingMenuItem({ + thinking, + selected, + onSelectThinkingOption, +}: { + thinking: StatusOption; + selected: boolean; + onSelectThinkingOption?: (thinkingOptionId: string) => void; +}) { + const handleSelect = useCallback(() => { + onSelectThinkingOption?.(thinking.id); + }, [onSelectThinkingOption, thinking.id]); + + return ( + + {thinking.label} + + ); +} + +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 ( + } + > + {mode.label} + + ); +} + const EMPTY_MODES: AgentMode[] = []; export const AgentStatusBar = memo(function AgentStatusBar({ diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 3eed84dc3..20a331639 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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 { 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 { + 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 { - 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 ); } @@ -447,7 +459,9 @@ const AgentStreamViewComponent = forwardRef ); } const data = payload.data; return ( - ); } @@ -493,7 +507,7 @@ const AgentStreamViewComponent = forwardRef - collectAssistantTurnContentForStreamRenderStrategy({ - strategy: streamRenderStrategy, - items, - startIndex: index, - }); return ( {content} - {isEndOfAssistantTurn ? : null} + {isEndOfAssistantTurn ? ( + + ) : null} ); }, @@ -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 ; +} + +interface ToolCallSlotProps extends Omit< + ComponentProps, + "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 ; +} + +interface PermissionActionButtonProps { + action: AgentPermissionAction; + isRespondingAction: boolean; + isResponding: boolean; + textColor: string; + iconColor: string; + isDanger: boolean; + Icon: typeof Check; + testID: string; + theme: ReturnType["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 ( + + {isRespondingAction ? ( + + ) : ( + + + {action.label} + + )} + + ); +} + function PermissionRequestCard({ permission, client, @@ -940,31 +1048,19 @@ function PermissionRequestCard({ : `permission-request-action-${action.id}`; return ( - [ - 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 ? ( - - ) : ( - - - - {action.label} - - - )} - + theme={theme} + onPress={handleActionPress} + /> ); })} diff --git a/packages/app/src/components/attachment-pill.tsx b/packages/app/src/components/attachment-pill.tsx index 0a9795ec3..dccd8ad54 100644 --- a/packages/app/src/components/attachment-pill.tsx +++ b/packages/app/src/components/attachment-pill.tsx @@ -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 ( setIsBodyHovered(true)} - onHoverOut={() => setIsBodyHovered(false)} + onHoverIn={handleBodyHoverIn} + onHoverOut={handleBodyHoverOut} accessibilityRole="button" accessibilityLabel={openAccessibilityLabel} style={styles.body} @@ -51,8 +55,8 @@ export function AttachmentPill({ setIsCloseHovered(true)} - onHoverOut={() => setIsCloseHovered(false)} + onHoverIn={handleCloseHoverIn} + onHoverOut={handleCloseHoverOut} hitSlop={8} accessibilityRole="button" accessibilityLabel={removeAccessibilityLabel} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 9f5e5c8ec..c923b40bc 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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 = []; + +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({ [ - 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 ( + + ); +} + function FavoritesSection({ favoriteRows, selectedProvider, @@ -243,14 +307,14 @@ function FavoritesSection({ Favorites {favoriteRows.map((row) => ( - 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 ( + + + {providerLabel} + + + {rowCount} {rowCount === 1 ? "model" : "models"} + + + + + ); +} + 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 ( {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) => ( - onSelect(row.provider, row.modelId)} + onSelect={onSelect} onToggleFavorite={onToggleFavorite} /> ))} ) : ( - onDrillDown(group.providerId, group.providerLabel)} - style={({ pressed, hovered }) => [ - styles.drillDownRow, - hovered && styles.drillDownRowHovered, - pressed && styles.drillDownRowPressed, - ]} - > - - {group.providerLabel} - - - {group.rows.length} {group.rows.length === 1 ? "model" : "models"} - - - - + )} ); @@ -453,7 +530,6 @@ function SelectorContent({ {filteredGroupedRows.length > 0 ? ( [ - styles.backButton, - hovered && styles.backButtonHovered, - pressed && styles.backButtonPressed, - ]} - > + {providerLabel} @@ -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 ( <> 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({ {}} + onSelect={noop} open={isOpen} onOpenChange={handleOpenChange} anchorRef={anchorRef} @@ -681,10 +768,7 @@ export function CombinedModelSelector({ { - setView({ kind: "all" }); - setSearchQuery(""); - }} + onBack={handleBackToAll} /> ) : null} { - setView({ kind: "provider", providerId, providerLabel }); - }} + onDrillDown={handleDrillDown} /> ) : ( diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index d9e046370..4eb8d2f6d 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -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 ( - [ - styles.row, - (hovered || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ]} - onPress={onPress} - > + {children} ); }); +interface CommandCenterRowContainerProps { + rowIndex: number; + active: boolean; + rowRefs: React.MutableRefObject>; + 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 ( + + {children} + + ); +} + +interface CommandCenterActionRowProps { + item: Extract["items"][number], { kind: "action" }>; + rowIndex: number; + active: boolean; + rowRefs: React.MutableRefObject>; + onSelect: (item: ReturnType["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" ? ( + + ) : action.icon === "settings" ? ( + + ) : null; + return ( + + + + {actionIcon ? {actionIcon} : null} + + + {action.title} + + + + {action.shortcutKeys ? ( + + ) : null} + + + ); +} + +interface CommandCenterAgentRowProps { + item: Extract["items"][number], { kind: "agent" }>; + rowIndex: number; + active: boolean; + rowRefs: React.MutableRefObject>; + onSelect: (item: ReturnType["items"][number]) => void; + children: ReactNode; +} + +function CommandCenterAgentRow({ + rowIndex, + active, + rowRefs, + onSelect, + item, + children, +}: CommandCenterAgentRowProps) { + const handlePress = useCallback(() => onSelect(item), [onSelect, item]); + return ( + + {children} + + ); +} + export function CommandCenter() { const { theme } = useUnistyles(); const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } = @@ -141,50 +257,16 @@ export function CommandCenter() { Actions - {actionItems.map((item, index) => { - const active = index === activeIndex; - const action = item.action; - const actionIcon = - action.icon === "plus" ? ( - - ) : action.icon === "settings" ? ( - - ) : null; - return ( - { - if (el) rowRefs.current.set(index, el); - else rowRefs.current.delete(index); - }} - active={active} - onPress={() => handleSelectItem(item)} - > - - - {actionIcon ? ( - {actionIcon} - ) : null} - - - {action.title} - - - - {action.shortcutKeys ? ( - - ) : null} - - - ); - })} + {actionItems.map((item, index) => ( + + ))} ) : null} @@ -200,17 +282,15 @@ export function CommandCenter() { {agentItems.map((item, index) => { const rowIndex = actionItems.length + index; - const active = rowIndex === activeIndex; const agent = item.agent; return ( - { - 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} > @@ -237,7 +317,7 @@ export function CommandCenter() { - + ); })} diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 3164d07e3..990a98be6 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -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 ( + + + {item.text} + + + + + + + + + + + ); +} + function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { const uri = useAttachmentPreviewUrl(image); if (!uri) { @@ -89,6 +132,129 @@ function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { return ; } +interface ImageAttachmentPillProps { + attachment: Extract; + 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 ( + + + + ); +} + +interface GithubAttachmentPillProps { + attachment: Exclude; + 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 ( + + + + {item.kind === "pr" ? ( + + ) : ( + + )} + + + #{item.number} {item.title} + + + + ); +} + +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 ( + + ) : ( + + ) + } + /> + ); +} + 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 ; + } + const selected = selectedAttachments.some( + (attachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number, + ); + return ( + + ); + }, + [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 ( - setLightboxMetadata(null)} /> + {/* Input area */} @@ -887,25 +1110,12 @@ export function Composer({ {queuedMessages.length > 0 && ( {queuedMessages.map((item) => ( - - - {item.text} - - - handleEditQueuedMessage(item.id)} - style={styles.queueActionButton} - > - - - handleSendQueuedNow(item.id)} - style={[styles.queueActionButton, styles.queueSendButton]} - > - - - - + ))} )} @@ -933,51 +1143,25 @@ export function Composer({ {selectedAttachments.map((attachment, index) => { if (attachment.kind === "image") { return ( - handleOpenAttachment(attachment)} - onRemove={() => handleRemoveAttachment(index)} - openAccessibilityLabel="Open image attachment" - removeAccessibilityLabel="Remove image attachment" + attachment={attachment} + index={index} disabled={isComposerLocked} - > - - + onOpen={handleOpenAttachment} + onRemove={handleRemoveAttachment} + /> ); } - - const item = attachment.item; - const kindLabel = item.kind === "pr" ? "PR" : "issue"; return ( - handleOpenAttachment(attachment)} - onRemove={() => handleRemoveAttachment(index)} - openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} - removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} + - - - {item.kind === "pr" ? ( - - ) : ( - - )} - - - #{item.number} {item.title} - - - + onOpen={handleOpenAttachment} + onRemove={handleRemoveAttachment} + /> ); })} @@ -1025,56 +1209,18 @@ export function Composer({ {}} + 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 ; - } - const selected = selectedAttachments.some( - (attachment) => - attachment.kind !== "image" && - attachment.item.kind === item.kind && - attachment.item.number === item.number, - ); - return ( - handleToggleGithubItem(item)} - leadingSlot={ - item.kind === "pr" ? ( - - ) : ( - - ) - } - /> - ); - }} + renderOption={renderGithubPickerOption} /> diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 052205b70..9b3482cf5 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -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({ handleClose("header-close-button")} + onClose={handleHeaderClose} serverId={serverId} workspaceId={workspaceId} workspaceRoot={workspaceRoot} @@ -319,7 +322,7 @@ export function ExplorerSidebar({ 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 ( + + {children} + {label !== undefined ? ( + {label} + ) : null} + + ); +} + interface SidebarContentProps { activeTab: ExplorerTab; onTabPress: (tab: ExplorerTab) => void; @@ -381,30 +416,28 @@ function SidebarContent({ {isGit && ( - onTabPress("changes")} - > - - Changes - - + /> )} - onTabPress("files")} - > - - Files - - + /> {isGit && hasPullRequest && ( - onTabPress("pr")} > - - {prTabLabel} - - + )} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index eb59d0e11..fe5096fde 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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 ( + + {depth > 0 && + Array.from({ length: depth }, (_, i) => ( + + ))} + + + {isDirectory ? ( + loading ? ( + + ) : ( + + + + ) + ) : ( + + )} + + + {entry.name} + + + + + + + + + + + Size + + + {formatFileSize({ size: entry.size })} + + + + + Modified + + + {formatTimeAgo(new Date(entry.modifiedAt))} + + + + + } + onSelect={handleCopy} + > + Copy path + + {entry.kind === "file" ? ( + } + onSelect={handleDownload} + > + Download + + ) : null} + + + + ); +} + interface FileExplorerPaneProps { serverId: string; workspaceId?: string | null; @@ -335,94 +487,16 @@ export function FileExplorerPane({ const loading = isDirectory && isDirectoryLoading(entry.path); return ( - 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) => ( - - ))} - - - {isDirectory ? ( - loading ? ( - - ) : ( - - - - ) - ) : ( - - )} - - - {entry.name} - - - - event.stopPropagation?.()} - style={({ hovered, pressed, open }) => [ - styles.menuButton, - (hovered || pressed || open) && styles.menuButtonActive, - ]} - > - - - - - - - Size - - - {formatFileSize({ size: entry.size })} - - - - - Modified - - - {formatTimeAgo(new Date(entry.modifiedAt))} - - - - - } - onSelect={() => { - void handleCopyPath(entry.path); - }} - > - Copy path - - {entry.kind === "file" ? ( - } - onSelect={() => handleDownloadEntry(entry)} - > - Download - - ) : null} - - - + ); }, [ @@ -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 ( @@ -467,15 +546,7 @@ export function FileExplorerPane({ Back ) : null} - { - void requestDirectoryListing(".", { - recordHistory: false, - setCurrentPath: false, - }); - }} - > + Retry @@ -492,13 +563,7 @@ export function FileExplorerPane({ ) : ( - [ - styles.sortTrigger, - (hovered || pressed) && styles.sortTriggerHovered, - ]} - > + {currentSortLabel} @@ -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} diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index ce6a11bde..823194293 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -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 ( + + {needsSeparator && showSeparator ? : null} + + {action.label} + + + ); +} + 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 ( {gitActions.primary ? ( [ - 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 [ - styles.splitButtonCaret, - (hovered || pressed || open) && styles.splitButtonCaretHovered, - ]} + style={caretTriggerStyle} accessibilityRole="button" accessibilityLabel="More options" > - {gitActions.secondary.map((action, index) => { - const needsSeparator = - action.id === "merge-from-base" || action.id === "archive-worktree"; - return ( - - {needsSeparator && index > 0 ? : null} - - ) : 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} - - - ); - })} + {gitActions.secondary.map((action, index) => ( + + ) : 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" + } + /> + ))} ) : null} @@ -142,20 +186,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli {gitActions.menu.map((action) => ( - handleActionSelect(action)} - > - {action.label} - + /> ))} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index d287e839b..6815ddf1e 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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; 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 ( { - layoutYRef.current = event.nativeEvent.layout.y; - onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); - }} + onLayout={handleLayout} testID={testID} > [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} > @@ -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 ( { - 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 [ - 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 setDiffModeOverride("uncommitted")} + onSelect={handleSelectUncommitted} > Uncommitted @@ -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 @@ -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} > handleLayoutChange("split")} - style={({ hovered, pressed }) => [ - styles.toggleButton, - styles.toggleButtonGroupEnd, - changesPreferences.layout === "split" && styles.toggleButtonSelected, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} + onPress={handleLayoutSplit} + style={splitToggleStyle} > [ - styles.expandAllButton, - changesPreferences.hideWhitespace && styles.toggleButtonSelected, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} + style={hideWhitespaceToggleStyle} onPress={handleToggleHideWhitespace} > - [ - styles.expandAllButton, - wrapLines && styles.toggleButtonSelected, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} - onPress={handleToggleWrapLines} - > + - [ - styles.expandAllButton, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} - onPress={handleToggleExpandAll} - > + {allExpanded ? ( ; + setIsHostPickerOpen: Dispatch>; + hostOptionsEmpty: boolean; + hostStatusDotStyle: StyleProp; + 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 ( + + + + {activeHostLabel} + + + ); +} + function HostSwitchOption({ serverId, label, @@ -526,20 +567,13 @@ function MobileSidebar({ - [ - styles.hostTrigger, - hovered && styles.hostTriggerHovered, - ]} - onPress={() => setIsHostPickerOpen(true)} - disabled={hostOptions.length === 0} - > - - - {activeHostLabel} - - + @@ -719,20 +753,13 @@ function DesktopSidebar({ - [ - styles.hostTrigger, - hovered && styles.hostTriggerHovered, - ]} - onPress={() => setIsHostPickerOpen(true)} - disabled={hostOptions.length === 0} - > - - - {activeHostLabel} - - + diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c03987541..1e8ccb873 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -826,13 +826,16 @@ export const MessageInput = forwardRef(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(funct onHeight: setBoundedInputHeight, }); - function handleContentSizeChange( - event: NativeSyntheticEvent, - ) { - if (isWeb) return; - setBoundedInputHeight(event.nativeEvent.contentSize.height); - } + const handleContentSizeChange = useCallback( + (event: NativeSyntheticEvent) => { + if (isWeb) return; + setBoundedInputHeight(event.nativeEvent.contentSize.height); + }, + [setBoundedInputHeight], + ); - function handleSelectionChange(event: NativeSyntheticEvent) { - 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) => { + 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(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 ( {/* Regular input */} @@ -955,16 +997,8 @@ export const MessageInput = forwardRef(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(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 }) => ( (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(funct isMuted={voice.isMuted} isSwitching={voice.isVoiceSwitching} onToggleMute={voice.toggleMute} - onStop={() => { - void handleStopRealtimeVoice(); - }} + onStop={handleRealtimeVoiceStop} /> ) : null} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 70c361c26..73ea32da7 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( setMessageHovered(true)} - onHoverOut={() => setMessageHovered(false)} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} > {hasImages ? ( @@ -414,7 +418,7 @@ export const UserMessage = memo(function UserMessage({ {hasText ? ( 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 ( + + {content} + + ); +} + 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 ( - onPress(href)} style={style}> + {children} ); @@ -740,9 +766,9 @@ function MarkdownLink({ return ( onPress(href)} - onHoverIn={() => setHovered(true)} - onHoverOut={() => setHovered(false)} + onPress={handlePress} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} > {children} @@ -848,11 +874,14 @@ export const TurnCopyButton = memo(function TurnCopyButton({ }; }, []); + const handleHoverIn = useCallback(() => onHoverChange?.(true), [onHoverChange]); + const handleHoverOut = useCallback(() => onHoverChange?.(false), [onHoverChange]); + return ( 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 ( - parsed && onInlinePathPress?.(parsed)} - selectable={isWeb ? undefined : false} - style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} - > - {content} - + 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(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({ 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({ onDetailHoverChange?.(true)} - onHoverOut={() => onDetailHoverChange?.(false)} + onHoverIn={handleDetailHoverIn} + onHoverOut={handleDetailHoverOut} > {detailContent} diff --git a/packages/app/src/components/pr-pane.tsx b/packages/app/src/components/pr-pane.tsx index c3e7c3718..14b101ada 100644 --- a/packages/app/src/components/pr-pane.tsx +++ b/packages/app/src/components/pr-pane.tsx @@ -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 ( - void openExternalUrl(data.url)} style={styles.header}> + {({ hovered }) => ( <> @@ -74,7 +94,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
setChecksOpen((o) => !o)} + onToggle={handleToggleChecks} summary={ <> setReviewsOpen((o) => !o)} + onToggle={handleToggleReviews} summary={ <> { + void openExternalUrl(check.url); + }, [check.url]); return ( - void openExternalUrl(check.url)} - style={({ hovered }) => [styles.row, hovered && styles.hoverable]} - > + {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 ( - void openExternalUrl(item.url)} - style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]} - > + {item.author.slice(0, 1).toUpperCase()} diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 6609ac108..87bfaca9c 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -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 ( + + + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + {isSelected ? ( + + + + ) : null} + + + ); +} + +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 ( + 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 = {}; - 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 - {q.options.map((opt, optIndex) => { - const isSelected = selected.has(optIndex); - return ( - [ - styles.optionItem, - (hovered || isSelected) && { - backgroundColor: theme.colors.surface2, - }, - pressed && styles.optionItemPressed, - ]} - onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} - disabled={isResponding} - > - - - - {opt.label} - - {opt.description ? ( - - {opt.description} - - ) : null} - - {isSelected ? ( - - - - ) : null} - - - ); - })} + {q.options.map((opt, optIndex) => ( + + ))} - 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} + setOtherText(qIndex, text)} - onSubmitEditing={handleSubmit} - editable={!isResponding} - blurOnSubmit={false} + isResponding={isResponding} + onChange={setOtherText} + onSubmit={handleSubmit} /> ); })} - [ - styles.actionButton, - { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - pressed && styles.optionItemPressed, - ]} - onPress={handleDeny} - disabled={isResponding} - > + {respondingAction === "dismiss" ? ( ) : ( @@ -264,23 +364,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi )} - { - 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} - > + {respondingAction === "submit" ? ( ) : ( diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 16d86936d..b4a8fe91f 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 ( setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} - style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + style={prBadgePressableStyle} > {isHovered ? ( @@ -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 ( [ - 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 = ( <> @@ -867,10 +913,7 @@ function ProjectHeaderRow({ [ - 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} > [ - 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} > [ - 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} > [ - 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({ [ - 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 ( + + ); +} + function WorkspaceRow({ workspace, shortcutNumber, @@ -1674,7 +1763,7 @@ function ProjectBlock({ showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; - 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 ( - { - 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 ( {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} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 3e0c1e22c..919e95748 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -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 ( @@ -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={ diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 195c4b37d..6e6d20de4 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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 (
{ - runtimeRef.current?.focus(); - }} - onContextMenu={(event) => { - event.preventDefault(); - handleContextMenu(); - }} + onPointerDown={handleRootPointerDown} + onContextMenu={handleRootContextMenu} >
{ - 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} >
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 ( + + + {MODIFIER_LABELS[modifier]} + + + ); +} + +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 ( + + {label} + + ); +} + 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 ( @@ -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({ {(Object.keys(MODIFIER_LABELS) as Array).map((modifier) => ( - toggleModifier(modifier)} - style={({ hovered, pressed }) => [ - styles.keyButton, - modifiers[modifier] && styles.keyButtonActive, - (hovered || pressed) && styles.keyButtonHovered, - ]} - > - - {MODIFIER_LABELS[modifier]} - - + modifier={modifier} + active={modifiers[modifier]} + onToggle={toggleModifier} + /> ))} {KEY_BUTTONS.map((button) => ( - sendVirtualKey(button.key)} - style={({ hovered, pressed }) => [ - styles.keyButton, - (hovered || pressed) && styles.keyButtonHovered, - ]} - > - {button.label} - + id={button.id} + label={button.label} + keyValue={button.key} + onSend={sendVirtualKey} + /> ))} diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 49f920148..442be2e7d 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -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 ( + + {isFileOrDir ? ( + <> + + {option.kind === "directory" ? ( + + ) : ( + + )} + + + + {optionLabel} + {removeBoltGlyphs(option.detail) ? ( + {removeBoltGlyphs(option.detail)} + ) : null} + + {optionDescription ? ( + + {optionDescription} + + ) : null} + + + ) : ( + + {optionLabel} + {optionDescription ? ( + + {optionDescription} + + ) : null} + + )} + + ); +} + export function Autocomplete({ options, selectedIndex, @@ -109,6 +191,10 @@ export function Autocomplete({ [ensureActiveItemVisible], ); + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + 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 ( - handleRowLayout(index, event)} - onPress={() => onSelect(option)} - style={({ hovered = false, pressed }) => [ - styles.item, - (hovered || pressed || isSelected) && styles.itemActive, - ]} - > - {isFileOrDir ? ( - <> - - {option.kind === "directory" ? ( - - ) : ( - - )} - - - - {optionLabel} - {removeBoltGlyphs(option.detail) ? ( - {removeBoltGlyphs(option.detail)} - ) : null} - - {optionDescription ? ( - - {optionDescription} - - ) : null} - - - ) : ( - - {optionLabel} - {optionDescription ? ( - - {optionDescription} - - ) : null} - - )} - - ); - })} + {options.map((option, index) => ( + + ))} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 2d4cd9434..f187b00fe 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -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 ( { - 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} @@ -651,32 +669,39 @@ export function ContextMenuItem({ ) : 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 = ( { - 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 ? ( diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index e21ed41f8..84647c039 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -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) { Welcome to Paseo Connect your computer to get started {isNative ? ( - openExternalUrl("https://paseo.sh")} - > + paseo.sh @@ -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) { setIsDirectOpen(false)} - onSaved={({ profile, serverId }) => { - onHostAdded?.(profile); - finishOnboarding(serverId); - }} + onClose={handleCloseDirect} + onSaved={handleHostSaved} /> setIsPasteLinkOpen(false)} - onSaved={({ profile, serverId }) => { - onHostAdded?.(profile); - finishOnboarding(serverId); - }} + onClose={handleClosePasteLink} + onSaved={handleHostSaved} /> diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 85bbc87f7..5662798e2 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -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 = ( +
+ ); +} + +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) => onChangeText(event.currentTarget.value), + [onChangeText], + ); + const handleSubmit = React.useCallback( + () => onSubmitMessage({ text: value, attachments, cwd: "/repo" }), + [onSubmitMessage, value, attachments], + ); + return (
({ >