Merge branch 'enhance-agent-fetch-sidebar'
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}));
|
||||
+31
-65
@@ -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,
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user