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