From c233c99cd323597c21c1c4bc824aa32e3131d901 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Jan 2026 18:03:51 +0700 Subject: [PATCH] Update files --- package-lock.json | 71 ++++++ packages/app/package.json | 4 + packages/app/src/components/agent-list.tsx | 3 +- .../src/components/draggable-list.native.tsx | 86 +++++++ .../app/src/components/draggable-list.tsx | 7 + .../src/components/draggable-list.types.ts | 31 +++ .../app/src/components/draggable-list.web.tsx | 178 ++++++++++++++ .../app/src/components/explorer-sidebar.tsx | 9 +- packages/app/src/components/git-diff-pane.tsx | 5 +- .../app/src/components/grouped-agent-list.tsx | 223 ++++++++++-------- .../app/src/components/sliding-sidebar.tsx | 13 +- .../contexts/sidebar-animation-context.tsx | 4 + .../src/hooks/use-checkout-status-query.ts | 15 +- .../src/screens/agent/agent-ready-screen.tsx | 5 +- .../app/src/stores/section-order-store.ts | 79 +++++++ .../server/src/client/daemon-client-v2.ts | 194 ++++++++++----- .../server/src/server/agent/agent-manager.ts | 4 + packages/server/src/server/session.ts | 3 +- ...eact-native-draggable-flatlist+4.0.3.patch | 18 ++ 19 files changed, 774 insertions(+), 178 deletions(-) create mode 100644 packages/app/src/components/draggable-list.native.tsx create mode 100644 packages/app/src/components/draggable-list.tsx create mode 100644 packages/app/src/components/draggable-list.types.ts create mode 100644 packages/app/src/components/draggable-list.web.tsx create mode 100644 packages/app/src/stores/section-order-store.ts create mode 100644 patches/react-native-draggable-flatlist+4.0.3.patch diff --git a/package-lock.json b/package-lock.json index 8821e145e..38d079b76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1836,6 +1836,59 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -20980,6 +21033,20 @@ "@babel/types": "^7.26.0" } }, + "node_modules/react-native-draggable-flatlist": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz", + "integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.17.12" + }, + "peerDependencies": { + "react-native": ">=0.64.0", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=2.8.0" + } + }, "node_modules/react-native-edge-to-edge": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.7.0.tgz", @@ -26173,6 +26240,9 @@ "version": "1.0.0", "dependencies": { "@boudra/expo-two-way-audio": "^0.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", "@gorhom/bottom-sheet": "^5.2.6", @@ -26223,6 +26293,7 @@ "react-dom": "19.1.0", "react-native": "^0.81.5", "react-native-css": "^3.0.1", + "react-native-draggable-flatlist": "^4.0.3", "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "^1.19.2", diff --git a/packages/app/package.json b/packages/app/package.json index 45fb746e2..21da06ca1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -20,6 +20,9 @@ }, "dependencies": { "@boudra/expo-two-way-audio": "^0.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", "@gorhom/bottom-sheet": "^5.2.6", @@ -70,6 +73,7 @@ "react-dom": "19.1.0", "react-native": "^0.81.5", "react-native-css": "^3.0.1", + "react-native-draggable-flatlist": "^4.0.3", "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "^1.19.2", diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 0af043711..4dcf50373 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -159,7 +159,7 @@ export function AgentList({ continue; } - const queryKey = checkoutStatusQueryKey(agent.serverId, agent.id); + const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd); const queryState = queryClient.getQueryState(queryKey); const isFetching = queryState?.fetchStatus === "fetching"; const isFresh = @@ -193,6 +193,7 @@ export function AgentList({ const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: agent.serverId, agentId: agent.id, + cwd: agent.cwd, }); const checkout = checkoutQuery.data ?? null; const projectPath = deriveProjectPath(agent.cwd, checkout); diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx new file mode 100644 index 000000000..d4046e6a6 --- /dev/null +++ b/packages/app/src/components/draggable-list.native.tsx @@ -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({ + data, + keyExtractor, + renderItem, + onDragEnd, + style, + contentContainerStyle, + ListFooterComponent, + ListHeaderComponent, + ListEmptyComponent, + showsVerticalScrollIndicator = true, + refreshing, + onRefresh, + simultaneousGestureRef, + waitFor, +}: DraggableListProps) { + 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) => { + const index = getIndex() ?? 0; + const info: DraggableRenderItemInfo = { + item, + index, + drag, + isActive, + }; + return renderItem(info); + }, + [renderItem] + ); + + const handleDragEnd = useCallback( + ({ data: newData }: { data: T[] }) => { + onDragEnd(newData); + }, + [onDragEnd] + ); + + return ( + + ) : undefined + } + /> + ); +} diff --git a/packages/app/src/components/draggable-list.tsx b/packages/app/src/components/draggable-list.tsx new file mode 100644 index 000000000..359cd61f6 --- /dev/null +++ b/packages/app/src/components/draggable-list.tsx @@ -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"; diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts new file mode 100644 index 000000000..a7f9ef6cb --- /dev/null +++ b/packages/app/src/components/draggable-list.types.ts @@ -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 { + item: T; + index: number; + drag: () => void; + isActive: boolean; +} + +export interface DraggableListProps { + data: T[]; + keyExtractor: (item: T, index: number) => string; + renderItem: (info: DraggableRenderItemInfo) => ReactElement; + onDragEnd: (data: T[]) => void; + style?: StyleProp; + contentContainerStyle?: StyleProp; + 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; + /** Gesture ref(s) that the list should wait for before handling scroll */ + waitFor?: MutableRefObject | MutableRefObject[]; +} diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx new file mode 100644 index 000000000..5afb80315 --- /dev/null +++ b/packages/app/src/components/draggable-list.web.tsx @@ -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 { + id: string; + item: T; + index: number; + renderItem: (info: DraggableRenderItemInfo) => ReactElement; + activeId: string | null; +} + +function SortableItem({ + id, + item, + index, + renderItem, + activeId, +}: SortableItemProps) { + 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 = { + item, + index, + drag, + isActive: activeId === id, + }; + + return ( +
+ {renderItem(info)} +
+ ); +} + +export function DraggableList({ + data, + keyExtractor, + renderItem, + onDragEnd, + style, + contentContainerStyle, + ListFooterComponent, + ListHeaderComponent, + ListEmptyComponent, + showsVerticalScrollIndicator = true, + // simultaneousGestureRef is native-only, ignored on web +}: DraggableListProps) { + const [activeId, setActiveId] = useState(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 ( + + {ListHeaderComponent} + {items.length === 0 && ListEmptyComponent} + + + {items.map((item, index) => { + const id = keyExtractor(item, index); + return ( + + ); + })} + + + {ListFooterComponent} + + ); +} diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 0f20821f6..c8e1e7079 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -24,9 +24,10 @@ import { FileExplorerPane } from "./file-explorer-pane"; interface ExplorerSidebarProps { serverId: string; agentId: string; + cwd: string; } -export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { +export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = @@ -182,6 +183,7 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { onClose={handleClose} serverId={serverId} agentId={agentId} + cwd={cwd} fileViewMode={explorerViewMode} onFileViewModeChange={setExplorerViewMode} isMobile={isMobile} @@ -215,6 +217,7 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { onClose={handleClose} serverId={serverId} agentId={agentId} + cwd={cwd} fileViewMode={explorerViewMode} onFileViewModeChange={setExplorerViewMode} isMobile={false} @@ -229,6 +232,7 @@ interface SidebarContentProps { onClose: () => void; serverId: string; agentId: string; + cwd: string; fileViewMode: ViewMode; onFileViewModeChange: (mode: ViewMode) => void; isMobile: boolean; @@ -240,6 +244,7 @@ function SidebarContent({ onClose, serverId, agentId, + cwd, fileViewMode, onFileViewModeChange, isMobile, @@ -293,7 +298,7 @@ function SidebarContent({ {/* Content based on active tab */} {activeTab === "changes" && ( - + )} {activeTab === "files" && ( diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 69a67ee92..b07fe3348 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -352,9 +352,10 @@ const DiffFileSection = memo(function DiffFileSection({ interface GitDiffPaneProps { serverId: string; agentId: string; + cwd: string; } -export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { +export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const { theme } = useUnistyles(); const router = useRouter(); const queryClient = useQueryClient(); @@ -366,7 +367,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const [actionStatus, setActionStatus] = useState(null); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = - useCheckoutStatusQuery({ serverId, agentId }); + useCheckoutStatusQuery({ serverId, agentId, cwd }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 4d7a0f554..b11b08bfc 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -3,50 +3,52 @@ import { Text, Pressable, Modal, - RefreshControl, - SectionList, - type ViewToken, - type SectionListRenderItem, } from "react-native"; import { useCallback, useMemo, - useRef, useState, + useEffect, type ReactElement, + type MutableRefObject, } from "react"; import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { type GestureType } from "react-native-gesture-handler"; import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; import { ChevronDown, ChevronRight, Plus } from "lucide-react-native"; +import { + DraggableList, + type DraggableRenderItemInfo, +} from "./draggable-list"; import { formatTimeAgo } from "@/utils/time"; import { groupAgents, parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl, - type ProjectGroup, } from "@/utils/agent-grouping"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { - CHECKOUT_STATUS_STALE_TIME, type CheckoutStatusPayload, checkoutStatusQueryKey, useCheckoutStatusCacheOnly, + CHECKOUT_STATUS_STALE_TIME, } from "@/hooks/use-checkout-status-query"; import { buildAgentNavigationKey, startNavigationTiming, } from "@/utils/navigation-timing"; - -type SectionType = - | { type: "project"; data: ProjectGroup }; +import { + useSectionOrderStore, + sortProjectsByStoredOrder, +} from "@/stores/section-order-store"; interface SectionData { key: string; + projectKey: string; title: string; - type: "project"; - data: AggregatedAgent[]; + agents: AggregatedAgent[]; /** For project sections, the first agent's serverId (to lookup checkout status) */ firstAgentServerId?: string; /** For project sections, the first agent's id (to lookup checkout status) */ @@ -62,6 +64,8 @@ interface GroupedAgentListProps { selectedAgentId?: string; onAgentSelect?: () => void; listFooterComponent?: ReactElement | null; + /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ + parentGestureRef?: MutableRefObject; } interface SectionHeaderProps { @@ -69,6 +73,8 @@ interface SectionHeaderProps { isCollapsed: boolean; onToggle: () => void; onCreateAgent: (workingDir: string) => void; + onDrag: () => void; + isDragging: boolean; } function SectionHeader({ @@ -76,6 +82,8 @@ function SectionHeader({ isCollapsed, onToggle, onCreateAgent, + onDrag, + isDragging, }: SectionHeaderProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); @@ -84,12 +92,13 @@ function SectionHeader({ const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: section.firstAgentServerId ?? "", agentId: section.firstAgentId ?? "", + cwd: section.workingDir ?? "", }); const checkout = checkoutQuery.data ?? null; // Derive display title: prefer repo name from remote URL, fallback to path-based name let displayTitle = section.title; - if (section.type === "project" && checkout?.isGit && checkout.remoteUrl) { + if (checkout?.isGit && checkout.remoteUrl) { const isGitHubRemote = checkout.remoteUrl.includes("github.com") || checkout.remoteUrl.includes("git@github.com:"); @@ -115,12 +124,16 @@ function SectionHeader({ return ( [ styles.sectionHeader, isHovered && styles.sectionHeaderHovered, + pressed && styles.sectionHeaderPressed, !isCollapsed && styles.sectionHeaderExpanded, + isDragging && styles.sectionHeaderDragging, ]} onPress={onToggle} + onLongPress={onDrag} + delayLongPress={200} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} > @@ -132,10 +145,7 @@ function SectionHeader({ {section.workingDir && ( setIsHovered(true)} onHoverOut={() => setIsHovered(true)} @@ -165,6 +175,7 @@ export function GroupedAgentList({ selectedAgentId, onAgentSelect, listFooterComponent, + parentGestureRef, }: GroupedAgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); @@ -249,12 +260,42 @@ export function GroupedAgentList({ [] ); + // Prefetch checkout status for all agents in the sidebar. + // The sidebar shows a limited number of agents, so we fetch all of them upfront + // to ensure project grouping (by remote URL) is stable from the start. + useEffect(() => { + for (const agent of agents) { + const session = useSessionStore.getState().sessions[agent.serverId]; + const client = session?.client ?? null; + const isConnected = session?.connection.isConnected ?? false; + if (!client || !isConnected) { + continue; + } + + const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd); + const queryState = queryClient.getQueryState(queryKey); + const isFetching = queryState?.fetchStatus === "fetching"; + const isFresh = + typeof queryState?.dataUpdatedAt === "number" && + Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME; + if (isFetching || isFresh) { + continue; + } + + void queryClient.prefetchQuery({ + queryKey, + queryFn: async () => await client.getCheckoutStatus(agent.id), + staleTime: CHECKOUT_STATUS_STALE_TIME, + }); + } + }, [agents, queryClient]); + // Subscribe to checkout status cache entries so project grouping can react // to remote URL updates (e.g. git worktrees in different directories). const checkoutCacheQueries = useQueries({ queries: agents.map( (agent): UseQueryOptions => ({ - queryKey: checkoutStatusQueryKey(agent.serverId, agent.id), + queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd), enabled: false, staleTime: CHECKOUT_STATUS_STALE_TIME, queryFn: async (): Promise => { @@ -278,6 +319,10 @@ export function GroupedAgentList({ return result; }, [agents, checkoutCacheQueries]); + // Section order from store + const projectOrder = useSectionOrderStore((state) => state.projectOrder); + const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); + // Group agents const { activeGroups } = useMemo( () => @@ -288,19 +333,24 @@ export function GroupedAgentList({ [agents, remoteUrlByAgentKey] ); - // Build sections for SectionList + // Sort groups by persisted order + const sortedGroups = useMemo( + () => sortProjectsByStoredOrder(activeGroups, projectOrder), + [activeGroups, projectOrder] + ); + + // Build sections for DraggableFlatList const sections: SectionData[] = useMemo(() => { const result: SectionData[] = []; - for (const group of activeGroups) { + for (const group of sortedGroups) { const sectionKey = `project:${group.projectKey}`; - const isCollapsed = collapsedSections.has(sectionKey); const firstAgent = group.agents[0]; result.push({ key: sectionKey, + projectKey: group.projectKey, title: group.projectName, - type: "project", - data: isCollapsed ? [] : group.agents, + agents: group.agents, firstAgentServerId: firstAgent?.serverId, firstAgentId: firstAgent?.id, workingDir: firstAgent?.cwd, @@ -308,45 +358,26 @@ export function GroupedAgentList({ } return result; - }, [activeGroups, collapsedSections]); + }, [sortedGroups]); - const viewabilityConfig = useMemo( - () => ({ itemVisiblePercentThreshold: 30 }), - [] - ); + // Sync section order when new projects appear + useEffect(() => { + const currentKeys = sections.map((s) => s.projectKey); + const storedKeys = new Set(projectOrder); + const newKeys = currentKeys.filter((key) => !storedKeys.has(key)); - const onViewableItemsChanged = useRef( - ({ viewableItems }: { viewableItems: Array }) => { - for (const token of viewableItems) { - const agent = token.item as AggregatedAgent | undefined; - if (!agent) { - continue; - } - - const session = useSessionStore.getState().sessions[agent.serverId]; - const client = session?.client ?? null; - const isConnected = session?.connection.isConnected ?? false; - if (!client || !isConnected) { - continue; - } - - const queryKey = checkoutStatusQueryKey(agent.serverId, agent.id); - const queryState = queryClient.getQueryState(queryKey); - const isFetching = queryState?.fetchStatus === "fetching"; - const isFresh = - typeof queryState?.dataUpdatedAt === "number" && - Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME; - if (isFetching || isFresh) { - continue; - } - - void queryClient.prefetchQuery({ - queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.id), - staleTime: CHECKOUT_STATUS_STALE_TIME, - }); - } + if (newKeys.length > 0) { + // Add new projects at the end of the stored order + setProjectOrder([...projectOrder, ...newKeys]); } + }, [sections, projectOrder, setProjectOrder]); + + const handleDragEnd = useCallback( + (newData: SectionData[]) => { + const newOrder = newData.map((section) => section.projectKey); + setProjectOrder(newOrder); + }, + [setProjectOrder] ); const AgentListRow = useCallback( @@ -364,6 +395,7 @@ export function GroupedAgentList({ const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: agent.serverId, agentId: agent.id, + cwd: agent.cwd, }); const checkout = checkoutQuery.data ?? null; const activeBranchLabel = checkout?.isGit @@ -418,60 +450,49 @@ export function GroupedAgentList({ ] ); - const renderItem: SectionListRenderItem = - useCallback(({ item: agent }) => , [AgentListRow]); - - const renderSectionHeader = useCallback( - ({ section }: { section: SectionData }) => { + const renderSection = useCallback( + ({ item: section, drag, isActive }: DraggableRenderItemInfo) => { const isCollapsed = collapsedSections.has(section.key); return ( - toggleSection(section.key)} - onCreateAgent={handleCreateAgentInProject} - /> + + toggleSection(section.key)} + onCreateAgent={handleCreateAgentInProject} + onDrag={drag} + isDragging={isActive} + /> + {!isCollapsed && + section.agents.map((agent) => ( + + ))} + ); }, - [collapsedSections, toggleSection, handleCreateAgentInProject] + [AgentListRow, collapsedSections, handleCreateAgentInProject, toggleSection] ); const keyExtractor = useCallback( - (agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`, + (section: SectionData) => section.key, [] ); return ( <> - - ) : undefined - } + refreshing={isRefreshing} + onRefresh={onRefresh} + simultaneousGestureRef={parentGestureRef} /> ({ list: { flex: 1, - minHeight: 0, }, listContent: { paddingHorizontal: theme.spacing[4], @@ -548,9 +568,18 @@ const styles = StyleSheet.create((theme) => ({ sectionHeaderHovered: { backgroundColor: theme.colors.surface1, }, + sectionHeaderPressed: { + backgroundColor: theme.colors.surface2, + }, sectionHeaderExpanded: { marginBottom: theme.spacing[1], }, + sectionHeaderDragging: { + backgroundColor: theme.colors.surface2, + }, + sectionDragging: { + opacity: 0.9, + }, sectionHeaderLeft: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 350966e92..81716de2b 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -44,6 +44,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { animateToOpen, animateToClose, isGesturing, + closeGestureRef, } = useSidebarAnimation(); const trafficLightPadding = useTrafficLightPadding(); const dragHandlers = useTauriDragHandlers(); @@ -124,11 +125,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]); // Close gesture (swipe left to close when sidebar is open) + // Only activates on leftward swipe, fails on rightward or vertical movement + // This mirrors the explorer-sidebar pattern for the right sidebar const closeGesture = Gesture.Pan() + .withRef(closeGestureRef) .enabled(isOpen) - // Only activate after 15px horizontal movement - .activeOffsetX([-15, 15]) - // Fail if 10px vertical movement happens first (allow vertical scroll) + // Only activate on leftward swipe (negative X) + .activeOffsetX(-15) + // Fail on rightward movement (allow internal list scrolling) + .failOffsetX(10) + // Fail if vertical movement happens first (allow vertical scroll) .failOffsetY([-10, 10]) .onStart(() => { isGesturing.value = true; @@ -214,6 +220,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { onRefresh={handleRefresh} selectedAgentId={selectedAgentId} onAgentSelect={handleAgentSelectMobile} + parentGestureRef={closeGestureRef} /> {/* Footer */} diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 2eb42e2dc..59203e697 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -6,6 +6,7 @@ import { Easing, type SharedValue, } from "react-native-reanimated"; +import { type GestureType } from "react-native-gesture-handler"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; @@ -19,6 +20,7 @@ interface SidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + closeGestureRef: React.MutableRefObject; } const SidebarAnimationContext = createContext(null); @@ -37,6 +39,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const translateX = useSharedValue(isOpen ? 0 : -windowWidth); const backdropOpacity = useSharedValue(isOpen ? 1 : 0); const isGesturing = useSharedValue(false); + const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); @@ -108,6 +111,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + closeGestureRef, }} > {children} diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index f919fb9cc..e5be10e40 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -11,13 +11,14 @@ import { export const CHECKOUT_STATUS_STALE_TIME = 15_000; -export function checkoutStatusQueryKey(serverId: string, agentId: string) { - return ["checkoutStatus", serverId, agentId] as const; +export function checkoutStatusQueryKey(serverId: string, cwd: string) { + return ["checkoutStatus", serverId, cwd] as const; } interface UseCheckoutStatusQueryOptions { serverId: string; agentId: string; + cwd: string; } export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; @@ -29,7 +30,7 @@ function fetchCheckoutStatus( return client.getCheckoutStatus(agentId); } -export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQueryOptions) { +export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); @@ -44,14 +45,14 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const query = useQuery({ - queryKey: checkoutStatusQueryKey(serverId, agentId), + queryKey: checkoutStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } return await fetchCheckoutStatus(client, agentId); }, - enabled: !!client && isConnected && !!agentId, + enabled: !!client && isConnected && !!agentId && !!cwd, staleTime: CHECKOUT_STATUS_STALE_TIME, refetchInterval: 10_000, refetchIntervalInBackground: true, @@ -86,13 +87,13 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ * initiating a fetch. Useful for list rows where a parent component prefetches * only the visible agents. */ -export function useCheckoutStatusCacheOnly({ serverId, agentId }: UseCheckoutStatusQueryOptions) { +export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); return useQuery({ - queryKey: checkoutStatusQueryKey(serverId, agentId), + queryKey: checkoutStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 58b8bc923..9054f5b10 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -415,6 +415,7 @@ function AgentScreenContent({ const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, agentId: resolvedAgentId ?? "", + cwd: agent?.cwd ?? "", }); const checkout = checkoutStatusQuery.status; @@ -795,7 +796,7 @@ function AgentScreenContent({ {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} {!isMobile && isExplorerOpen && resolvedAgentId && ( - + )} ); @@ -812,7 +813,7 @@ function AgentScreenContent({ {/* Mobile Explorer Sidebar Overlay */} {isMobile && resolvedAgentId && ( - + )} ); diff --git a/packages/app/src/stores/section-order-store.ts b/packages/app/src/stores/section-order-store.ts new file mode 100644 index 000000000..58e744718 --- /dev/null +++ b/packages/app/src/stores/section-order-store.ts @@ -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()( + 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( + 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; + }); +} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 9728bd226..eace32e65 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -213,6 +213,16 @@ const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500; const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000; const DEFAULT_MESSAGE_QUEUE_LIMIT = 0; +/** Default timeout for waiting for connection before sending queued messages */ +const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000; + +interface PendingSend { + message: SessionInboundMessage; + resolve: () => void; + reject: (error: Error) => void; + timeoutHandle: ReturnType; +} + export class DaemonClientV2 { private transport: DaemonTransport | null = null; private transportCleanup: Array<() => void> = []; @@ -238,6 +248,7 @@ export class DaemonClientV2 { private messageQueueLimit: number | null; private agentIndex: Map = new Map(); private logger: Logger; + private pendingSendQueue: PendingSend[] = []; constructor(private config: DaemonClientV2Config) { this.messageQueueLimit = @@ -308,6 +319,7 @@ export class DaemonClientV2 { this.lastErrorValue = null; this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }); + this.flushPendingSendQueue(); this.resolveConnect(); }), transport.onClose((event) => { @@ -493,12 +505,17 @@ export class DaemonClientV2 { // Core Send Helpers // ============================================================================ + /** + * Send a session message. For fire-and-forget messages (heartbeats, etc.), + * failures are suppressed if `suppressSendErrors` is configured. + * For RPC methods that wait for responses, use `sendSessionMessageOrThrow` instead. + */ private sendSessionMessage(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { if (this.config.suppressSendErrors) { return; } - throw new Error("Transport not connected"); + throw new Error(`Transport not connected (status: ${this.connectionState.status})`); } const payload = SessionInboundMessageSchema.parse(message); try { @@ -511,6 +528,78 @@ export class DaemonClientV2 { } } + /** + * Send a session message for RPC methods that create waiters. + * If the connection is still being established ("connecting"), the message + * is queued and will be sent once connected (or rejected after timeout). + * This prevents waiters from hanging forever when called during connection. + */ + private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { + const status = this.connectionState.status; + + // If connected, send immediately + if (this.transport && status === "connected") { + const payload = SessionInboundMessageSchema.parse(message); + this.transport.send(JSON.stringify({ type: "session", message: payload })); + return Promise.resolve(); + } + + // If connecting, queue the message to be sent once connected + if (status === "connecting") { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + // Remove from queue + const idx = this.pendingSendQueue.findIndex((p) => p.resolve === resolve); + if (idx !== -1) { + this.pendingSendQueue.splice(idx, 1); + } + reject(new Error(`Timed out waiting for connection to send message`)); + }, DEFAULT_SEND_QUEUE_TIMEOUT_MS); + + this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }); + }); + } + + // Not connected and not connecting - fail immediately + return Promise.reject(new Error(`Transport not connected (status: ${status})`)); + } + + /** + * Flush pending send queue - called when connection is established. + */ + private flushPendingSendQueue(): void { + const queue = this.pendingSendQueue; + this.pendingSendQueue = []; + + for (const pending of queue) { + clearTimeout(pending.timeoutHandle); + try { + if (this.transport && this.connectionState.status === "connected") { + const payload = SessionInboundMessageSchema.parse(pending.message); + this.transport.send(JSON.stringify({ type: "session", message: payload })); + pending.resolve(); + } else { + pending.reject(new Error("Connection lost before message could be sent")); + } + } catch (error) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + } + } + + /** + * Reject all pending sends - called when connection fails or is closed. + */ + private rejectPendingSendQueue(error: Error): void { + const queue = this.pendingSendQueue; + this.pendingSendQueue = []; + + for (const pending of queue) { + clearTimeout(pending.timeoutHandle); + pending.reject(error); + } + } + private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { throw new Error("Transport not connected"); @@ -589,7 +678,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -612,7 +701,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -639,7 +728,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -682,7 +771,7 @@ export class DaemonClientV2 { { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); const status = await statusPromise; if (status.status === "agent_create_failed") { throw new Error(status.error); @@ -723,7 +812,7 @@ export class DaemonClientV2 { { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -747,7 +836,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); await response; } @@ -790,7 +879,7 @@ export class DaemonClientV2 { { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); const status = await statusPromise; return this.waitForAgentState( @@ -824,7 +913,7 @@ export class DaemonClientV2 { 15000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -851,7 +940,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); const payload = await response; if (payload.error) { throw new Error(payload.error); @@ -926,7 +1015,7 @@ export class DaemonClientV2 { { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1046,10 +1135,6 @@ export class DaemonClientV2 { if (!requestId) { const existing = this.checkoutStatusInFlight.get(agentId); if (existing) { - this.logger.debug( - { agentId, inFlightCount: this.checkoutStatusInFlight.size }, - "getCheckoutStatus: returning existing in-flight request" - ); return existing; } } @@ -1061,11 +1146,6 @@ export class DaemonClientV2 { requestId: resolvedRequestId, }); - this.logger.debug( - { agentId, requestId: resolvedRequestId, waiterCount: this.waiters.size }, - "getCheckoutStatus: creating new request" - ); - const responsePromise = (async () => { const response = this.waitFor( (msg) => { @@ -1080,30 +1160,17 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; })(); if (!requestId) { this.checkoutStatusInFlight.set(agentId, responsePromise); - responsePromise - .then(() => { - this.logger.debug( - { agentId, requestId: resolvedRequestId }, - "getCheckoutStatus: request completed successfully" - ); - }) - .catch((err) => { - this.logger.debug( - { agentId, requestId: resolvedRequestId, error: err?.message }, - "getCheckoutStatus: request failed" - ); - }) - .finally(() => { - if (this.checkoutStatusInFlight.get(agentId) === responsePromise) { - this.checkoutStatusInFlight.delete(agentId); - } - }); + responsePromise.finally(() => { + if (this.checkoutStatusInFlight.get(agentId) === responsePromise) { + this.checkoutStatusInFlight.delete(agentId); + } + }); } return responsePromise; @@ -1134,7 +1201,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1164,7 +1231,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1195,7 +1262,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1225,7 +1292,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1249,7 +1316,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1280,7 +1347,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1307,7 +1374,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1335,7 +1402,7 @@ export class DaemonClientV2 { 60000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1364,7 +1431,7 @@ export class DaemonClientV2 { 20000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1391,7 +1458,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1418,7 +1485,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1464,7 +1531,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1499,7 +1566,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1528,7 +1595,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1560,7 +1627,7 @@ export class DaemonClientV2 { 30000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1587,7 +1654,7 @@ export class DaemonClientV2 { 30000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1618,7 +1685,7 @@ export class DaemonClientV2 { 30000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1667,7 +1734,7 @@ export class DaemonClientV2 { timeout, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return resolved; } @@ -1907,7 +1974,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1936,7 +2003,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -1963,7 +2030,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -2008,7 +2075,7 @@ export class DaemonClientV2 { 10000, { skipQueue: true } ); - this.sendSessionMessage(message); + await this.sendSessionMessageOrThrow(message); return response; } @@ -2121,9 +2188,10 @@ export class DaemonClientV2 { this.lastErrorValue = reason.trim(); } - // Clear all pending waiters since the connection was lost and responses - // from the previous connection will never arrive. + // Clear all pending waiters and queued sends since the connection was lost + // and responses from the previous connection will never arrive. this.clearWaiters(new Error(reason ?? "Connection lost")); + this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); this.updateConnectionState({ status: "disconnected", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d63612066..bf4745fd8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -848,6 +848,10 @@ export class AgentManager { if (!this.registry) { return; } + // Don't persist internal agents - they're ephemeral system tasks + if (agent.internal) { + return; + } await this.registry.applySnapshot(agent, options); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fecc765c6..d7b9f3e7c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3930,10 +3930,11 @@ export class Session { ); // Add persisted agents that have not been lazily initialized yet + // (excluding internal agents which are for ephemeral system tasks) const registryRecords = await this.agentStorage.list(); const liveIds = new Set(agentSnapshots.map((a) => a.id)); const persistedAgents = registryRecords - .filter((record) => !liveIds.has(record.id)) + .filter((record) => !liveIds.has(record.id) && !record.internal) .map((record) => this.buildStoredAgentPayload(record)); const agents = [...liveAgents, ...persistedAgents]; diff --git a/patches/react-native-draggable-flatlist+4.0.3.patch b/patches/react-native-draggable-flatlist+4.0.3.patch new file mode 100644 index 000000000..2adf9a894 --- /dev/null +++ b/patches/react-native-draggable-flatlist+4.0.3.patch @@ -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(props: DraggableFlatListProps) { + panGesture.activeOffsetY(activeOffset); + } + } ++ if (props.simultaneousHandlers) { ++ panGesture.simultaneousWithExternalGesture( ++ ...(Array.isArray(props.simultaneousHandlers) ++ ? props.simultaneousHandlers ++ : [props.simultaneousHandlers]) ++ ); ++ } + + const onScroll = useStableCallback((scrollOffset: number) => { + props.onScrollOffsetChange?.(scrollOffset);