Update files

This commit is contained in:
Mohamed Boudra
2026-01-26 18:03:51 +07:00
parent 6095bf975b
commit c233c99cd3
19 changed files with 774 additions and 178 deletions
+71
View File
@@ -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",
+4
View File
@@ -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",
+2 -1
View File
@@ -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;
});
}
+131 -63
View File
@@ -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);
} }
+2 -1
View File
@@ -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);