From 84571e5309485211dd95121451b24b08db98542b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Apr 2026 00:10:24 +0700 Subject: [PATCH] chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop) Work in progress: 30 of 369 warnings fixed across 23 files. --- .../src/components/archived-agent-callout.tsx | 6 +- .../src/components/attachment-lightbox.tsx | 9 +- packages/app/src/components/callout-card.tsx | 20 +- packages/app/src/components/diff-scroll.tsx | 7 +- .../app/src/components/diff-scroll.web.tsx | 8 +- packages/app/src/components/diff-viewer.tsx | 7 +- .../app/src/components/download-toast.tsx | 14 +- .../src/components/headers/back-header.tsx | 15 +- .../headers/header-toggle-button.tsx | 4 +- .../src/components/headers/menu-header.tsx | 8 +- .../components/keyboard-shortcuts-dialog.tsx | 6 +- .../components/provider-diagnostic-sheet.tsx | 24 ++- packages/app/src/components/resize-handle.tsx | 28 +-- .../components/sidebar/sidebar-header-row.tsx | 46 +++-- .../src/components/stream-strategy-native.tsx | 6 +- .../src/components/stream-strategy-web.tsx | 17 +- .../ui/isolated-bottom-sheet-modal.test.tsx | 4 +- .../src/components/ui/segmented-control.tsx | 44 +++-- packages/app/src/components/ui/tooltip.tsx | 6 +- packages/app/src/panels/draft-panel.tsx | 35 ++-- packages/app/src/panels/setup-panel.tsx | 187 ++++++++++++------ packages/app/src/screens/sessions-screen.tsx | 10 +- .../workspace/workspace-tab-presentation.tsx | 19 +- 23 files changed, 346 insertions(+), 184 deletions(-) diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 39bc06ea4..e8cfecabe 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { View, Text } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import Animated from "react-native-reanimated"; @@ -27,7 +27,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout [insets.bottom, keyboardAnimatedStyle], ); - async function handleUnarchive() { + const handleUnarchive = useCallback(async () => { if (!client || !isConnected || isUnarchiving) return; setIsUnarchiving(true); try { @@ -36,7 +36,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error); setIsUnarchiving(false); } - } + }, [client, isConnected, isUnarchiving, agentId]); return ( diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index 531175420..0a9913153 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Modal, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -47,6 +47,9 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp [insets.top, insets.right, theme.spacing], ); + const handleImageError = useCallback(() => setErrored(true), []); + const noopPress = useCallback(() => {}, []); + if (!metadata) { return null; } @@ -68,12 +71,12 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp {hasError ? ( Couldn't load image ) : ( - {}} style={styles.imagePressable}> + setErrored(true)} + onError={handleImageError} style={imageFillStyle} /> diff --git a/packages/app/src/components/callout-card.tsx b/packages/app/src/components/callout-card.tsx index 2258a3335..4bc92fa57 100644 --- a/packages/app/src/components/callout-card.tsx +++ b/packages/app/src/components/callout-card.tsx @@ -1,6 +1,6 @@ import { X } from "lucide-react-native"; -import { useMemo, type ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo, type ReactNode } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; export type CalloutActionVariant = "primary" | "secondary"; @@ -113,18 +113,22 @@ function CalloutActionButton({ action, testID }: { action: CalloutAction; testID () => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary], [isPrimary], ); + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.actionButton, + isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary, + pressed ? styles.actionButtonPressed : null, + action.disabled ? styles.actionButtonDisabled : null, + ], + [action.disabled, isPrimary], + ); return ( [ - styles.actionButton, - isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary, - pressed ? styles.actionButtonPressed : null, - action.disabled ? styles.actionButtonDisabled : null, - ]} + style={pressableStyle} > {action.label} diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx index ab5feb77b..ec66855d2 100644 --- a/packages/app/src/components/diff-scroll.tsx +++ b/packages/app/src/components/diff-scroll.tsx @@ -61,6 +61,11 @@ export function DiffScroll({ [horizontalScroll, scrollId], ); + const handleLayout = useCallback( + (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), + [onScrollViewWidthChange], + ); + return ( onScrollViewWidthChange(e.nativeEvent.layout.width)} + onLayout={handleLayout} // When at left edge, wait for close gesture to fail before scrolling. // The close gesture fails quickly on leftward swipes (failOffsetX=-10), // so scrolling left works normally. On rightward swipes, close gesture diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 80f61a763..8af11ee0e 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; @@ -18,6 +18,10 @@ export function DiffScroll({ }: DiffScrollProps) { const webScrollbarStyle = useWebScrollbarStyle(); const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]); + const handleLayout = useCallback( + (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), + [onScrollViewWidthChange], + ); return ( onScrollViewWidthChange(e.nativeEvent.layout.width)} + onLayout={handleLayout} > {children} diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 0e7eb2a9c..cda3382a0 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -25,6 +25,11 @@ export function DiffViewer({ }: DiffViewerProps) { const [scrollViewWidth, setScrollViewWidth] = React.useState(0); const webScrollbarStyle = useWebScrollbarStyle(); + const handleInnerLayout = React.useCallback( + (e: { nativeEvent: { layout: { width: number } } }) => + setScrollViewWidth(e.nativeEvent.layout.width), + [], + ); if (!diffLines.length) { return ( @@ -52,7 +57,7 @@ export function DiffViewer({ showsHorizontalScrollIndicator style={webScrollbarStyle} contentContainerStyle={styles.horizontalContent} - onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)} + onLayout={handleInnerLayout} > 0 && { minWidth: scrollViewWidth }]}> {diffLines.map((line, index) => ( diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx index 993168a3e..ae025201c 100644 --- a/packages/app/src/components/download-toast.tsx +++ b/packages/app/src/components/download-toast.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -41,6 +41,12 @@ export function DownloadToast() { [theme.spacing, insets.bottom], ); + const handleDismiss = useCallback(() => { + if (activeDownload) { + dismissDownload(activeDownload.id); + } + }, [activeDownload, dismissDownload]); + if (!activeDownload) { return null; } @@ -75,11 +81,7 @@ export function DownloadToast() { )} {activeDownload.status !== "downloading" && ( - dismissDownload(activeDownload.id)} - hitSlop={8} - style={styles.dismiss} - > + )} diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index e17f6072c..30382f239 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useCallback, type ReactNode } from "react"; import { Pressable } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -13,15 +13,26 @@ interface BackHeaderProps { onBack?: () => void; } +function goBack(): void { + router.back(); +} + export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); + const handleBack = useCallback(() => { + if (onBack) { + onBack(); + return; + } + goBack(); + }, [onBack]); return ( router.back())} + onPress={handleBack} style={styles.backButton} accessibilityRole="button" accessibilityLabel="Back" diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index c1f6792f1..bf2c0816b 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -50,9 +50,7 @@ export function HeaderToggleButton({ {...props} {...ariaExpandedProps} disabled={disabled} - onPress={(e) => { - onPress(e); - }} + onPress={onPress} style={combinedStyle} > {typeof children === "function" diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 1449326de..eaaecb258 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useCallback, type ReactNode } from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PanelLeft } from "lucide-react-native"; @@ -53,9 +53,13 @@ export function SidebarMenuToggle({ const menuIconColor = !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; + const handlePress = useCallback(() => { + toggleAgentListForLayout({ isCompact: isMobile }); + }, [toggleAgentListForLayout, isMobile]); + return ( toggleAgentListForLayout({ isCompact: isMobile })} + onPress={handlePress} tooltipLabel="Toggle sidebar" tooltipKeys={toggleShortcutKeys} tooltipSide={tooltipSide} diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index 92bc86e01..566730721 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { getIsElectronRuntime } from "@/constants/layout"; @@ -21,11 +21,13 @@ export function KeyboardShortcutsDialog() { [isDesktopApp, isMac], ); + const handleClose = useCallback(() => setOpen(false), [setOpen]); + return ( setOpen(false)} + onClose={handleClose} testID="keyboard-shortcuts-dialog" snapPoints={SNAP_POINTS} > diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 4808a5234..fbf56608b 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,6 +1,13 @@ import { AlertCircle, RotateCw, Search } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { + ActivityIndicator, + Pressable, + type PressableStateCallbackType, + ScrollView, + Text, + View, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -81,6 +88,15 @@ export function ProviderDiagnosticSheet({ [client, provider], ); + const refreshButtonStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + sheetStyles.iconButton, + (Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered, + refreshInFlight ? sheetStyles.disabled : null, + ], + [refreshInFlight], + ); + const handleRefresh = useCallback(() => { if (!provider) { return; @@ -155,11 +171,7 @@ export function ProviderDiagnosticSheet({ onPress={handleRefresh} disabled={refreshInFlight} hitSlop={8} - style={({ hovered, pressed }) => [ - sheetStyles.iconButton, - (hovered || pressed) && sheetStyles.iconButtonHovered, - refreshInFlight ? sheetStyles.disabled : null, - ]} + style={refreshButtonStyle} accessibilityRole="button" accessibilityLabel={ refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}` diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index a5bad521a..a803ef67c 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -93,6 +93,20 @@ export function ResizeHandle({ [direction, groupId, index, onResizeSplit, sizes], ); + const handlePointerEnter = useCallback(() => { + hoverTimerRef.current = setTimeout(() => { + setActive(true); + }, 150); + }, []); + + const handlePointerLeave = useCallback(() => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setActive(false); + }, []); + return ( { - hoverTimerRef.current = setTimeout(() => { - setActive(true); - }, 150); - }} - onPointerLeave={() => { - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - setActive(false); - }} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} /> ); diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 94fad1fd5..d1d9c9b13 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,5 +1,5 @@ -import { useMemo } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { LucideIcon } from "lucide-react-native"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; @@ -31,6 +31,35 @@ export function SidebarHeaderRow({ }: SidebarHeaderRowProps) { const { theme } = useUnistyles(); + const buttonStyle = useCallback( + ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.button, + (Boolean(hovered) || isActive) && styles.buttonHovered, + ], + [isActive], + ); + + const renderChildren = useCallback( + (state: PressableStateCallbackType & { hovered?: boolean }) => { + const isHighlighted = Boolean(state.hovered) || isActive; + const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + <> + + + + ); + }, + [ + Icon, + isActive, + label, + theme.colors.foreground, + theme.colors.foregroundMuted, + theme.iconSize.md, + ], + ); + return ( [styles.button, (hovered || isActive) && styles.buttonHovered]} + style={buttonStyle} > - {({ hovered }) => { - const isHighlighted = hovered || isActive; - const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; - return ( - <> - - - - ); - }} + {renderChildren} ); diff --git a/packages/app/src/components/stream-strategy-native.tsx b/packages/app/src/components/stream-strategy-native.tsx index afb651310..dd25e5946 100644 --- a/packages/app/src/components/stream-strategy-native.tsx +++ b/packages/app/src/components/stream-strategy-native.tsx @@ -22,6 +22,10 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ autoscrollToTopThreshold: 0, }); +function keyExtractor(item: { id: string }): string { + return item.id; +} + function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { const { agentId, @@ -298,7 +302,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ref={flatListRef} data={historyRows} renderItem={renderItem} - keyExtractor={(item) => item.id} + keyExtractor={keyExtractor} testID="agent-chat-scroll" nativeID="agent-chat-scroll-native-virtualized" ListHeaderComponent={liveHeaderContent ?? undefined} diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 8f4abf145..d59b9865b 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -107,6 +107,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } = props; const scrollContainerRef = useRef(null); const contentRef = useRef(null); + const handleScrollContainerRef = useCallback((node: HTMLElement | null) => { + scrollContainerRef.current = node; + }, []); + const handleContentRef = useCallback((node: HTMLElement | null) => { + contentRef.current = node; + }, []); const [followOutput, setFollowOutputr] = useState(true); const setFollowOutput = (value: boolean) => { setFollowOutputr(value); @@ -705,19 +711,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return ( <>
{ - scrollContainerRef.current = node; - }} + ref={handleScrollContainerRef} data-testid="agent-chat-scroll" id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`} style={scrollContainerStyle} > -
{ - contentRef.current = node; - }} - style={contentContainerStyle} - > +
{shouldUseVirtualizer ? (
{virtualRows.map((virtualRow) => { diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx index ddec35405..efb52ae06 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx @@ -12,6 +12,8 @@ const SNAP_POINTS_50: (string | number)[] = ["50%"]; const SNAP_POINTS_60: (string | number)[] = ["60%"]; const SNAP_POINTS_90: (string | number)[] = ["90%"]; +function noop(): void {} + const { modalMethods, modalProps } = vi.hoisted(() => ({ modalMethods: { present: vi.fn(), @@ -117,7 +119,7 @@ describe("IsolatedBottomSheetModal", () => { it("allows nested sheets inside a parent sheet without creating a sibling provider", () => { const { getAllByTestId } = render( - {}}> +
Nested model picker
, diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 0b4280eac..b0cf05276 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -1,5 +1,5 @@ -import { useMemo, type ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo, type ReactNode } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import type { StyleProp, TextStyle, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -61,11 +61,8 @@ export function SegmentedControl({ hideLabels={hideLabels} segmentSizeStyle={segmentSizeStyle} labelSizeStyle={labelSizeStyle} - onPress={() => { - if (!option.disabled && option.value !== value) { - onValueChange(option.value); - } - }} + currentValue={value} + onValueChange={onValueChange} /> ); })} @@ -81,7 +78,8 @@ function SegmentItem({ hideLabels, segmentSizeStyle, labelSizeStyle, - onPress, + currentValue, + onValueChange, }: { option: SegmentedControlOption; isSelected: boolean; @@ -90,27 +88,37 @@ function SegmentItem({ hideLabels: boolean; segmentSizeStyle: StyleProp; labelSizeStyle: StyleProp; - onPress: () => void; + currentValue: T; + onValueChange: (value: T) => void; }) { const labelStyle = useMemo( () => [styles.label, labelSizeStyle, isSelected && styles.labelSelected], [labelSizeStyle, isSelected], ); + const handlePress = useCallback(() => { + if (!option.disabled && option.value !== currentValue) { + onValueChange(option.value); + } + }, [option.disabled, option.value, currentValue, onValueChange]); + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.segment, + segmentSizeStyle, + isSelected && styles.segmentSelected, + Boolean(hovered) && !isSelected && styles.segmentHover, + pressed && !isSelected && styles.segmentPressed, + option.disabled && styles.segmentDisabled, + ], + [isSelected, option.disabled, segmentSizeStyle], + ); return ( [ - styles.segment, - segmentSizeStyle, - isSelected && styles.segmentSelected, - hovered && !isSelected && styles.segmentHover, - pressed && !isSelected && styles.segmentPressed, - option.disabled && styles.segmentDisabled, - ]} + onPress={handlePress} + style={pressableStyle} > {option.icon ? ( diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index e77ace85e..2a8b7899a 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -494,6 +494,8 @@ export function TooltipContent({ [maxWidth, style, position?.x, position?.y], ); + const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]); + if (!ctx.open || !ctx.enabled) return null; // On web, avoid React Native's implementation (it uses and can @@ -525,9 +527,9 @@ export function TooltipContent({ transparent animationType="none" statusBarTranslucent={Platform.OS === "android"} - onRequestClose={() => ctx.setOpen(false)} + onRequestClose={handleDismiss} > - ctx.setOpen(false)}> + { + openFileInWorkspace(filePath); + }, + [openFileInWorkspace], + ); + + const handleCreated = useCallback( + (agentSnapshot: Parameters[0]) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + useSessionStore.getState().setAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentSnapshot.id, normalized); + return next; + }); + }, + [retargetCurrentTab, serverId], + ); + return ( { - openFileInWorkspace(filePath); - }} - onCreated={(agentSnapshot) => { - const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); - retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, normalized); - return next; - }); - }} + onOpenWorkspaceFile={handleOpenWorkspaceFile} + onCreated={handleCreated} /> ); } diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 6d78d4a99..f49a6fe82 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,13 +1,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { + ActivityIndicator, + Pressable, + type PressableStateCallbackType, + ScrollView, + Text, + View, +} from "react-native"; import invariant from "tiny-invariant"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; -import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { + useWorkspaceSetupStore, + type WorkspaceSetupSnapshot, +} from "@/stores/workspace-setup-store"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; function useSetupPanelDescriptor( @@ -223,67 +233,19 @@ function SetupPanel() { const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; return ( - - toggleExpanded(command.index, isAutoExpanded)} - style={({ pressed }) => [ - styles.commandRow, - showDetail && styles.commandRowExpanded, - pressed && styles.commandRowPressed, - ]} - accessibilityRole="button" - accessibilityState={{ expanded: showDetail }} - > - - - - - {command.command} - - {command.durationMs != null ? ( - {formatDuration(command.durationMs)} - ) : null} - - - {showDetail ? ( - - {hasLog ? ( - - - {processedLog} - - - ) : ( - - No output - - )} - {hasError ? ( - - - {snapshot.error} - - - ) : null} - - ) : null} - + ); })} @@ -317,6 +279,105 @@ function SetupPanel() { ); } +type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number]; + +interface SetupCommandRowProps { + command: SetupCommand; + showDetail: boolean; + isAutoExpanded: boolean; + isExpandable: boolean; + hasLog: boolean; + hasError: boolean; + processedLog: string; + errorMessage: string | null; + foregroundMutedColor: string; + onToggle: (index: number, isAutoExpanded: boolean) => void; +} + +function SetupCommandRow({ + command, + showDetail, + isAutoExpanded, + isExpandable, + hasLog, + hasError, + processedLog, + errorMessage, + foregroundMutedColor, + onToggle, +}: SetupCommandRowProps) { + const handlePress = useCallback(() => { + if (!isExpandable) return; + onToggle(command.index, isAutoExpanded); + }, [command.index, isAutoExpanded, isExpandable, onToggle]); + + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + styles.commandRow, + showDetail && styles.commandRowExpanded, + pressed && styles.commandRowPressed, + ], + [showDetail], + ); + + return ( + + + + + + + {command.command} + + {command.durationMs != null ? ( + {formatDuration(command.durationMs)} + ) : null} + + + {showDetail ? ( + + {hasLog ? ( + + + {processedLog} + + + ) : ( + + No output + + )} + {hasError && errorMessage ? ( + + + {errorMessage} + + + ) : null} + + ) : null} + + ); +} + export const setupPanelRegistration: PanelRegistration<"setup"> = { kind: "setup", component: SetupPanel, diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 6216d553e..3f02f85e5 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -47,6 +47,10 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { return [...agents].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); }, [agents]); + const handleBack = useCallback(() => { + router.navigate(buildHostOpenProjectRoute(serverId)); + }, [serverId]); + return ( @@ -57,11 +61,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { ) : sortedAgents.length === 0 ? ( No sessions yet - diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index e14aa3aba..717391f8e 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,5 +1,5 @@ -import { useMemo, type ReactElement, type ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Check } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; @@ -184,15 +184,16 @@ export function WorkspaceTabOptionRow({ trailingAccessory, }: WorkspaceTabOptionRowProps): ReactElement { const { theme } = useUnistyles(); + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.optionMainPressable, + (Boolean(hovered) || pressed || active) && styles.optionRowActive, + ], + [active], + ); return ( - [ - styles.optionMainPressable, - (hovered || pressed || active) && styles.optionRowActive, - ]} - > +