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

Work in progress: 30 of 369 warnings fixed across 23 files.
This commit is contained in:
Mohamed Boudra
2026-04-24 00:10:24 +07:00
parent 6f131ad551
commit 84571e5309
23 changed files with 346 additions and 184 deletions

View File

@@ -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 (
<Animated.View style={containerStyle}>

View File

@@ -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 ? (
<Text style={styles.errorText}>Couldn't load image</Text>
) : (
<Pressable onPress={() => {}} style={styles.imagePressable}>
<Pressable onPress={noopPress} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={{ uri: url }}
contentFit="contain"
onError={() => setErrored(true)}
onError={handleImageError}
style={imageFillStyle}
/>
</Pressable>

View File

@@ -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 (
<Pressable
onPress={action.onPress}
disabled={action.disabled}
testID={testID}
accessibilityRole="button"
style={({ pressed }) => [
styles.actionButton,
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
pressed ? styles.actionButtonPressed : null,
action.disabled ? styles.actionButtonDisabled : null,
]}
style={pressableStyle}
>
<Text style={labelStyle} numberOfLines={1}>
{action.label}

View File

@@ -61,6 +61,11 @@ export function DiffScroll({
[horizontalScroll, scrollId],
);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
return (
<ScrollView
ref={scrollViewRef}
@@ -72,7 +77,7 @@ export function DiffScroll({
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e: LayoutChangeEvent) => 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

View File

@@ -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 (
<ScrollView
@@ -26,7 +30,7 @@ export function DiffScroll({
showsHorizontalScrollIndicator
style={combinedStyle}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
onLayout={handleLayout}
>
{children}
</ScrollView>

View File

@@ -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}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
{diffLines.map((line, index) => (

View File

@@ -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() {
)}
</View>
{activeDownload.status !== "downloading" && (
<Pressable
onPress={() => dismissDownload(activeDownload.id)}
hitSlop={8}
style={styles.dismiss}
>
<Pressable onPress={handleDismiss} hitSlop={8} style={styles.dismiss}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
)}

View File

@@ -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 (
<ScreenHeader
left={
<>
<Pressable
onPress={onBack ?? (() => router.back())}
onPress={handleBack}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"

View File

@@ -50,9 +50,7 @@ export function HeaderToggleButton({
{...props}
{...ariaExpandedProps}
disabled={disabled}
onPress={(e) => {
onPress(e);
}}
onPress={onPress}
style={combinedStyle}
>
{typeof children === "function"

View File

@@ -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 (
<HeaderToggleButton
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
onPress={handlePress}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}

View File

@@ -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 (
<AdaptiveModalSheet
title="Shortcuts"
visible={open}
onClose={() => setOpen(false)}
onClose={handleClose}
testID="keyboard-shortcuts-dialog"
snapPoints={SNAP_POINTS}
>

View File

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

View File

@@ -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 (
<View
style={[
@@ -122,18 +136,8 @@ export function ResizeHandle({
} as any,
]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
}, 150);
}}
onPointerLeave={() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setActive(false);
}}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
</View>
);

View File

@@ -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 size={theme.iconSize.md} color={iconColor} />
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
</>
);
},
[
Icon,
isActive,
label,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.iconSize.md,
],
);
return (
<View style={styles.container}>
<Pressable
@@ -40,18 +69,9 @@ export function SidebarHeaderRow({
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
style={({ hovered }) => [styles.button, (hovered || isActive) && styles.buttonHovered]}
style={buttonStyle}
>
{({ hovered }) => {
const isHighlighted = hovered || isActive;
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Icon size={theme.iconSize.md} color={iconColor} />
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
</>
);
}}
{renderChildren}
</Pressable>
</View>
);

View File

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

View File

@@ -107,6 +107,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} = props;
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(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 (
<>
<div
ref={(node) => {
scrollContainerRef.current = node;
}}
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div
ref={(node) => {
contentRef.current = node;
}}
style={contentContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {

View File

@@ -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(
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_90}>
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_60} onChange={() => {}}>
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_60} onChange={noop}>
<div>Nested model picker</div>
</IsolatedBottomSheetModal>
</IsolatedBottomSheetModal>,

View File

@@ -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<T extends string>({
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<T extends string>({
hideLabels,
segmentSizeStyle,
labelSizeStyle,
onPress,
currentValue,
onValueChange,
}: {
option: SegmentedControlOption<T>;
isSelected: boolean;
@@ -90,27 +88,37 @@ function SegmentItem<T extends string>({
hideLabels: boolean;
segmentSizeStyle: StyleProp<ViewStyle>;
labelSizeStyle: StyleProp<TextStyle>;
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 (
<Pressable
accessibilityRole="button"
accessibilityState={{ selected: isSelected, disabled: option.disabled }}
disabled={option.disabled}
testID={option.testID}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
hovered && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
]}
onPress={handlePress}
style={pressableStyle}
>
{option.icon ? (
<View style={styles.iconContainer}>

View File

@@ -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 <Modal/> implementation (it uses <dialog> and can
@@ -525,9 +527,9 @@ export function TooltipContent({
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}

View File

@@ -1,4 +1,5 @@
import { SquarePen } from "lucide-react-native";
import { useCallback } from "react";
import invariant from "tiny-invariant";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
@@ -22,6 +23,26 @@ function DraftPanel() {
const { isInteractive } = usePaneFocus();
invariant(target.kind === "draft", "DraftPanel requires draft target");
const handleOpenWorkspaceFile = useCallback(
({ filePath }: { filePath: string }) => {
openFileInWorkspace(filePath);
},
[openFileInWorkspace],
);
const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[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 (
<WorkspaceDraftAgentTab
serverId={serverId}
@@ -29,18 +50,8 @@ function DraftPanel() {
tabId={tabId}
draftId={target.draftId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={({ filePath }) => {
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}
/>
);
}

View File

@@ -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 (
<View key={`${command.index}:${command.command}`} style={styles.commandItem}>
<Pressable
onPress={() => toggleExpanded(command.index, isAutoExpanded)}
style={({ pressed }) => [
styles.commandRow,
showDetail && styles.commandRowExpanded,
pressed && styles.commandRowPressed,
]}
accessibilityRole="button"
accessibilityState={{ expanded: showDetail }}
>
<View style={styles.commandStatusIcon}>
<CommandStatusIcon status={command.status} />
</View>
<Text style={styles.commandText} numberOfLines={1}>
{command.command}
</Text>
{command.durationMs != null ? (
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
) : null}
<SetupCommandChevron
showDetail={showDetail}
color={theme.colors.foregroundMuted}
/>
</Pressable>
{showDetail ? (
<View style={styles.commandDetail}>
{hasLog ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
horizontal={false}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
) : (
<View
style={styles.logScrollContent}
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text style={styles.emptyLogText}>No output</Text>
</View>
)}
{hasError ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{snapshot.error}
</Text>
</View>
) : null}
</View>
) : null}
</View>
<SetupCommandRow
key={`${command.index}:${command.command}`}
command={command}
showDetail={showDetail}
isAutoExpanded={isAutoExpanded}
isExpandable={isExpandable}
hasLog={hasLog}
hasError={!!hasError}
processedLog={processedLog}
errorMessage={snapshot?.error ?? null}
foregroundMutedColor={theme.colors.foregroundMuted}
onToggle={toggleExpanded}
/>
);
})}
@@ -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 (
<View style={styles.commandItem}>
<Pressable
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityState={{ expanded: showDetail }}
>
<View style={styles.commandStatusIcon}>
<CommandStatusIcon status={command.status} />
</View>
<Text style={styles.commandText} numberOfLines={1}>
{command.command}
</Text>
{command.durationMs != null ? (
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
) : null}
<SetupCommandChevron showDetail={showDetail} color={foregroundMutedColor} />
</Pressable>
{showDetail ? (
<View style={styles.commandDetail}>
{hasLog ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
horizontal={false}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
) : (
<View
style={styles.logScrollContent}
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text style={styles.emptyLogText}>No output</Text>
</View>
)}
{hasError && errorMessage ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{errorMessage}
</Text>
</View>
) : null}
</View>
) : null}
</View>
);
}
export const setupPanelRegistration: PanelRegistration<"setup"> = {
kind: "setup",
component: SetupPanel,

View File

@@ -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 (
<View style={styles.container}>
<MenuHeader title="Sessions" />
@@ -57,11 +61,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
) : sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
</View>

View File

@@ -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 (
<View style={[styles.optionRow, active && styles.optionRowActive]}>
<Pressable
onPress={onPress}
style={({ hovered = false, pressed }) => [
styles.optionMainPressable,
(hovered || pressed || active) && styles.optionRowActive,
]}
>
<Pressable onPress={onPress} style={pressableStyle}>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
</View>