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

This commit is contained in:
Mohamed Boudra
2026-04-24 00:25:02 +07:00
parent 84571e5309
commit 5f620a36d6
21 changed files with 560 additions and 323 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}>

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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}
/>,
);

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}
/>,
);
});

View File

@@ -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}

View File

@@ -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 ? (
<>

View File

@@ -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}>

View File

@@ -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",

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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>

View File

@@ -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} />
</>
);
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>