Update files
This commit is contained in:
Generated
+71
@@ -1836,6 +1836,59 @@
|
|||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@egjs/hammerjs": {
|
||||||
"version": "2.0.17",
|
"version": "2.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||||
@@ -20980,6 +21033,20 @@
|
|||||||
"@babel/types": "^7.26.0"
|
"@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": {
|
"node_modules/react-native-edge-to-edge": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.7.0.tgz",
|
"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",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@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",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
@@ -26223,6 +26293,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "^0.81.5",
|
"react-native": "^0.81.5",
|
||||||
"react-native-css": "^3.0.1",
|
"react-native-css": "^3.0.1",
|
||||||
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-controller": "^1.19.2",
|
"react-native-keyboard-controller": "^1.19.2",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@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",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
@@ -70,6 +73,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "^0.81.5",
|
"react-native": "^0.81.5",
|
||||||
"react-native-css": "^3.0.1",
|
"react-native-css": "^3.0.1",
|
||||||
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-controller": "^1.19.2",
|
"react-native-keyboard-controller": "^1.19.2",
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function AgentList({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.id);
|
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
|
||||||
const queryState = queryClient.getQueryState(queryKey);
|
const queryState = queryClient.getQueryState(queryKey);
|
||||||
const isFetching = queryState?.fetchStatus === "fetching";
|
const isFetching = queryState?.fetchStatus === "fetching";
|
||||||
const isFresh =
|
const isFresh =
|
||||||
@@ -193,6 +193,7 @@ export function AgentList({
|
|||||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||||
serverId: agent.serverId,
|
serverId: agent.serverId,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
|
cwd: agent.cwd,
|
||||||
});
|
});
|
||||||
const checkout = checkoutQuery.data ?? null;
|
const checkout = checkoutQuery.data ?? null;
|
||||||
const projectPath = deriveProjectPath(agent.cwd, checkout);
|
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 {
|
interface ExplorerSidebarProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
@@ -182,6 +183,7 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
|
cwd={cwd}
|
||||||
fileViewMode={explorerViewMode}
|
fileViewMode={explorerViewMode}
|
||||||
onFileViewModeChange={setExplorerViewMode}
|
onFileViewModeChange={setExplorerViewMode}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -215,6 +217,7 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
|
cwd={cwd}
|
||||||
fileViewMode={explorerViewMode}
|
fileViewMode={explorerViewMode}
|
||||||
onFileViewModeChange={setExplorerViewMode}
|
onFileViewModeChange={setExplorerViewMode}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
@@ -229,6 +232,7 @@ interface SidebarContentProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
cwd: string;
|
||||||
fileViewMode: ViewMode;
|
fileViewMode: ViewMode;
|
||||||
onFileViewModeChange: (mode: ViewMode) => void;
|
onFileViewModeChange: (mode: ViewMode) => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
@@ -240,6 +244,7 @@ function SidebarContent({
|
|||||||
onClose,
|
onClose,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
|
cwd,
|
||||||
fileViewMode,
|
fileViewMode,
|
||||||
onFileViewModeChange,
|
onFileViewModeChange,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -293,7 +298,7 @@ function SidebarContent({
|
|||||||
{/* Content based on active tab */}
|
{/* Content based on active tab */}
|
||||||
<View style={styles.contentArea} testID="explorer-content-area">
|
<View style={styles.contentArea} testID="explorer-content-area">
|
||||||
{activeTab === "changes" && (
|
{activeTab === "changes" && (
|
||||||
<GitDiffPane serverId={serverId} agentId={agentId} />
|
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "files" && (
|
{activeTab === "files" && (
|
||||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||||
|
|||||||
@@ -352,9 +352,10 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
interface GitDiffPaneProps {
|
interface GitDiffPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -366,7 +367,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
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 gitStatus = status && status.isGit ? status : null;
|
||||||
const isGit = Boolean(gitStatus);
|
const isGit = Boolean(gitStatus);
|
||||||
const notGit = status !== null && !status.isGit && !status.error;
|
const notGit = status !== null && !status.isGit && !status.error;
|
||||||
|
|||||||
@@ -3,50 +3,52 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
Modal,
|
||||||
RefreshControl,
|
|
||||||
SectionList,
|
|
||||||
type ViewToken,
|
|
||||||
type SectionListRenderItem,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
|
useEffect,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
type MutableRefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
||||||
import { ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
||||||
|
import {
|
||||||
|
DraggableList,
|
||||||
|
type DraggableRenderItemInfo,
|
||||||
|
} from "./draggable-list";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import {
|
import {
|
||||||
groupAgents,
|
groupAgents,
|
||||||
parseRepoNameFromRemoteUrl,
|
parseRepoNameFromRemoteUrl,
|
||||||
parseRepoShortNameFromRemoteUrl,
|
parseRepoShortNameFromRemoteUrl,
|
||||||
type ProjectGroup,
|
|
||||||
} from "@/utils/agent-grouping";
|
} from "@/utils/agent-grouping";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
|
||||||
type CheckoutStatusPayload,
|
type CheckoutStatusPayload,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
useCheckoutStatusCacheOnly,
|
useCheckoutStatusCacheOnly,
|
||||||
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
buildAgentNavigationKey,
|
buildAgentNavigationKey,
|
||||||
startNavigationTiming,
|
startNavigationTiming,
|
||||||
} from "@/utils/navigation-timing";
|
} from "@/utils/navigation-timing";
|
||||||
|
import {
|
||||||
type SectionType =
|
useSectionOrderStore,
|
||||||
| { type: "project"; data: ProjectGroup };
|
sortProjectsByStoredOrder,
|
||||||
|
} from "@/stores/section-order-store";
|
||||||
|
|
||||||
interface SectionData {
|
interface SectionData {
|
||||||
key: string;
|
key: string;
|
||||||
|
projectKey: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: "project";
|
agents: AggregatedAgent[];
|
||||||
data: AggregatedAgent[];
|
|
||||||
/** For project sections, the first agent's serverId (to lookup checkout status) */
|
/** For project sections, the first agent's serverId (to lookup checkout status) */
|
||||||
firstAgentServerId?: string;
|
firstAgentServerId?: string;
|
||||||
/** For project sections, the first agent's id (to lookup checkout status) */
|
/** For project sections, the first agent's id (to lookup checkout status) */
|
||||||
@@ -62,6 +64,8 @@ interface GroupedAgentListProps {
|
|||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
onAgentSelect?: () => void;
|
onAgentSelect?: () => void;
|
||||||
listFooterComponent?: ReactElement | null;
|
listFooterComponent?: ReactElement | null;
|
||||||
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||||
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionHeaderProps {
|
interface SectionHeaderProps {
|
||||||
@@ -69,6 +73,8 @@ interface SectionHeaderProps {
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onCreateAgent: (workingDir: string) => void;
|
onCreateAgent: (workingDir: string) => void;
|
||||||
|
onDrag: () => void;
|
||||||
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({
|
function SectionHeader({
|
||||||
@@ -76,6 +82,8 @@ function SectionHeader({
|
|||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCreateAgent,
|
onCreateAgent,
|
||||||
|
onDrag,
|
||||||
|
isDragging,
|
||||||
}: SectionHeaderProps) {
|
}: SectionHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -84,12 +92,13 @@ function SectionHeader({
|
|||||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||||
serverId: section.firstAgentServerId ?? "",
|
serverId: section.firstAgentServerId ?? "",
|
||||||
agentId: section.firstAgentId ?? "",
|
agentId: section.firstAgentId ?? "",
|
||||||
|
cwd: section.workingDir ?? "",
|
||||||
});
|
});
|
||||||
const checkout = checkoutQuery.data ?? null;
|
const checkout = checkoutQuery.data ?? null;
|
||||||
|
|
||||||
// Derive display title: prefer repo name from remote URL, fallback to path-based name
|
// Derive display title: prefer repo name from remote URL, fallback to path-based name
|
||||||
let displayTitle = section.title;
|
let displayTitle = section.title;
|
||||||
if (section.type === "project" && checkout?.isGit && checkout.remoteUrl) {
|
if (checkout?.isGit && checkout.remoteUrl) {
|
||||||
const isGitHubRemote =
|
const isGitHubRemote =
|
||||||
checkout.remoteUrl.includes("github.com") ||
|
checkout.remoteUrl.includes("github.com") ||
|
||||||
checkout.remoteUrl.includes("git@github.com:");
|
checkout.remoteUrl.includes("git@github.com:");
|
||||||
@@ -115,12 +124,16 @@ function SectionHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={({ pressed }) => [
|
||||||
styles.sectionHeader,
|
styles.sectionHeader,
|
||||||
isHovered && styles.sectionHeaderHovered,
|
isHovered && styles.sectionHeaderHovered,
|
||||||
|
pressed && styles.sectionHeaderPressed,
|
||||||
!isCollapsed && styles.sectionHeaderExpanded,
|
!isCollapsed && styles.sectionHeaderExpanded,
|
||||||
|
isDragging && styles.sectionHeaderDragging,
|
||||||
]}
|
]}
|
||||||
onPress={onToggle}
|
onPress={onToggle}
|
||||||
|
onLongPress={onDrag}
|
||||||
|
delayLongPress={200}
|
||||||
onHoverIn={() => setIsHovered(true)}
|
onHoverIn={() => setIsHovered(true)}
|
||||||
onHoverOut={() => setIsHovered(false)}
|
onHoverOut={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
@@ -132,10 +145,7 @@ function SectionHeader({
|
|||||||
<View style={styles.sectionHeaderRight}>
|
<View style={styles.sectionHeaderRight}>
|
||||||
{section.workingDir && (
|
{section.workingDir && (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={styles.createAgentButton}
|
||||||
styles.createAgentButton,
|
|
||||||
{ opacity: isHovered ? 1 : 0 },
|
|
||||||
]}
|
|
||||||
onPress={handleCreatePress}
|
onPress={handleCreatePress}
|
||||||
onHoverIn={() => setIsHovered(true)}
|
onHoverIn={() => setIsHovered(true)}
|
||||||
onHoverOut={() => setIsHovered(true)}
|
onHoverOut={() => setIsHovered(true)}
|
||||||
@@ -165,6 +175,7 @@ export function GroupedAgentList({
|
|||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
onAgentSelect,
|
onAgentSelect,
|
||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
|
parentGestureRef,
|
||||||
}: GroupedAgentListProps) {
|
}: GroupedAgentListProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
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
|
// Subscribe to checkout status cache entries so project grouping can react
|
||||||
// to remote URL updates (e.g. git worktrees in different directories).
|
// to remote URL updates (e.g. git worktrees in different directories).
|
||||||
const checkoutCacheQueries = useQueries({
|
const checkoutCacheQueries = useQueries({
|
||||||
queries: agents.map(
|
queries: agents.map(
|
||||||
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
||||||
queryKey: checkoutStatusQueryKey(agent.serverId, agent.id),
|
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
|
||||||
enabled: false,
|
enabled: false,
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||||
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
||||||
@@ -278,6 +319,10 @@ export function GroupedAgentList({
|
|||||||
return result;
|
return result;
|
||||||
}, [agents, checkoutCacheQueries]);
|
}, [agents, checkoutCacheQueries]);
|
||||||
|
|
||||||
|
// Section order from store
|
||||||
|
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||||
|
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||||
|
|
||||||
// Group agents
|
// Group agents
|
||||||
const { activeGroups } = useMemo(
|
const { activeGroups } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -288,19 +333,24 @@ export function GroupedAgentList({
|
|||||||
[agents, remoteUrlByAgentKey]
|
[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 sections: SectionData[] = useMemo(() => {
|
||||||
const result: SectionData[] = [];
|
const result: SectionData[] = [];
|
||||||
|
|
||||||
for (const group of activeGroups) {
|
for (const group of sortedGroups) {
|
||||||
const sectionKey = `project:${group.projectKey}`;
|
const sectionKey = `project:${group.projectKey}`;
|
||||||
const isCollapsed = collapsedSections.has(sectionKey);
|
|
||||||
const firstAgent = group.agents[0];
|
const firstAgent = group.agents[0];
|
||||||
result.push({
|
result.push({
|
||||||
key: sectionKey,
|
key: sectionKey,
|
||||||
|
projectKey: group.projectKey,
|
||||||
title: group.projectName,
|
title: group.projectName,
|
||||||
type: "project",
|
agents: group.agents,
|
||||||
data: isCollapsed ? [] : group.agents,
|
|
||||||
firstAgentServerId: firstAgent?.serverId,
|
firstAgentServerId: firstAgent?.serverId,
|
||||||
firstAgentId: firstAgent?.id,
|
firstAgentId: firstAgent?.id,
|
||||||
workingDir: firstAgent?.cwd,
|
workingDir: firstAgent?.cwd,
|
||||||
@@ -308,45 +358,26 @@ export function GroupedAgentList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [activeGroups, collapsedSections]);
|
}, [sortedGroups]);
|
||||||
|
|
||||||
const viewabilityConfig = useMemo(
|
// Sync section order when new projects appear
|
||||||
() => ({ itemVisiblePercentThreshold: 30 }),
|
useEffect(() => {
|
||||||
[]
|
const currentKeys = sections.map((s) => s.projectKey);
|
||||||
);
|
const storedKeys = new Set(projectOrder);
|
||||||
|
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
||||||
|
|
||||||
const onViewableItemsChanged = useRef(
|
if (newKeys.length > 0) {
|
||||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
// Add new projects at the end of the stored order
|
||||||
for (const token of viewableItems) {
|
setProjectOrder([...projectOrder, ...newKeys]);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [sections, projectOrder, setProjectOrder]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(newData: SectionData[]) => {
|
||||||
|
const newOrder = newData.map((section) => section.projectKey);
|
||||||
|
setProjectOrder(newOrder);
|
||||||
|
},
|
||||||
|
[setProjectOrder]
|
||||||
);
|
);
|
||||||
|
|
||||||
const AgentListRow = useCallback(
|
const AgentListRow = useCallback(
|
||||||
@@ -364,6 +395,7 @@ export function GroupedAgentList({
|
|||||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||||
serverId: agent.serverId,
|
serverId: agent.serverId,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
|
cwd: agent.cwd,
|
||||||
});
|
});
|
||||||
const checkout = checkoutQuery.data ?? null;
|
const checkout = checkoutQuery.data ?? null;
|
||||||
const activeBranchLabel = checkout?.isGit
|
const activeBranchLabel = checkout?.isGit
|
||||||
@@ -418,60 +450,49 @@ export function GroupedAgentList({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem: SectionListRenderItem<AggregatedAgent, SectionData> =
|
const renderSection = useCallback(
|
||||||
useCallback(({ item: agent }) => <AgentListRow agent={agent} />, [AgentListRow]);
|
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||||
|
|
||||||
const renderSectionHeader = useCallback(
|
|
||||||
({ section }: { section: SectionData }) => {
|
|
||||||
const isCollapsed = collapsedSections.has(section.key);
|
const isCollapsed = collapsedSections.has(section.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionHeader
|
<View style={isActive && styles.sectionDragging}>
|
||||||
section={section}
|
<SectionHeader
|
||||||
isCollapsed={isCollapsed}
|
section={section}
|
||||||
onToggle={() => toggleSection(section.key)}
|
isCollapsed={isCollapsed}
|
||||||
onCreateAgent={handleCreateAgentInProject}
|
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(
|
const keyExtractor = useCallback(
|
||||||
(agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`,
|
(section: SectionData) => section.key,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionList
|
<DraggableList
|
||||||
sections={sections}
|
data={sections}
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
renderItem={renderItem}
|
renderItem={renderSection}
|
||||||
renderSectionHeader={renderSectionHeader}
|
onDragEnd={handleDragEnd}
|
||||||
stickySectionHeadersEnabled={false}
|
|
||||||
extraData={selectedAgentId}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
initialNumToRender={12}
|
|
||||||
windowSize={7}
|
|
||||||
maxToRenderPerBatch={12}
|
|
||||||
updateCellsBatchingPeriod={16}
|
|
||||||
ListFooterComponent={listFooterComponent}
|
ListFooterComponent={listFooterComponent}
|
||||||
onViewableItemsChanged={onViewableItemsChanged.current}
|
refreshing={isRefreshing}
|
||||||
viewabilityConfig={viewabilityConfig}
|
onRefresh={onRefresh}
|
||||||
refreshControl={
|
simultaneousGestureRef={parentGestureRef}
|
||||||
onRefresh ? (
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={isRefreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
tintColor={theme.colors.foregroundMuted}
|
|
||||||
colors={[theme.colors.foregroundMuted]}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -528,7 +549,6 @@ export function GroupedAgentList({
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
list: {
|
list: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
|
||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
@@ -548,9 +568,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sectionHeaderHovered: {
|
sectionHeaderHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
sectionHeaderPressed: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
sectionHeaderExpanded: {
|
sectionHeaderExpanded: {
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
sectionHeaderDragging: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
sectionDragging: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
sectionHeaderLeft: {
|
sectionHeaderLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
const trafficLightPadding = useTrafficLightPadding();
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const dragHandlers = useTauriDragHandlers();
|
||||||
@@ -124,11 +125,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
|
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
|
||||||
|
|
||||||
// Close gesture (swipe left to close when sidebar is open)
|
// 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()
|
const closeGesture = Gesture.Pan()
|
||||||
|
.withRef(closeGestureRef)
|
||||||
.enabled(isOpen)
|
.enabled(isOpen)
|
||||||
// Only activate after 15px horizontal movement
|
// Only activate on leftward swipe (negative X)
|
||||||
.activeOffsetX([-15, 15])
|
.activeOffsetX(-15)
|
||||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
// Fail on rightward movement (allow internal list scrolling)
|
||||||
|
.failOffsetX(10)
|
||||||
|
// Fail if vertical movement happens first (allow vertical scroll)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
isGesturing.value = true;
|
isGesturing.value = true;
|
||||||
@@ -214,6 +220,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
selectedAgentId={selectedAgentId}
|
selectedAgentId={selectedAgentId}
|
||||||
onAgentSelect={handleAgentSelectMobile}
|
onAgentSelect={handleAgentSelectMobile}
|
||||||
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Easing,
|
Easing,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface SidebarAnimationContextValue {
|
|||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
|
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
||||||
@@ -37,6 +39,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
||||||
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
@@ -108,6 +111,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
closeGestureRef,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import {
|
|||||||
|
|
||||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||||
|
|
||||||
export function checkoutStatusQueryKey(serverId: string, agentId: string) {
|
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||||
return ["checkoutStatus", serverId, agentId] as const;
|
return ["checkoutStatus", serverId, cwd] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseCheckoutStatusQueryOptions {
|
interface UseCheckoutStatusQueryOptions {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
@@ -29,7 +30,7 @@ function fetchCheckoutStatus(
|
|||||||
return client.getCheckoutStatus(agentId);
|
return client.getCheckoutStatus(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQueryOptions) {
|
export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
);
|
);
|
||||||
@@ -44,14 +45,14 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ
|
|||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
}
|
}
|
||||||
return await fetchCheckoutStatus(client, agentId);
|
return await fetchCheckoutStatus(client, agentId);
|
||||||
},
|
},
|
||||||
enabled: !!client && isConnected && !!agentId,
|
enabled: !!client && isConnected && !!agentId && !!cwd,
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
refetchIntervalInBackground: true,
|
refetchIntervalInBackground: true,
|
||||||
@@ -86,13 +87,13 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ
|
|||||||
* initiating a fetch. Useful for list rows where a parent component prefetches
|
* initiating a fetch. Useful for list rows where a parent component prefetches
|
||||||
* only the visible agents.
|
* only the visible agents.
|
||||||
*/
|
*/
|
||||||
export function useCheckoutStatusCacheOnly({ serverId, agentId }: UseCheckoutStatusQueryOptions) {
|
export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ function AgentScreenContent({
|
|||||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId: resolvedAgentId ?? "",
|
agentId: resolvedAgentId ?? "",
|
||||||
|
cwd: agent?.cwd ?? "",
|
||||||
});
|
});
|
||||||
const checkout = checkoutStatusQuery.status;
|
const checkout = checkoutStatusQuery.status;
|
||||||
|
|
||||||
@@ -795,7 +796,7 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
|
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -812,7 +813,7 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
{/* Mobile Explorer Sidebar Overlay */}
|
{/* Mobile Explorer Sidebar Overlay */}
|
||||||
{isMobile && resolvedAgentId && (
|
{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_RECONNECT_MAX_DELAY_MS = 30000;
|
||||||
const DEFAULT_MESSAGE_QUEUE_LIMIT = 0;
|
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 {
|
export class DaemonClientV2 {
|
||||||
private transport: DaemonTransport | null = null;
|
private transport: DaemonTransport | null = null;
|
||||||
private transportCleanup: Array<() => void> = [];
|
private transportCleanup: Array<() => void> = [];
|
||||||
@@ -238,6 +248,7 @@ export class DaemonClientV2 {
|
|||||||
private messageQueueLimit: number | null;
|
private messageQueueLimit: number | null;
|
||||||
private agentIndex: Map<string, AgentSnapshotPayload> = new Map();
|
private agentIndex: Map<string, AgentSnapshotPayload> = new Map();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
private pendingSendQueue: PendingSend[] = [];
|
||||||
|
|
||||||
constructor(private config: DaemonClientV2Config) {
|
constructor(private config: DaemonClientV2Config) {
|
||||||
this.messageQueueLimit =
|
this.messageQueueLimit =
|
||||||
@@ -308,6 +319,7 @@ export class DaemonClientV2 {
|
|||||||
this.lastErrorValue = null;
|
this.lastErrorValue = null;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.updateConnectionState({ status: "connected" });
|
this.updateConnectionState({ status: "connected" });
|
||||||
|
this.flushPendingSendQueue();
|
||||||
this.resolveConnect();
|
this.resolveConnect();
|
||||||
}),
|
}),
|
||||||
transport.onClose((event) => {
|
transport.onClose((event) => {
|
||||||
@@ -493,12 +505,17 @@ export class DaemonClientV2 {
|
|||||||
// Core Send Helpers
|
// 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 {
|
private sendSessionMessage(message: SessionInboundMessage): void {
|
||||||
if (!this.transport || this.connectionState.status !== "connected") {
|
if (!this.transport || this.connectionState.status !== "connected") {
|
||||||
if (this.config.suppressSendErrors) {
|
if (this.config.suppressSendErrors) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error("Transport not connected");
|
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||||
}
|
}
|
||||||
const payload = SessionInboundMessageSchema.parse(message);
|
const payload = SessionInboundMessageSchema.parse(message);
|
||||||
try {
|
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 {
|
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
||||||
if (!this.transport || this.connectionState.status !== "connected") {
|
if (!this.transport || this.connectionState.status !== "connected") {
|
||||||
throw new Error("Transport not connected");
|
throw new Error("Transport not connected");
|
||||||
@@ -589,7 +678,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +701,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +728,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,7 +771,7 @@ export class DaemonClientV2 {
|
|||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
const status = await statusPromise;
|
const status = await statusPromise;
|
||||||
if (status.status === "agent_create_failed") {
|
if (status.status === "agent_create_failed") {
|
||||||
throw new Error(status.error);
|
throw new Error(status.error);
|
||||||
@@ -723,7 +812,7 @@ export class DaemonClientV2 {
|
|||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,7 +836,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
await response;
|
await response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +879,7 @@ export class DaemonClientV2 {
|
|||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
const status = await statusPromise;
|
const status = await statusPromise;
|
||||||
|
|
||||||
return this.waitForAgentState(
|
return this.waitForAgentState(
|
||||||
@@ -824,7 +913,7 @@ export class DaemonClientV2 {
|
|||||||
15000,
|
15000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +940,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
const payload = await response;
|
const payload = await response;
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
@@ -926,7 +1015,7 @@ export class DaemonClientV2 {
|
|||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,10 +1135,6 @@ export class DaemonClientV2 {
|
|||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
const existing = this.checkoutStatusInFlight.get(agentId);
|
const existing = this.checkoutStatusInFlight.get(agentId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logger.debug(
|
|
||||||
{ agentId, inFlightCount: this.checkoutStatusInFlight.size },
|
|
||||||
"getCheckoutStatus: returning existing in-flight request"
|
|
||||||
);
|
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1061,11 +1146,6 @@ export class DaemonClientV2 {
|
|||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
{ agentId, requestId: resolvedRequestId, waiterCount: this.waiters.size },
|
|
||||||
"getCheckoutStatus: creating new request"
|
|
||||||
);
|
|
||||||
|
|
||||||
const responsePromise = (async () => {
|
const responsePromise = (async () => {
|
||||||
const response = this.waitFor(
|
const response = this.waitFor(
|
||||||
(msg) => {
|
(msg) => {
|
||||||
@@ -1080,30 +1160,17 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
this.checkoutStatusInFlight.set(agentId, responsePromise);
|
this.checkoutStatusInFlight.set(agentId, responsePromise);
|
||||||
responsePromise
|
responsePromise.finally(() => {
|
||||||
.then(() => {
|
if (this.checkoutStatusInFlight.get(agentId) === responsePromise) {
|
||||||
this.logger.debug(
|
this.checkoutStatusInFlight.delete(agentId);
|
||||||
{ 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return responsePromise;
|
return responsePromise;
|
||||||
@@ -1134,7 +1201,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,7 +1231,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,7 +1262,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1225,7 +1292,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,7 +1316,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,7 +1347,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,7 +1374,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1335,7 +1402,7 @@ export class DaemonClientV2 {
|
|||||||
60000,
|
60000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,7 +1431,7 @@ export class DaemonClientV2 {
|
|||||||
20000,
|
20000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,7 +1458,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1418,7 +1485,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,7 +1531,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,7 +1566,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1528,7 +1595,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1560,7 +1627,7 @@ export class DaemonClientV2 {
|
|||||||
30000,
|
30000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1587,7 +1654,7 @@ export class DaemonClientV2 {
|
|||||||
30000,
|
30000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1618,7 +1685,7 @@ export class DaemonClientV2 {
|
|||||||
30000,
|
30000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1667,7 +1734,7 @@ export class DaemonClientV2 {
|
|||||||
timeout,
|
timeout,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1907,7 +1974,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1936,7 +2003,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1963,7 +2030,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2008,7 +2075,7 @@ export class DaemonClientV2 {
|
|||||||
10000,
|
10000,
|
||||||
{ skipQueue: true }
|
{ skipQueue: true }
|
||||||
);
|
);
|
||||||
this.sendSessionMessage(message);
|
await this.sendSessionMessageOrThrow(message);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2121,9 +2188,10 @@ export class DaemonClientV2 {
|
|||||||
this.lastErrorValue = reason.trim();
|
this.lastErrorValue = reason.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all pending waiters since the connection was lost and responses
|
// Clear all pending waiters and queued sends since the connection was lost
|
||||||
// from the previous connection will never arrive.
|
// and responses from the previous connection will never arrive.
|
||||||
this.clearWaiters(new Error(reason ?? "Connection lost"));
|
this.clearWaiters(new Error(reason ?? "Connection lost"));
|
||||||
|
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
|
||||||
|
|
||||||
this.updateConnectionState({
|
this.updateConnectionState({
|
||||||
status: "disconnected",
|
status: "disconnected",
|
||||||
|
|||||||
@@ -848,6 +848,10 @@ export class AgentManager {
|
|||||||
if (!this.registry) {
|
if (!this.registry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Don't persist internal agents - they're ephemeral system tasks
|
||||||
|
if (agent.internal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.registry.applySnapshot(agent, options);
|
await this.registry.applySnapshot(agent, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3930,10 +3930,11 @@ export class Session {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add persisted agents that have not been lazily initialized yet
|
// 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 registryRecords = await this.agentStorage.list();
|
||||||
const liveIds = new Set(agentSnapshots.map((a) => a.id));
|
const liveIds = new Set(agentSnapshots.map((a) => a.id));
|
||||||
const persistedAgents = registryRecords
|
const persistedAgents = registryRecords
|
||||||
.filter((record) => !liveIds.has(record.id))
|
.filter((record) => !liveIds.has(record.id) && !record.internal)
|
||||||
.map((record) => this.buildStoredAgentPayload(record));
|
.map((record) => this.buildStoredAgentPayload(record));
|
||||||
|
|
||||||
const agents = [...liveAgents, ...persistedAgents];
|
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