feat: add project-grouped agent fetch with checkout-lite placement

This commit is contained in:
Mohamed Boudra
2026-02-09 15:44:58 +07:00
parent 8403ece4ed
commit 1810ace233
13 changed files with 1063 additions and 117 deletions
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.45, 0.95],
});
return <Animated.View style={[style, { opacity }]} />;
}
export function SidebarAgentListSkeleton() {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, {
toValue: 1,
duration: 850,
useNativeDriver: true,
}),
Animated.timing(pulse, {
toValue: 0,
duration: 850,
useNativeDriver: true,
}),
])
);
animation.start();
return () => animation.stop();
}, [pulse]);
return (
<View style={styles.container}>
{Array.from({ length: 4 }).map((_, sectionIdx) => (
<View key={`skeleton-section-${sectionIdx}`} style={styles.section}>
<View style={styles.sectionHeader}>
<SkeletonPulse pulse={pulse} style={styles.projectIcon} />
<SkeletonPulse pulse={pulse} style={styles.sectionTitle} />
</View>
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<View style={styles.rowText}>
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
</View>
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
</View>
))}
</View>
</View>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
gap: theme.spacing[4],
},
section: {
gap: theme.spacing[2],
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
projectIcon: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
shadowColor: theme.colors.foreground,
shadowOpacity: 0.18,
shadowRadius: 8,
shadowOffset: { width: 0, height: 0 },
},
sectionTitle: {
width: "52%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
shadowColor: theme.colors.foreground,
shadowOpacity: 0.12,
shadowRadius: 8,
shadowOffset: { width: 0, height: 0 },
},
rows: {
gap: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
rowDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
rowText: {
flex: 1,
},
rowTitle: {
width: "70%",
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface3,
},
rowBadge: {
width: 46,
height: 18,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
}));
@@ -19,7 +19,6 @@ import {
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { useQueryClient } from "@tanstack/react-query";
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
import {
DraggableList,
@@ -28,11 +27,6 @@ import {
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
useCheckoutStatusCacheOnly,
} from "@/hooks/use-checkout-status-query";
import {
buildAgentNavigationKey,
startNavigationTiming,
@@ -41,7 +35,10 @@ import {
useSectionOrderStore,
} from "@/stores/section-order-store";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useSidebarAgentSections, type SidebarSectionData } from "@/hooks/use-sidebar-agent-sections";
import {
type SidebarCheckoutLite,
type SidebarSectionData,
} from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { getIsTauri } from "@/constants/layout";
@@ -49,8 +46,9 @@ import { AgentStatusDot } from "@/components/agent-status-dot";
type SectionData = SidebarSectionData;
interface GroupedAgentListProps {
agents: AggregatedAgent[];
interface SidebarAgentListProps {
sections: SidebarSectionData[];
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
@@ -62,6 +60,7 @@ interface GroupedAgentListProps {
interface SectionHeaderProps {
section: SectionData;
checkout: SidebarCheckoutLite | null;
isCollapsed: boolean;
onToggle: () => void;
onCreateAgent: (workingDir: string) => void;
@@ -71,6 +70,7 @@ interface SectionHeaderProps {
function SectionHeader({
section,
checkout,
isCollapsed,
onToggle,
onCreateAgent,
@@ -80,13 +80,6 @@ function SectionHeader({
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
// For project sections, try to get repo name from checkout status
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: section.firstAgentServerId ?? "",
cwd: section.workingDir ?? "",
});
const checkout = checkoutQuery.data ?? null;
// Get project icon
const iconQuery = useProjectIconQuery({
serverId: section.firstAgentServerId ?? "",
@@ -185,8 +178,9 @@ function SectionHeader({
);
}
interface GroupedAgentRowProps {
interface SidebarAgentRowProps {
agent: AggregatedAgent;
checkout: SidebarCheckoutLite | null;
isSelected: boolean;
shortcutNumber: number | null;
onPress: () => void;
@@ -194,14 +188,15 @@ interface GroupedAgentRowProps {
onArchive: (e: { stopPropagation: () => void }) => void;
}
function GroupedAgentRow({
function SidebarAgentRow({
agent,
checkout,
isSelected,
shortcutNumber,
onPress,
onLongPress,
onArchive,
}: GroupedAgentRowProps) {
}: SidebarAgentRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const [isArchiveHovered, setIsArchiveHovered] = useState(false);
@@ -233,17 +228,10 @@ function GroupedAgentRow({
setIsArchiveConfirmVisible(false);
}, 50);
}, [clearHoverOutTimeout]);
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null;
@@ -355,18 +343,18 @@ function GroupedAgentRow({
);
}
export function GroupedAgentList({
agents,
export function SidebarAgentList({
sections,
checkoutByAgentKey,
isRefreshing = false,
onRefresh,
selectedAgentId,
onAgentSelect,
listFooterComponent,
parentGestureRef,
}: GroupedAgentListProps) {
}: SidebarAgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
const queryClient = useQueryClient();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
@@ -448,43 +436,9 @@ export function GroupedAgentList({
[onAgentSelect]
);
// 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.cwd),
staleTime: CHECKOUT_STATUS_STALE_TIME,
}).catch((error) => {
console.warn("[checkout_status] prefetch failed", error);
});
}
}, [agents, queryClient]);
// Section order from store
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const sections: SectionData[] = useSidebarAgentSections(agents);
const handleDragEnd = useCallback(
(newData: SectionData[]) => {
const newOrder = newData.map((section) => section.projectKey);
@@ -510,11 +464,19 @@ export function GroupedAgentList({
const renderSection = useCallback(
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
const firstAgent = section.agents[0];
const firstAgentKey = firstAgent
? `${firstAgent.serverId}:${firstAgent.id}`
: null;
const firstAgentCheckout = firstAgentKey
? (checkoutByAgentKey.get(firstAgentKey) ?? null)
: null;
return (
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
<SectionHeader
section={section}
checkout={firstAgentCheckout}
isCollapsed={isCollapsed}
onToggle={() => toggleProjectCollapsed(section.projectKey)}
onCreateAgent={handleCreateAgentInProject}
@@ -523,9 +485,12 @@ export function GroupedAgentList({
/>
{!isCollapsed &&
section.agents.map((agent) => (
<GroupedAgentRow
<SidebarAgentRow
key={`${agent.serverId}:${agent.id}`}
agent={agent}
checkout={
checkoutByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null
}
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
shortcutNumber={
showShortcutBadges
@@ -546,6 +511,7 @@ export function GroupedAgentList({
handleAgentPress,
handleArchiveAgent,
handleCreateAgentInProject,
checkoutByAgentKey,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
+36 -31
View File
@@ -12,17 +12,16 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { Plus, Settings, Users } from "lucide-react-native";
import { router } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { GroupedAgentList } from "./grouped-agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 50; // temporary for screenshots
interface SlidingSidebarProps {
selectedAgentId?: string;
@@ -40,7 +39,13 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
const {
sections,
checkoutByAgentKey,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({ isOpen });
const {
translateX,
backdropOpacity,
@@ -68,21 +73,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}
}, [isRevalidating, isManualRefresh]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => {
if (a.requiresAttention && !b.requiresAttention) return -1;
if (!a.requiresAttention && b.requiresAttention) return 1;
return 0;
});
}, [agents]);
// Pass all agents to grouping, limit is applied inside groupAgents per-project
const sidebarSections = useSidebarAgentSections(sortedAgents);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
const sidebarShortcutAgentKeys = useMemo(() => {
return deriveSidebarShortcutAgentKeys(sidebarSections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sidebarSections]);
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sections]);
useEffect(() => {
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
@@ -226,14 +221,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</View>
{/* Middle: scrollable agent list */}
<GroupedAgentList
agents={sortedAgents}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
@@ -301,12 +301,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</View>
{/* Middle: scrollable agent list */}
<GroupedAgentList
agents={sortedAgents}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>