Update files
This commit is contained in:
Generated
+71
@@ -1836,6 +1836,59 @@
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||
@@ -20980,6 +21033,20 @@
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-draggable-flatlist": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
|
||||
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.17.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.64.0",
|
||||
"react-native-gesture-handler": ">=2.0.0",
|
||||
"react-native-reanimated": ">=2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-edge-to-edge": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.7.0.tgz",
|
||||
@@ -26173,6 +26240,9 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
@@ -26223,6 +26293,7 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
@@ -70,6 +73,7 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
|
||||
@@ -159,7 +159,7 @@ export function AgentList({
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.id);
|
||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
|
||||
const queryState = queryClient.getQueryState(queryKey);
|
||||
const isFetching = queryState?.fetchStatus === "fetching";
|
||||
const isFresh =
|
||||
@@ -193,6 +193,7 @@ export function AgentList({
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
agentId: agent.id,
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const projectPath = deriveProjectPath(agent.cwd, checkout);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { RefreshControl } from "react-native";
|
||||
import { useCallback } from "react";
|
||||
import DraggableFlatList, {
|
||||
type RenderItemParams,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
DraggableListProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
simultaneousGestureRef,
|
||||
waitFor,
|
||||
}: DraggableListProps<T>) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
// Pass the ref directly to DraggableFlatList - it handles the gesture coordination
|
||||
// The ref may not have .current set yet, but that's okay - DraggableFlatList will
|
||||
// read it when the gesture is being recognized
|
||||
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
|
||||
|
||||
const handleRenderItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive,
|
||||
};
|
||||
return renderItem(info);
|
||||
},
|
||||
[renderItem]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ data: newData }: { data: T[] }) => {
|
||||
onDragEnd(newData);
|
||||
},
|
||||
[onDragEnd]
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={handleRenderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={style}
|
||||
containerStyle={{ flex: 1 }}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
||||
waitFor={waitFor}
|
||||
refreshControl={
|
||||
onRefresh ? (
|
||||
<RefreshControl
|
||||
refreshing={refreshing ?? false}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.foregroundMuted}
|
||||
colors={[theme.colors.foregroundMuted]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file exists for TypeScript resolution.
|
||||
// The actual implementations are in:
|
||||
// - draggable-list.native.tsx (iOS/Android)
|
||||
// - draggable-list.web.tsx (Web)
|
||||
// Metro's platform-specific extensions will pick the right one at runtime.
|
||||
|
||||
export * from "./draggable-list.native";
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactElement, MutableRefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
|
||||
export interface DraggableRenderItemInfo<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
drag: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface DraggableListProps<T> {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
onDragEnd: (data: T[]) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
ListFooterComponent?: ReactElement | null;
|
||||
ListHeaderComponent?: ReactElement | null;
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
refreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
contentContainerFlexGrow?: boolean;
|
||||
/** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
/** Gesture ref(s) that the list should wait for before handling scroll */
|
||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useState, useRef, type ReactElement } from "react";
|
||||
import { View, ScrollView } from "react-native";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type {
|
||||
DraggableListProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
id: string;
|
||||
item: T;
|
||||
index: number;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
function SortableItem<T>({
|
||||
id,
|
||||
item,
|
||||
index,
|
||||
renderItem,
|
||||
activeId,
|
||||
}: SortableItemProps<T>) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const dragRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
// Store listeners in ref so drag handle can access them
|
||||
dragRef.current = () => {
|
||||
// Trigger drag - handled by dnd-kit's listeners
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
};
|
||||
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
{renderItem(info)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
}: DraggableListProps<T>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState(data);
|
||||
|
||||
// Sync items with data prop
|
||||
if (data !== items && !activeId) {
|
||||
setItems(data);
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setActiveId(null);
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex(
|
||||
(item, i) => keyExtractor(item, i) === active.id
|
||||
);
|
||||
const newIndex = items.findIndex(
|
||||
(item, i) => keyExtractor(item, i) === over.id
|
||||
);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newItems);
|
||||
onDragEnd(newItems);
|
||||
}
|
||||
},
|
||||
[items, keyExtractor, onDragEnd]
|
||||
);
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,10 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
@@ -182,6 +183,7 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
onClose={handleClose}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
fileViewMode={explorerViewMode}
|
||||
onFileViewModeChange={setExplorerViewMode}
|
||||
isMobile={isMobile}
|
||||
@@ -215,6 +217,7 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
onClose={handleClose}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
fileViewMode={explorerViewMode}
|
||||
onFileViewModeChange={setExplorerViewMode}
|
||||
isMobile={false}
|
||||
@@ -229,6 +232,7 @@ interface SidebarContentProps {
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
fileViewMode: ViewMode;
|
||||
onFileViewModeChange: (mode: ViewMode) => void;
|
||||
isMobile: boolean;
|
||||
@@ -240,6 +244,7 @@ function SidebarContent({
|
||||
onClose,
|
||||
serverId,
|
||||
agentId,
|
||||
cwd,
|
||||
fileViewMode,
|
||||
onFileViewModeChange,
|
||||
isMobile,
|
||||
@@ -293,7 +298,7 @@ function SidebarContent({
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{activeTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} />
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
|
||||
@@ -352,9 +352,10 @@ const DiffFileSection = memo(function DiffFileSection({
|
||||
interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -366,7 +367,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, agentId });
|
||||
useCheckoutStatusQuery({ serverId, agentId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
|
||||
@@ -3,50 +3,52 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
RefreshControl,
|
||||
SectionList,
|
||||
type ViewToken,
|
||||
type SectionListRenderItem,
|
||||
} from "react-native";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type ReactElement,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
||||
import {
|
||||
DraggableList,
|
||||
type DraggableRenderItemInfo,
|
||||
} from "./draggable-list";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import {
|
||||
groupAgents,
|
||||
parseRepoNameFromRemoteUrl,
|
||||
parseRepoShortNameFromRemoteUrl,
|
||||
type ProjectGroup,
|
||||
} from "@/utils/agent-grouping";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
type CheckoutStatusPayload,
|
||||
checkoutStatusQueryKey,
|
||||
useCheckoutStatusCacheOnly,
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
|
||||
type SectionType =
|
||||
| { type: "project"; data: ProjectGroup };
|
||||
import {
|
||||
useSectionOrderStore,
|
||||
sortProjectsByStoredOrder,
|
||||
} from "@/stores/section-order-store";
|
||||
|
||||
interface SectionData {
|
||||
key: string;
|
||||
projectKey: string;
|
||||
title: string;
|
||||
type: "project";
|
||||
data: AggregatedAgent[];
|
||||
agents: AggregatedAgent[];
|
||||
/** For project sections, the first agent's serverId (to lookup checkout status) */
|
||||
firstAgentServerId?: string;
|
||||
/** For project sections, the first agent's id (to lookup checkout status) */
|
||||
@@ -62,6 +64,8 @@ interface GroupedAgentListProps {
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
@@ -69,6 +73,8 @@ interface SectionHeaderProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onCreateAgent: (workingDir: string) => void;
|
||||
onDrag: () => void;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
@@ -76,6 +82,8 @@ function SectionHeader({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
onCreateAgent,
|
||||
onDrag,
|
||||
isDragging,
|
||||
}: SectionHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -84,12 +92,13 @@ function SectionHeader({
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: section.firstAgentServerId ?? "",
|
||||
agentId: section.firstAgentId ?? "",
|
||||
cwd: section.workingDir ?? "",
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
|
||||
// Derive display title: prefer repo name from remote URL, fallback to path-based name
|
||||
let displayTitle = section.title;
|
||||
if (section.type === "project" && checkout?.isGit && checkout.remoteUrl) {
|
||||
if (checkout?.isGit && checkout.remoteUrl) {
|
||||
const isGitHubRemote =
|
||||
checkout.remoteUrl.includes("github.com") ||
|
||||
checkout.remoteUrl.includes("git@github.com:");
|
||||
@@ -115,12 +124,16 @@ function SectionHeader({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
style={({ pressed }) => [
|
||||
styles.sectionHeader,
|
||||
isHovered && styles.sectionHeaderHovered,
|
||||
pressed && styles.sectionHeaderPressed,
|
||||
!isCollapsed && styles.sectionHeaderExpanded,
|
||||
isDragging && styles.sectionHeaderDragging,
|
||||
]}
|
||||
onPress={onToggle}
|
||||
onLongPress={onDrag}
|
||||
delayLongPress={200}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -132,10 +145,7 @@ function SectionHeader({
|
||||
<View style={styles.sectionHeaderRight}>
|
||||
{section.workingDir && (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.createAgentButton,
|
||||
{ opacity: isHovered ? 1 : 0 },
|
||||
]}
|
||||
style={styles.createAgentButton}
|
||||
onPress={handleCreatePress}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(true)}
|
||||
@@ -165,6 +175,7 @@ export function GroupedAgentList({
|
||||
selectedAgentId,
|
||||
onAgentSelect,
|
||||
listFooterComponent,
|
||||
parentGestureRef,
|
||||
}: GroupedAgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
@@ -249,12 +260,42 @@ export function GroupedAgentList({
|
||||
[]
|
||||
);
|
||||
|
||||
// Prefetch checkout status for all agents in the sidebar.
|
||||
// The sidebar shows a limited number of agents, so we fetch all of them upfront
|
||||
// to ensure project grouping (by remote URL) is stable from the start.
|
||||
useEffect(() => {
|
||||
for (const agent of agents) {
|
||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||
const client = session?.client ?? null;
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
if (!client || !isConnected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
|
||||
const queryState = queryClient.getQueryState(queryKey);
|
||||
const isFetching = queryState?.fetchStatus === "fetching";
|
||||
const isFresh =
|
||||
typeof queryState?.dataUpdatedAt === "number" &&
|
||||
Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME;
|
||||
if (isFetching || isFresh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: async () => await client.getCheckoutStatus(agent.id),
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
}, [agents, queryClient]);
|
||||
|
||||
// Subscribe to checkout status cache entries so project grouping can react
|
||||
// to remote URL updates (e.g. git worktrees in different directories).
|
||||
const checkoutCacheQueries = useQueries({
|
||||
queries: agents.map(
|
||||
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
||||
queryKey: checkoutStatusQueryKey(agent.serverId, agent.id),
|
||||
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
|
||||
enabled: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
||||
@@ -278,6 +319,10 @@ export function GroupedAgentList({
|
||||
return result;
|
||||
}, [agents, checkoutCacheQueries]);
|
||||
|
||||
// Section order from store
|
||||
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
// Group agents
|
||||
const { activeGroups } = useMemo(
|
||||
() =>
|
||||
@@ -288,19 +333,24 @@ export function GroupedAgentList({
|
||||
[agents, remoteUrlByAgentKey]
|
||||
);
|
||||
|
||||
// Build sections for SectionList
|
||||
// Sort groups by persisted order
|
||||
const sortedGroups = useMemo(
|
||||
() => sortProjectsByStoredOrder(activeGroups, projectOrder),
|
||||
[activeGroups, projectOrder]
|
||||
);
|
||||
|
||||
// Build sections for DraggableFlatList
|
||||
const sections: SectionData[] = useMemo(() => {
|
||||
const result: SectionData[] = [];
|
||||
|
||||
for (const group of activeGroups) {
|
||||
for (const group of sortedGroups) {
|
||||
const sectionKey = `project:${group.projectKey}`;
|
||||
const isCollapsed = collapsedSections.has(sectionKey);
|
||||
const firstAgent = group.agents[0];
|
||||
result.push({
|
||||
key: sectionKey,
|
||||
projectKey: group.projectKey,
|
||||
title: group.projectName,
|
||||
type: "project",
|
||||
data: isCollapsed ? [] : group.agents,
|
||||
agents: group.agents,
|
||||
firstAgentServerId: firstAgent?.serverId,
|
||||
firstAgentId: firstAgent?.id,
|
||||
workingDir: firstAgent?.cwd,
|
||||
@@ -308,45 +358,26 @@ export function GroupedAgentList({
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeGroups, collapsedSections]);
|
||||
}, [sortedGroups]);
|
||||
|
||||
const viewabilityConfig = useMemo(
|
||||
() => ({ itemVisiblePercentThreshold: 30 }),
|
||||
[]
|
||||
);
|
||||
// Sync section order when new projects appear
|
||||
useEffect(() => {
|
||||
const currentKeys = sections.map((s) => s.projectKey);
|
||||
const storedKeys = new Set(projectOrder);
|
||||
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
||||
|
||||
const onViewableItemsChanged = useRef(
|
||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||
for (const token of viewableItems) {
|
||||
const agent = token.item as AggregatedAgent | undefined;
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||
const client = session?.client ?? null;
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
if (!client || !isConnected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.id);
|
||||
const queryState = queryClient.getQueryState(queryKey);
|
||||
const isFetching = queryState?.fetchStatus === "fetching";
|
||||
const isFresh =
|
||||
typeof queryState?.dataUpdatedAt === "number" &&
|
||||
Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME;
|
||||
if (isFetching || isFresh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: async () => await client.getCheckoutStatus(agent.id),
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
if (newKeys.length > 0) {
|
||||
// Add new projects at the end of the stored order
|
||||
setProjectOrder([...projectOrder, ...newKeys]);
|
||||
}
|
||||
}, [sections, projectOrder, setProjectOrder]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(newData: SectionData[]) => {
|
||||
const newOrder = newData.map((section) => section.projectKey);
|
||||
setProjectOrder(newOrder);
|
||||
},
|
||||
[setProjectOrder]
|
||||
);
|
||||
|
||||
const AgentListRow = useCallback(
|
||||
@@ -364,6 +395,7 @@ export function GroupedAgentList({
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
agentId: agent.id,
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const activeBranchLabel = checkout?.isGit
|
||||
@@ -418,60 +450,49 @@ export function GroupedAgentList({
|
||||
]
|
||||
);
|
||||
|
||||
const renderItem: SectionListRenderItem<AggregatedAgent, SectionData> =
|
||||
useCallback(({ item: agent }) => <AgentListRow agent={agent} />, [AgentListRow]);
|
||||
|
||||
const renderSectionHeader = useCallback(
|
||||
({ section }: { section: SectionData }) => {
|
||||
const renderSection = useCallback(
|
||||
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||
const isCollapsed = collapsedSections.has(section.key);
|
||||
|
||||
return (
|
||||
<SectionHeader
|
||||
section={section}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleSection(section.key)}
|
||||
onCreateAgent={handleCreateAgentInProject}
|
||||
/>
|
||||
<View style={isActive && styles.sectionDragging}>
|
||||
<SectionHeader
|
||||
section={section}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleSection(section.key)}
|
||||
onCreateAgent={handleCreateAgentInProject}
|
||||
onDrag={drag}
|
||||
isDragging={isActive}
|
||||
/>
|
||||
{!isCollapsed &&
|
||||
section.agents.map((agent) => (
|
||||
<AgentListRow key={`${agent.serverId}:${agent.id}`} agent={agent} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[collapsedSections, toggleSection, handleCreateAgentInProject]
|
||||
[AgentListRow, collapsedSections, handleCreateAgentInProject, toggleSection]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`,
|
||||
(section: SectionData) => section.key,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionList
|
||||
sections={sections}
|
||||
<DraggableList
|
||||
data={sections}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
stickySectionHeadersEnabled={false}
|
||||
extraData={selectedAgentId}
|
||||
renderItem={renderSection}
|
||||
onDragEnd={handleDragEnd}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={12}
|
||||
windowSize={7}
|
||||
maxToRenderPerBatch={12}
|
||||
updateCellsBatchingPeriod={16}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
onViewableItemsChanged={onViewableItemsChanged.current}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
refreshControl={
|
||||
onRefresh ? (
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.foregroundMuted}
|
||||
colors={[theme.colors.foregroundMuted]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -528,7 +549,6 @@ export function GroupedAgentList({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
@@ -548,9 +568,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeaderHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
sectionHeaderPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sectionHeaderExpanded: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderDragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sectionDragging: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
sectionHeaderLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -44,6 +44,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
@@ -124,11 +125,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
|
||||
|
||||
// Close gesture (swipe left to close when sidebar is open)
|
||||
// Only activates on leftward swipe, fails on rightward or vertical movement
|
||||
// This mirrors the explorer-sidebar pattern for the right sidebar
|
||||
const closeGesture = Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isOpen)
|
||||
// Only activate after 15px horizontal movement
|
||||
.activeOffsetX([-15, 15])
|
||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||
// Only activate on leftward swipe (negative X)
|
||||
.activeOffsetX(-15)
|
||||
// Fail on rightward movement (allow internal list scrolling)
|
||||
.failOffsetX(10)
|
||||
// Fail if vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
@@ -214,6 +220,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Easing,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
@@ -19,6 +20,7 @@ interface SidebarAnimationContextValue {
|
||||
animateToOpen: () => void;
|
||||
animateToClose: () => void;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
||||
@@ -37,6 +39,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
const prevIsOpen = useRef(isOpen);
|
||||
@@ -108,6 +111,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
|
||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, agentId: string) {
|
||||
return ["checkoutStatus", serverId, agentId] as const;
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutStatusQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -29,7 +30,7 @@ function fetchCheckoutStatus(
|
||||
return client.getCheckoutStatus(agentId);
|
||||
}
|
||||
|
||||
export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQueryOptions) {
|
||||
export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
@@ -44,14 +45,14 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await fetchCheckoutStatus(client, agentId);
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
enabled: !!client && isConnected && !!agentId && !!cwd,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
refetchIntervalInBackground: true,
|
||||
@@ -86,13 +87,13 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ
|
||||
* initiating a fetch. Useful for list rows where a parent component prefetches
|
||||
* only the visible agents.
|
||||
*/
|
||||
export function useCheckoutStatusCacheOnly({ serverId, agentId }: UseCheckoutStatusQueryOptions) {
|
||||
export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
|
||||
@@ -415,6 +415,7 @@ function AgentScreenContent({
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
agentId: resolvedAgentId ?? "",
|
||||
cwd: agent?.cwd ?? "",
|
||||
});
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
|
||||
@@ -795,7 +796,7 @@ function AgentScreenContent({
|
||||
|
||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -812,7 +813,7 @@ function AgentScreenContent({
|
||||
|
||||
{/* Mobile Explorer Sidebar Overlay */}
|
||||
{isMobile && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
interface SectionOrderState {
|
||||
/** Ordered array of project keys. Projects not in this list appear at the end in their natural order. */
|
||||
projectOrder: string[];
|
||||
|
||||
/** Set the full order of project keys */
|
||||
setProjectOrder: (order: string[]) => void;
|
||||
|
||||
/** Move a project to a new index */
|
||||
moveProject: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
export const useSectionOrderStore = create<SectionOrderState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
projectOrder: [],
|
||||
|
||||
setProjectOrder: (order) => set({ projectOrder: order }),
|
||||
|
||||
moveProject: (fromIndex, toIndex) =>
|
||||
set((state) => {
|
||||
const newOrder = [...state.projectOrder];
|
||||
const [removed] = newOrder.splice(fromIndex, 1);
|
||||
if (removed !== undefined) {
|
||||
newOrder.splice(toIndex, 0, removed);
|
||||
}
|
||||
return { projectOrder: newOrder };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "section-order",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
projectOrder: state.projectOrder,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Sort project groups according to persisted order.
|
||||
* Projects not in the persisted order appear at the end in their original order.
|
||||
*/
|
||||
export function sortProjectsByStoredOrder<T extends { projectKey: string }>(
|
||||
groups: T[],
|
||||
storedOrder: string[]
|
||||
): T[] {
|
||||
if (storedOrder.length === 0) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const orderMap = new Map(storedOrder.map((key, index) => [key, index]));
|
||||
|
||||
return [...groups].sort((a, b) => {
|
||||
const aIndex = orderMap.get(a.projectKey);
|
||||
const bIndex = orderMap.get(b.projectKey);
|
||||
|
||||
// Both have stored order - sort by stored order
|
||||
if (aIndex !== undefined && bIndex !== undefined) {
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
|
||||
// Only a has stored order - a comes first
|
||||
if (aIndex !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Only b has stored order - b comes first
|
||||
if (bIndex !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Neither has stored order - maintain original order (stable sort)
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -213,6 +213,16 @@ const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
||||
const DEFAULT_MESSAGE_QUEUE_LIMIT = 0;
|
||||
|
||||
/** Default timeout for waiting for connection before sending queued messages */
|
||||
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000;
|
||||
|
||||
interface PendingSend {
|
||||
message: SessionInboundMessage;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class DaemonClientV2 {
|
||||
private transport: DaemonTransport | null = null;
|
||||
private transportCleanup: Array<() => void> = [];
|
||||
@@ -238,6 +248,7 @@ export class DaemonClientV2 {
|
||||
private messageQueueLimit: number | null;
|
||||
private agentIndex: Map<string, AgentSnapshotPayload> = new Map();
|
||||
private logger: Logger;
|
||||
private pendingSendQueue: PendingSend[] = [];
|
||||
|
||||
constructor(private config: DaemonClientV2Config) {
|
||||
this.messageQueueLimit =
|
||||
@@ -308,6 +319,7 @@ export class DaemonClientV2 {
|
||||
this.lastErrorValue = null;
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateConnectionState({ status: "connected" });
|
||||
this.flushPendingSendQueue();
|
||||
this.resolveConnect();
|
||||
}),
|
||||
transport.onClose((event) => {
|
||||
@@ -493,12 +505,17 @@ export class DaemonClientV2 {
|
||||
// Core Send Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Send a session message. For fire-and-forget messages (heartbeats, etc.),
|
||||
* failures are suppressed if `suppressSendErrors` is configured.
|
||||
* For RPC methods that wait for responses, use `sendSessionMessageOrThrow` instead.
|
||||
*/
|
||||
private sendSessionMessage(message: SessionInboundMessage): void {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Transport not connected");
|
||||
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
@@ -511,6 +528,78 @@ export class DaemonClientV2 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session message for RPC methods that create waiters.
|
||||
* If the connection is still being established ("connecting"), the message
|
||||
* is queued and will be sent once connected (or rejected after timeout).
|
||||
* This prevents waiters from hanging forever when called during connection.
|
||||
*/
|
||||
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
|
||||
const status = this.connectionState.status;
|
||||
|
||||
// If connected, send immediately
|
||||
if (this.transport && status === "connected") {
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// If connecting, queue the message to be sent once connected
|
||||
if (status === "connecting") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
// Remove from queue
|
||||
const idx = this.pendingSendQueue.findIndex((p) => p.resolve === resolve);
|
||||
if (idx !== -1) {
|
||||
this.pendingSendQueue.splice(idx, 1);
|
||||
}
|
||||
reject(new Error(`Timed out waiting for connection to send message`));
|
||||
}, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
|
||||
|
||||
this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
|
||||
});
|
||||
}
|
||||
|
||||
// Not connected and not connecting - fail immediately
|
||||
return Promise.reject(new Error(`Transport not connected (status: ${status})`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending send queue - called when connection is established.
|
||||
*/
|
||||
private flushPendingSendQueue(): void {
|
||||
const queue = this.pendingSendQueue;
|
||||
this.pendingSendQueue = [];
|
||||
|
||||
for (const pending of queue) {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
try {
|
||||
if (this.transport && this.connectionState.status === "connected") {
|
||||
const payload = SessionInboundMessageSchema.parse(pending.message);
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
pending.resolve();
|
||||
} else {
|
||||
pending.reject(new Error("Connection lost before message could be sent"));
|
||||
}
|
||||
} catch (error) {
|
||||
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all pending sends - called when connection fails or is closed.
|
||||
*/
|
||||
private rejectPendingSendQueue(error: Error): void {
|
||||
const queue = this.pendingSendQueue;
|
||||
this.pendingSendQueue = [];
|
||||
|
||||
for (const pending of queue) {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
throw new Error("Transport not connected");
|
||||
@@ -589,7 +678,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -612,7 +701,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -639,7 +728,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -682,7 +771,7 @@ export class DaemonClientV2 {
|
||||
{ skipQueue: true }
|
||||
);
|
||||
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
const status = await statusPromise;
|
||||
if (status.status === "agent_create_failed") {
|
||||
throw new Error(status.error);
|
||||
@@ -723,7 +812,7 @@ export class DaemonClientV2 {
|
||||
{ skipQueue: true }
|
||||
);
|
||||
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -747,7 +836,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
await response;
|
||||
}
|
||||
|
||||
@@ -790,7 +879,7 @@ export class DaemonClientV2 {
|
||||
{ skipQueue: true }
|
||||
);
|
||||
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
const status = await statusPromise;
|
||||
|
||||
return this.waitForAgentState(
|
||||
@@ -824,7 +913,7 @@ export class DaemonClientV2 {
|
||||
15000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -851,7 +940,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
const payload = await response;
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -926,7 +1015,7 @@ export class DaemonClientV2 {
|
||||
{ skipQueue: true }
|
||||
);
|
||||
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1046,10 +1135,6 @@ export class DaemonClientV2 {
|
||||
if (!requestId) {
|
||||
const existing = this.checkoutStatusInFlight.get(agentId);
|
||||
if (existing) {
|
||||
this.logger.debug(
|
||||
{ agentId, inFlightCount: this.checkoutStatusInFlight.size },
|
||||
"getCheckoutStatus: returning existing in-flight request"
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -1061,11 +1146,6 @@ export class DaemonClientV2 {
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
{ agentId, requestId: resolvedRequestId, waiterCount: this.waiters.size },
|
||||
"getCheckoutStatus: creating new request"
|
||||
);
|
||||
|
||||
const responsePromise = (async () => {
|
||||
const response = this.waitFor(
|
||||
(msg) => {
|
||||
@@ -1080,30 +1160,17 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
})();
|
||||
|
||||
if (!requestId) {
|
||||
this.checkoutStatusInFlight.set(agentId, responsePromise);
|
||||
responsePromise
|
||||
.then(() => {
|
||||
this.logger.debug(
|
||||
{ agentId, requestId: resolvedRequestId },
|
||||
"getCheckoutStatus: request completed successfully"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.debug(
|
||||
{ agentId, requestId: resolvedRequestId, error: err?.message },
|
||||
"getCheckoutStatus: request failed"
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.checkoutStatusInFlight.get(agentId) === responsePromise) {
|
||||
this.checkoutStatusInFlight.delete(agentId);
|
||||
}
|
||||
});
|
||||
responsePromise.finally(() => {
|
||||
if (this.checkoutStatusInFlight.get(agentId) === responsePromise) {
|
||||
this.checkoutStatusInFlight.delete(agentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return responsePromise;
|
||||
@@ -1134,7 +1201,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1164,7 +1231,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1195,7 +1262,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1225,7 +1292,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1249,7 +1316,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1280,7 +1347,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1307,7 +1374,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1335,7 +1402,7 @@ export class DaemonClientV2 {
|
||||
60000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1364,7 +1431,7 @@ export class DaemonClientV2 {
|
||||
20000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1391,7 +1458,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1418,7 +1485,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1464,7 +1531,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1499,7 +1566,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1528,7 +1595,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1560,7 +1627,7 @@ export class DaemonClientV2 {
|
||||
30000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1587,7 +1654,7 @@ export class DaemonClientV2 {
|
||||
30000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1618,7 +1685,7 @@ export class DaemonClientV2 {
|
||||
30000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1667,7 +1734,7 @@ export class DaemonClientV2 {
|
||||
timeout,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -1907,7 +1974,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1936,7 +2003,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1963,7 +2030,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -2008,7 +2075,7 @@ export class DaemonClientV2 {
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
this.sendSessionMessage(message);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -2121,9 +2188,10 @@ export class DaemonClientV2 {
|
||||
this.lastErrorValue = reason.trim();
|
||||
}
|
||||
|
||||
// Clear all pending waiters since the connection was lost and responses
|
||||
// from the previous connection will never arrive.
|
||||
// Clear all pending waiters and queued sends since the connection was lost
|
||||
// and responses from the previous connection will never arrive.
|
||||
this.clearWaiters(new Error(reason ?? "Connection lost"));
|
||||
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
|
||||
|
||||
this.updateConnectionState({
|
||||
status: "disconnected",
|
||||
|
||||
@@ -848,6 +848,10 @@ export class AgentManager {
|
||||
if (!this.registry) {
|
||||
return;
|
||||
}
|
||||
// Don't persist internal agents - they're ephemeral system tasks
|
||||
if (agent.internal) {
|
||||
return;
|
||||
}
|
||||
await this.registry.applySnapshot(agent, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -3930,10 +3930,11 @@ export class Session {
|
||||
);
|
||||
|
||||
// Add persisted agents that have not been lazily initialized yet
|
||||
// (excluding internal agents which are for ephemeral system tasks)
|
||||
const registryRecords = await this.agentStorage.list();
|
||||
const liveIds = new Set(agentSnapshots.map((a) => a.id));
|
||||
const persistedAgents = registryRecords
|
||||
.filter((record) => !liveIds.has(record.id))
|
||||
.filter((record) => !liveIds.has(record.id) && !record.internal)
|
||||
.map((record) => this.buildStoredAgentPayload(record));
|
||||
|
||||
const agents = [...liveAgents, ...persistedAgents];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
index 7c88afc..b630940 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
@@ -332,6 +332,13 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
panGesture.activeOffsetY(activeOffset);
|
||||
}
|
||||
}
|
||||
+ if (props.simultaneousHandlers) {
|
||||
+ panGesture.simultaneousWithExternalGesture(
|
||||
+ ...(Array.isArray(props.simultaneousHandlers)
|
||||
+ ? props.simultaneousHandlers
|
||||
+ : [props.simultaneousHandlers])
|
||||
+ );
|
||||
+ }
|
||||
|
||||
const onScroll = useStableCallback((scrollOffset: number) => {
|
||||
props.onScrollOffsetChange?.(scrollOffset);
|
||||
Reference in New Issue
Block a user