mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
This commit is contained in:
@@ -198,10 +198,15 @@ export default function PairScanScreen() {
|
||||
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const handleRequestPermission = useCallback(() => {
|
||||
void requestPermission();
|
||||
}, [requestPermission]);
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={() => router.back()} />
|
||||
<BackHeader title="Scan QR" onBack={handleRouterBack} />
|
||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||
@@ -230,7 +235,7 @@ export default function PairScanScreen() {
|
||||
<Text style={styles.permissionBody}>
|
||||
Allow camera access to scan the pairing QR code from your daemon.
|
||||
</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -231,6 +231,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
|
||||
|
||||
const handleChangeEndpoint = useCallback((next: string) => {
|
||||
endpointRawRef.current = next;
|
||||
}, []);
|
||||
|
||||
const handleSubmitEditing = useCallback(() => {
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Direct connection"
|
||||
@@ -247,9 +259,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="direct-host-input"
|
||||
onChangeText={(next) => {
|
||||
endpointRawRef.current = next;
|
||||
}}
|
||||
onChangeText={handleChangeEndpoint}
|
||||
placeholder="hostname:port"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
@@ -258,7 +268,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => void handleSave()}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
@@ -270,7 +280,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
variant="default"
|
||||
onPress={() => void handleSave()}
|
||||
onPress={handleSavePress}
|
||||
disabled={isSaving}
|
||||
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
||||
testID="direct-host-submit"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
@@ -194,16 +195,24 @@ function SessionRow({
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
Boolean(hovered) && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[isSelected],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => onPress(agent), [onPress, agent]);
|
||||
const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
hovered && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
onPress={() => onPress(agent)}
|
||||
onLongPress={() => onLongPress(agent)}
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRef } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Pressable, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -51,6 +52,29 @@ export function BranchSwitcher({
|
||||
</View>
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.branchSwitcherTrigger,
|
||||
(Boolean(hovered) || pressed) && styles.branchSwitcherTriggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const renderBranchOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
|
||||
({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
),
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||
}
|
||||
@@ -59,11 +83,8 @@ export function BranchSwitcher({
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
testID="workspace-header-branch-switcher"
|
||||
onPress={() => setIsOpen(true)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.branchSwitcherTrigger,
|
||||
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||
]}
|
||||
onPress={handleOpen}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
@@ -85,16 +106,7 @@ export function BranchSwitcher({
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
desktopMinWidth={280}
|
||||
renderOption={({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
)}
|
||||
renderOption={renderBranchOption}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -459,13 +459,14 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const menu = React.useContext(DropdownContext);
|
||||
const handleClick = React.useCallback(() => menu?.setOpen(true), [menu]);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testID}
|
||||
aria-label={accessibilityLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => menu?.setOpen(true)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({ hovered: false, pressed: false, open: menu?.open ?? false })
|
||||
@@ -654,6 +655,17 @@ function ComposerHarness({
|
||||
const [attachments, setAttachments] = useState(initialAttachments);
|
||||
latestAttachments = attachments;
|
||||
|
||||
const handleChangeAttachments = React.useCallback(
|
||||
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
|
||||
setAttachments((current) => {
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
latestAttachments = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient!}>
|
||||
<Composer
|
||||
@@ -663,13 +675,7 @@ function ComposerHarness({
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
attachments={attachments}
|
||||
onChangeAttachments={(updater) => {
|
||||
setAttachments((current) => {
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
latestAttachments = next;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onChangeAttachments={handleChangeAttachments}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitBehavior={submitBehavior}
|
||||
cwd="/repo"
|
||||
|
||||
@@ -92,18 +92,26 @@ afterEach(() => {
|
||||
|
||||
const DATA: string[] = ["alpha", "beta"];
|
||||
|
||||
function keyExtractor(item: string): string {
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
|
||||
return (
|
||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<DraggableList
|
||||
data={DATA}
|
||||
keyExtractor={(item) => item}
|
||||
keyExtractor={keyExtractor}
|
||||
onDragEnd={vi.fn()}
|
||||
renderItem={({ item, isActive }) => (
|
||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
renderItem={renderItem}
|
||||
scrollEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -151,6 +151,14 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
offerUrlRef.current = next;
|
||||
}, []);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Paste pairing link"
|
||||
@@ -167,9 +175,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
testID="pair-link-input"
|
||||
nativeID="pair-link-input"
|
||||
accessibilityLabel="pair-link-input"
|
||||
onChangeText={(next) => {
|
||||
offerUrlRef.current = next;
|
||||
}}
|
||||
onChangeText={handleChangeOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
@@ -196,7 +202,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
variant="default"
|
||||
onPress={() => void handleSave()}
|
||||
onPress={handleSavePress}
|
||||
disabled={isSaving}
|
||||
testID="pair-link-submit"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -12,6 +20,40 @@ import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-sugg
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
|
||||
interface PathRowProps {
|
||||
path: string;
|
||||
active: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
void onSelect(path);
|
||||
}, [onSelect, path]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
],
|
||||
[active, theme.colors.surface1],
|
||||
);
|
||||
return (
|
||||
<Pressable style={pressableStyle} onPress={handlePress}>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.iconSlot}>
|
||||
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text style={[styles.rowText, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const serverId = useActiveServerId();
|
||||
@@ -86,6 +128,11 @@ export function ProjectPickerModal() {
|
||||
void handleSelectPath(trimmed);
|
||||
}, [handleSelectPath, query]);
|
||||
|
||||
const handleChangeQuery = useCallback((text: string) => {
|
||||
setQuery(text);
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
|
||||
// Reset state when opening/closing
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -165,10 +212,7 @@ export function ProjectPickerModal() {
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={(text) => {
|
||||
setQuery(text);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onChangeText={handleChangeQuery}
|
||||
placeholder="Type a directory path..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={[styles.input, { color: theme.colors.foreground }]}
|
||||
@@ -195,37 +239,14 @@ export function ProjectPickerModal() {
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{options.map((path, index) => {
|
||||
const active = index === activeIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={path}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.row,
|
||||
(hovered || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
onPress={() => void handleSelectPath(path)}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.iconSlot}>
|
||||
<Folder
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.rowText, { color: theme.colors.foreground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{options.map((path, index) => (
|
||||
<PathRow
|
||||
key={path}
|
||||
path={path}
|
||||
active={index === activeIndex}
|
||||
onSelect={handleSelectPath}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -75,18 +75,26 @@ afterEach(() => {
|
||||
|
||||
const DATA: string[] = ["alpha", "beta"];
|
||||
|
||||
function keyExtractor(item: string): string {
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
|
||||
return (
|
||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<SortableInlineList
|
||||
data={DATA}
|
||||
keyExtractor={(item) => item}
|
||||
keyExtractor={keyExtractor}
|
||||
onDragEnd={vi.fn()}
|
||||
renderItem={({ item, isActive }) => (
|
||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
renderItem={renderItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
@@ -6,7 +7,13 @@ import {
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
@@ -125,6 +132,21 @@ export function Button({
|
||||
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
|
||||
const isGhostHovered = hovered && variant === "ghost";
|
||||
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
|
||||
styles.base,
|
||||
sizeStyle,
|
||||
variantStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
style,
|
||||
],
|
||||
[sizeStyle, variantStyle, disabled, style],
|
||||
);
|
||||
|
||||
const resolvedTextStyle = useMemo(
|
||||
() => [
|
||||
styles.text,
|
||||
@@ -178,16 +200,9 @@ export function Button({
|
||||
{...props}
|
||||
accessibilityRole={accessibilityRole ?? "button"}
|
||||
disabled={disabled}
|
||||
onHoverIn={() => setHovered(true)}
|
||||
onHoverOut={() => setHovered(false)}
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
sizeStyle,
|
||||
variantStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
style,
|
||||
]}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{renderIcon()}
|
||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Platform,
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
type LayoutChangeEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -210,19 +212,20 @@ export function ComboboxItem({
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.comboboxItem,
|
||||
Boolean(hovered) &&
|
||||
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
],
|
||||
[elevated, active, disabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.comboboxItem,
|
||||
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
]}
|
||||
>
|
||||
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
|
||||
{leadingContent}
|
||||
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
@@ -254,6 +257,33 @@ export function ComboboxEmpty({ children }: { children: ReactNode }): ReactEleme
|
||||
);
|
||||
}
|
||||
|
||||
type RenderOptionFn = NonNullable<ComboboxProps["renderOption"]>;
|
||||
|
||||
interface OptionRowProps {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
renderOption: RenderOptionFn | undefined;
|
||||
}
|
||||
|
||||
function OptionRow({ option, selected, active, onSelect, renderOption }: OptionRowProps) {
|
||||
const handlePress = useCallback(() => onSelect(option.id), [onSelect, option.id]);
|
||||
if (renderOption) {
|
||||
return <View>{renderOption({ option, selected, active, onPress: handlePress })}</View>;
|
||||
}
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
kind={option.kind}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
@@ -525,6 +555,17 @@ export function Combobox({
|
||||
[effectiveOptionsPosition, visibleOptions],
|
||||
);
|
||||
|
||||
const handleDesktopContentLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const { width } = event.nativeEvent.layout;
|
||||
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||
void update();
|
||||
}
|
||||
},
|
||||
[useMeasuredTopStartPosition, hasResolvedDesktopPosition, update],
|
||||
);
|
||||
|
||||
const pinDesktopOptionsToBottom = useCallback(() => {
|
||||
if (isMobile || effectiveOptionsPosition !== "above-search") {
|
||||
return;
|
||||
@@ -661,28 +702,16 @@ export function Combobox({
|
||||
const optionsList = (
|
||||
<>
|
||||
{orderedVisibleOptions.length > 0 ? (
|
||||
orderedVisibleOptions.map((opt, index) =>
|
||||
renderOption ? (
|
||||
<View key={opt.id}>
|
||||
{renderOption({
|
||||
option: opt,
|
||||
selected: opt.id === value,
|
||||
active: index === activeIndex,
|
||||
onPress: () => handleSelect(opt.id),
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<ComboboxItem
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
kind={opt.kind}
|
||||
selected={opt.id === value}
|
||||
active={index === activeIndex}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
orderedVisibleOptions.map((opt, index) => (
|
||||
<OptionRow
|
||||
key={opt.id}
|
||||
option={opt}
|
||||
selected={opt.id === value}
|
||||
active={index === activeIndex}
|
||||
onSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
)}
|
||||
@@ -754,13 +783,7 @@ export function Combobox({
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={(event) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||
void update();
|
||||
}
|
||||
}}
|
||||
onLayout={handleDesktopContentLayout}
|
||||
>
|
||||
{children ? (
|
||||
<>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Platform,
|
||||
StatusBar,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type ViewStyle,
|
||||
type StyleProp,
|
||||
} from "react-native";
|
||||
@@ -249,6 +250,24 @@ export function DropdownMenuTrigger({
|
||||
ctx.setOpen(!ctx.open);
|
||||
}, [disabled, ctx]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
|
||||
return typeof children === "function" ? children(state) : children;
|
||||
},
|
||||
[children, ctx.open],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
@@ -256,17 +275,9 @@ export function DropdownMenuTrigger({
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={({ pressed, hovered = false }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
}}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{({ pressed, hovered = false }) => {
|
||||
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
|
||||
return typeof children === "function" ? children(state) : children;
|
||||
}}
|
||||
{renderChildren}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -583,30 +594,37 @@ export function DropdownMenuItem({
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
) : null);
|
||||
|
||||
const handleItemPress = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
selectItem(onSelect, closeOnSelect);
|
||||
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
|
||||
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.item,
|
||||
selected
|
||||
? selectedVariant === "accent"
|
||||
? styles.itemSelectedAccent
|
||||
: styles.itemSelected
|
||||
: null,
|
||||
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
|
||||
? styles.itemSelectedInteractive
|
||||
: null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
],
|
||||
[selected, selectedVariant, isDisabled, muted],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
disabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isDisabled) return;
|
||||
selectItem(onSelect, closeOnSelect);
|
||||
}}
|
||||
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,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
]}
|
||||
onPress={handleItemPress}
|
||||
style={itemPressableStyle}
|
||||
>
|
||||
{showSelectedCheck ? (
|
||||
<View style={styles.checkSlot}>
|
||||
|
||||
@@ -289,57 +289,7 @@ function WorkspaceHoverCardContent({
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]}
|
||||
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const checks = prHint.checks!;
|
||||
const failed = checks.filter((c) => c.status === "failure").length;
|
||||
const pending = checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
let badgeColor: string;
|
||||
let badgeLabel: string;
|
||||
|
||||
if (failed > 0) {
|
||||
badgeColor = theme.colors.palette.red[500];
|
||||
badgeLabel = `${failed} failed`;
|
||||
} else if (pending > 0) {
|
||||
badgeColor = theme.colors.palette.amber[500];
|
||||
badgeLabel = `${pending} running`;
|
||||
} else {
|
||||
badgeColor = theme.colors.palette.green[500];
|
||||
badgeLabel = `${checks.length} passed`;
|
||||
}
|
||||
|
||||
const iconColor = hovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
{hovered ? (
|
||||
<ExternalLink size={12} color={iconColor} />
|
||||
) : (
|
||||
<GitHubIcon size={12} color={iconColor} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.checksSummaryLabel,
|
||||
hovered && styles.checksSummaryLabelHovered,
|
||||
]}
|
||||
>
|
||||
Checks
|
||||
</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
|
||||
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
|
||||
{badgeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} theme={theme} />
|
||||
</>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
@@ -348,6 +298,79 @@ function WorkspaceHoverCardContent({
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksSummaryPressable({
|
||||
checks,
|
||||
url,
|
||||
theme,
|
||||
}: {
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
url: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(`${url}/checks`);
|
||||
}, [url]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered }: { pressed: boolean; hovered?: boolean }) => [
|
||||
styles.checksSummaryRow,
|
||||
Boolean(hovered) && styles.listRowHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { pressed: boolean; hovered?: boolean }) => {
|
||||
const failed = checks.filter((c) => c.status === "failure").length;
|
||||
const pending = checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
let badgeColor: string;
|
||||
let badgeLabel: string;
|
||||
|
||||
if (failed > 0) {
|
||||
badgeColor = theme.colors.palette.red[500];
|
||||
badgeLabel = `${failed} failed`;
|
||||
} else if (pending > 0) {
|
||||
badgeColor = theme.colors.palette.amber[500];
|
||||
badgeLabel = `${pending} running`;
|
||||
} else {
|
||||
badgeColor = theme.colors.palette.green[500];
|
||||
badgeLabel = `${checks.length} passed`;
|
||||
}
|
||||
|
||||
const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
{hovered ? (
|
||||
<ExternalLink size={12} color={iconColor} />
|
||||
) : (
|
||||
<GitHubIcon size={12} color={iconColor} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.checksSummaryLabel,
|
||||
Boolean(hovered) && styles.checksSummaryLabelHovered,
|
||||
]}
|
||||
>
|
||||
Checks
|
||||
</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
|
||||
<Text style={[styles.checksStatusText, { color: badgeColor }]}>{badgeLabel}</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[checks, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable style={pressableStyle} onPress={handlePress}>
|
||||
{renderChildren}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -57,6 +57,12 @@ import {
|
||||
useSidebarCallouts,
|
||||
} from "./sidebar-callout-context";
|
||||
|
||||
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
|
||||
|
||||
function handleApi(nextApi: SidebarCalloutsApi): void {
|
||||
apiSink.current = nextApi;
|
||||
}
|
||||
|
||||
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
|
||||
const api = useSidebarCallouts();
|
||||
onApi(api);
|
||||
@@ -70,6 +76,7 @@ describe("SidebarCalloutProvider", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
api = null;
|
||||
apiSink.current = null;
|
||||
asyncStorage.values.clear();
|
||||
asyncStorage.getItem.mockClear();
|
||||
asyncStorage.setItem.mockClear();
|
||||
@@ -79,12 +86,13 @@ describe("SidebarCalloutProvider", () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
|
||||
<CaptureApi onApi={handleApi} />
|
||||
<SidebarCalloutViewport />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
api = apiSink.current;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -142,11 +150,12 @@ describe("SidebarCalloutProvider", () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<Producer />
|
||||
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
|
||||
<CaptureApi onApi={handleApi} />
|
||||
<SidebarCalloutViewport />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
});
|
||||
api = apiSink.current;
|
||||
const firstApi = renders.mock.calls[0]?.[0];
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -77,6 +77,14 @@ export function IntegrationsSection() {
|
||||
});
|
||||
}, [isInstallingSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
}, []);
|
||||
|
||||
const handleOpenSkillsDocs = useCallback(() => {
|
||||
void openExternalUrl(SKILLS_DOCS_URL);
|
||||
}, []);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
}
|
||||
@@ -89,7 +97,7 @@ export function IntegrationsSection() {
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
||||
onPress={handleOpenCliDocs}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
>
|
||||
CLI docs
|
||||
@@ -100,7 +108,7 @@ export function IntegrationsSection() {
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
||||
onPress={handleOpenSkillsDocs}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
>
|
||||
Skills docs
|
||||
|
||||
@@ -42,6 +42,14 @@ export function PairDeviceSection() {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [pairingQuery.data?.url]);
|
||||
|
||||
const handleRefetch = useCallback(() => {
|
||||
void pairingQuery.refetch();
|
||||
}, [pairingQuery]);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
void handleCopyLink();
|
||||
}, [handleCopyLink]);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
return (
|
||||
@@ -63,7 +71,7 @@ export function PairDeviceSection() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void pairingQuery.refetch()}
|
||||
onPress={handleRefetch}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
@@ -79,7 +87,7 @@ export function PairDeviceSection() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void pairingQuery.refetch()}
|
||||
onPress={handleRefetch}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
@@ -118,7 +126,7 @@ export function PairDeviceSection() {
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={() => void handleCopyLink()}
|
||||
onPress={handleCopyPress}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { useRef, type RefObject } from "react";
|
||||
import React, { useCallback, useRef, type RefObject } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { View } from "react-native";
|
||||
@@ -73,20 +73,20 @@ function Harness({
|
||||
onLeaveSafeZone,
|
||||
});
|
||||
|
||||
const handleTriggerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
triggerRef.current = node;
|
||||
installRect(node, { left: 0, right: 100, top: 20, bottom: 60 });
|
||||
}, []);
|
||||
|
||||
const handleContentRef = useCallback((node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
installRect(node, { left: 120, right: 240, top: 20, bottom: 120 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={(node) => {
|
||||
triggerRef.current = node;
|
||||
installRect(node, { left: 0, right: 100, top: 20, bottom: 60 });
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={(node) => {
|
||||
contentRef.current = node;
|
||||
installRect(node, { left: 120, right: 240, top: 20, bottom: 120 });
|
||||
}}
|
||||
/>
|
||||
<div ref={handleTriggerRef} />
|
||||
<div ref={handleContentRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated from "react-native-reanimated";
|
||||
@@ -284,6 +285,27 @@ export function NewWorkspaceScreen({
|
||||
setPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleClearDraft = useCallback(() => {
|
||||
// No-op: screen navigates away on success, text should stay for retry on error
|
||||
}, []);
|
||||
|
||||
const badgePressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.badge,
|
||||
Boolean(hovered) && !isPending && styles.badgeHovered,
|
||||
pressed && !isPending && styles.badgePressed,
|
||||
isPending && styles.badgeDisabled,
|
||||
],
|
||||
[isPending],
|
||||
);
|
||||
|
||||
const handlePickerOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setPickerOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setPickerSearchQuery("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const buildCreateWorktreeInput = useCallback(
|
||||
(input: { cwd: string; attachments: AgentAttachment[] }) => {
|
||||
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
@@ -496,9 +518,7 @@ export function NewWorkspaceScreen({
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={chatDraft.cwd}
|
||||
clearDraft={() => {
|
||||
// No-op: screen navigates away on success, text should stay for retry on error
|
||||
}}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={
|
||||
@@ -520,12 +540,7 @@ export function NewWorkspaceScreen({
|
||||
testID="new-workspace-ref-picker-trigger"
|
||||
onPress={openPicker}
|
||||
disabled={isPending}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.badge,
|
||||
hovered && !isPending && styles.badgeHovered,
|
||||
pressed && !isPending && styles.badgePressed,
|
||||
isPending && styles.badgeDisabled,
|
||||
]}
|
||||
style={badgePressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Starting ref"
|
||||
>
|
||||
@@ -557,12 +572,7 @@ export function NewWorkspaceScreen({
|
||||
searchPlaceholder="Search branches and PRs"
|
||||
title="Start from"
|
||||
open={pickerOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setPickerOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setPickerSearchQuery("");
|
||||
}
|
||||
}}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { FolderOpen, Smartphone } from "lucide-react-native";
|
||||
@@ -35,6 +35,13 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
}
|
||||
}, [isCompactLayout, openDesktopAgentList]);
|
||||
|
||||
const handleOpenPicker = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
||||
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
@@ -55,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={FolderOpen}
|
||||
onPress={() => void openProjectPicker()}
|
||||
onPress={handleOpenPicker}
|
||||
testID="open-project-submit"
|
||||
>
|
||||
Add a project
|
||||
@@ -64,7 +71,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={Smartphone}
|
||||
onPress={() => setIsPairDeviceOpen(true)}
|
||||
onPress={handleOpenPairDevice}
|
||||
testID="open-project-pair-device"
|
||||
>
|
||||
Pair device
|
||||
@@ -74,7 +81,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
</View>
|
||||
<PairDeviceModal
|
||||
visible={isPairDeviceOpen}
|
||||
onClose={() => setIsPairDeviceOpen(false)}
|
||||
onClose={handleClosePairDevice}
|
||||
testID="open-project-pair-device-modal"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -12,6 +12,66 @@ import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||
|
||||
interface ProviderRowProps {
|
||||
def: ProviderDefinition;
|
||||
entry: ProviderEntry | undefined;
|
||||
isFirst: boolean;
|
||||
onPress: (providerId: string) => void;
|
||||
}
|
||||
|
||||
function ProviderRow({ def, entry, isFirst, onPress }: ProviderRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = entry?.models?.length ?? 0;
|
||||
|
||||
const handlePress = useCallback(() => onPress(def.id), [def.id, onPress]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[settingsStyles.row, !isFirst && settingsStyles.rowBorder]}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
{status === "ready" && modelCount > 0 ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<StatusBadge
|
||||
label={
|
||||
status === "ready"
|
||||
? "Available"
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: status === "loading"
|
||||
? "Loading..."
|
||||
: "Not installed"
|
||||
}
|
||||
variant={status === "ready" ? "success" : status === "error" ? "error" : "muted"}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProvidersSectionProps {
|
||||
serverId: string;
|
||||
}
|
||||
@@ -26,10 +86,16 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
|
||||
const hasServer = serverId.length > 0;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
|
||||
|
||||
const refreshAction =
|
||||
hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={() => void refresh()}
|
||||
onPress={handleRefresh}
|
||||
disabled={providerRefreshInFlight}
|
||||
hitSlop={8}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
@@ -62,58 +128,15 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
</View>
|
||||
) : (
|
||||
<View style={settingsStyles.card}>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
status === "error" &&
|
||||
typeof entry?.error === "string" &&
|
||||
entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = entry?.models?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={def.id}
|
||||
style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder]}
|
||||
onPress={() => setDiagnosticProvider(def.id)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
{status === "ready" && modelCount > 0 ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<StatusBadge
|
||||
label={
|
||||
status === "ready"
|
||||
? "Available"
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: status === "loading"
|
||||
? "Loading..."
|
||||
: "Not installed"
|
||||
}
|
||||
variant={
|
||||
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{providerDefinitions.map((def, index) => (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
def={def}
|
||||
entry={entries?.find((e) => e.provider === def.id)}
|
||||
isFirst={index === 0}
|
||||
onPress={setDiagnosticProvider}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
@@ -122,7 +145,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
<ProviderDiagnosticSheet
|
||||
provider={diagnosticProvider}
|
||||
visible
|
||||
onClose={() => setDiagnosticProvider(null)}
|
||||
onClose={handleCloseDiagnostic}
|
||||
serverId={serverId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -23,6 +23,14 @@ interface StartupSplashScreenProps {
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const DOCS_URL = "https://paseo.sh/docs";
|
||||
|
||||
function openGithubIssue(): void {
|
||||
void openExternalUrl(GITHUB_ISSUE_URL);
|
||||
}
|
||||
|
||||
function openDocs(): void {
|
||||
void openExternalUrl(DOCS_URL);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "relative",
|
||||
@@ -240,12 +248,12 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
return "No daemon logs available.";
|
||||
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
|
||||
|
||||
const handleCopyLogs = () => {
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
const payload = daemonLogs?.logPath
|
||||
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
|
||||
: logsText;
|
||||
void Clipboard.setStringAsync(payload);
|
||||
};
|
||||
}, [daemonLogs?.logPath, daemonLogs?.contents, logsText]);
|
||||
|
||||
if (isSimpleSplash) {
|
||||
return (
|
||||
@@ -327,14 +335,14 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
||||
onPress={openGithubIssue}
|
||||
>
|
||||
Open GitHub issue
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(DOCS_URL)}
|
||||
onPress={openDocs}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user