feat: add project-grouped agent fetch with checkout-lite placement
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 { router, usePathname } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
DraggableList,
|
DraggableList,
|
||||||
@@ -28,11 +27,6 @@ import {
|
|||||||
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
|
||||||
checkoutStatusQueryKey,
|
|
||||||
useCheckoutStatusCacheOnly,
|
|
||||||
} from "@/hooks/use-checkout-status-query";
|
|
||||||
import {
|
import {
|
||||||
buildAgentNavigationKey,
|
buildAgentNavigationKey,
|
||||||
startNavigationTiming,
|
startNavigationTiming,
|
||||||
@@ -41,7 +35,10 @@ import {
|
|||||||
useSectionOrderStore,
|
useSectionOrderStore,
|
||||||
} from "@/stores/section-order-store";
|
} from "@/stores/section-order-store";
|
||||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
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 { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsTauri } from "@/constants/layout";
|
||||||
@@ -49,8 +46,9 @@ import { AgentStatusDot } from "@/components/agent-status-dot";
|
|||||||
|
|
||||||
type SectionData = SidebarSectionData;
|
type SectionData = SidebarSectionData;
|
||||||
|
|
||||||
interface GroupedAgentListProps {
|
interface SidebarAgentListProps {
|
||||||
agents: AggregatedAgent[];
|
sections: SidebarSectionData[];
|
||||||
|
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
@@ -62,6 +60,7 @@ interface GroupedAgentListProps {
|
|||||||
|
|
||||||
interface SectionHeaderProps {
|
interface SectionHeaderProps {
|
||||||
section: SectionData;
|
section: SectionData;
|
||||||
|
checkout: SidebarCheckoutLite | null;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onCreateAgent: (workingDir: string) => void;
|
onCreateAgent: (workingDir: string) => void;
|
||||||
@@ -71,6 +70,7 @@ interface SectionHeaderProps {
|
|||||||
|
|
||||||
function SectionHeader({
|
function SectionHeader({
|
||||||
section,
|
section,
|
||||||
|
checkout,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCreateAgent,
|
onCreateAgent,
|
||||||
@@ -80,13 +80,6 @@ function SectionHeader({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
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
|
// Get project icon
|
||||||
const iconQuery = useProjectIconQuery({
|
const iconQuery = useProjectIconQuery({
|
||||||
serverId: section.firstAgentServerId ?? "",
|
serverId: section.firstAgentServerId ?? "",
|
||||||
@@ -185,8 +178,9 @@ function SectionHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupedAgentRowProps {
|
interface SidebarAgentRowProps {
|
||||||
agent: AggregatedAgent;
|
agent: AggregatedAgent;
|
||||||
|
checkout: SidebarCheckoutLite | null;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -194,14 +188,15 @@ interface GroupedAgentRowProps {
|
|||||||
onArchive: (e: { stopPropagation: () => void }) => void;
|
onArchive: (e: { stopPropagation: () => void }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupedAgentRow({
|
function SidebarAgentRow({
|
||||||
agent,
|
agent,
|
||||||
|
checkout,
|
||||||
isSelected,
|
isSelected,
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onArchive,
|
onArchive,
|
||||||
}: GroupedAgentRowProps) {
|
}: SidebarAgentRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isArchiveHovered, setIsArchiveHovered] = useState(false);
|
const [isArchiveHovered, setIsArchiveHovered] = useState(false);
|
||||||
@@ -233,17 +228,10 @@ function GroupedAgentRow({
|
|||||||
setIsArchiveConfirmVisible(false);
|
setIsArchiveConfirmVisible(false);
|
||||||
}, 50);
|
}, 50);
|
||||||
}, [clearHoverOutTimeout]);
|
}, [clearHoverOutTimeout]);
|
||||||
|
|
||||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
|
||||||
serverId: agent.serverId,
|
|
||||||
cwd: agent.cwd,
|
|
||||||
});
|
|
||||||
const checkout = checkoutQuery.data ?? null;
|
|
||||||
const activeBranchLabel = checkout?.isGit
|
const activeBranchLabel = checkout?.isGit
|
||||||
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
|
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
|
||||||
? checkout.currentBranch
|
? checkout.currentBranch
|
||||||
: null) ??
|
: null) ??
|
||||||
checkout.baseRef ??
|
|
||||||
"git")
|
"git")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -355,18 +343,18 @@ function GroupedAgentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupedAgentList({
|
export function SidebarAgentList({
|
||||||
agents,
|
sections,
|
||||||
|
checkoutByAgentKey,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
onAgentSelect,
|
onAgentSelect,
|
||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: GroupedAgentListProps) {
|
}: SidebarAgentListProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
|
|
||||||
@@ -448,43 +436,9 @@ export function GroupedAgentList({
|
|||||||
[onAgentSelect]
|
[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
|
// Section order from store
|
||||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||||
|
|
||||||
const sections: SectionData[] = useSidebarAgentSections(agents);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(newData: SectionData[]) => {
|
(newData: SectionData[]) => {
|
||||||
const newOrder = newData.map((section) => section.projectKey);
|
const newOrder = newData.map((section) => section.projectKey);
|
||||||
@@ -510,11 +464,19 @@ export function GroupedAgentList({
|
|||||||
const renderSection = useCallback(
|
const renderSection = useCallback(
|
||||||
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||||
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
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 (
|
return (
|
||||||
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
|
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
section={section}
|
section={section}
|
||||||
|
checkout={firstAgentCheckout}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onToggle={() => toggleProjectCollapsed(section.projectKey)}
|
onToggle={() => toggleProjectCollapsed(section.projectKey)}
|
||||||
onCreateAgent={handleCreateAgentInProject}
|
onCreateAgent={handleCreateAgentInProject}
|
||||||
@@ -523,9 +485,12 @@ export function GroupedAgentList({
|
|||||||
/>
|
/>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
section.agents.map((agent) => (
|
section.agents.map((agent) => (
|
||||||
<GroupedAgentRow
|
<SidebarAgentRow
|
||||||
key={`${agent.serverId}:${agent.id}`}
|
key={`${agent.serverId}:${agent.id}`}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
|
checkout={
|
||||||
|
checkoutByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null
|
||||||
|
}
|
||||||
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
|
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
|
||||||
shortcutNumber={
|
shortcutNumber={
|
||||||
showShortcutBadges
|
showShortcutBadges
|
||||||
@@ -546,6 +511,7 @@ export function GroupedAgentList({
|
|||||||
handleAgentPress,
|
handleAgentPress,
|
||||||
handleArchiveAgent,
|
handleArchiveAgent,
|
||||||
handleCreateAgentInProject,
|
handleCreateAgentInProject,
|
||||||
|
checkoutByAgentKey,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
shortcutIndexByAgentKey,
|
shortcutIndexByAgentKey,
|
||||||
@@ -12,17 +12,16 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
|||||||
import { Plus, Settings, Users } from "lucide-react-native";
|
import { Plus, Settings, Users } from "lucide-react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { GroupedAgentList } from "./grouped-agent-list";
|
import { SidebarAgentList } from "./sidebar-agent-list";
|
||||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
|
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
|
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||||
const SIDEBAR_AGENT_LIMIT = 50; // temporary for screenshots
|
|
||||||
|
|
||||||
interface SlidingSidebarProps {
|
interface SlidingSidebarProps {
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
@@ -40,7 +39,13 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
// Derive isOpen from the unified panel state
|
// Derive isOpen from the unified panel state
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
|
|
||||||
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
|
const {
|
||||||
|
sections,
|
||||||
|
checkoutByAgentKey,
|
||||||
|
isInitialLoad,
|
||||||
|
isRevalidating,
|
||||||
|
refreshAll,
|
||||||
|
} = useSidebarAgentsGrouped({ isOpen });
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -68,21 +73,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
}
|
}
|
||||||
}, [isRevalidating, isManualRefresh]);
|
}, [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 collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||||
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
|
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
|
||||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||||
return deriveSidebarShortcutAgentKeys(sidebarSections, collapsedProjectKeys, 9);
|
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
||||||
}, [collapsedProjectKeys, sidebarSections]);
|
}, [collapsedProjectKeys, sections]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
||||||
@@ -226,14 +221,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Middle: scrollable agent list */}
|
{/* Middle: scrollable agent list */}
|
||||||
<GroupedAgentList
|
{isInitialLoad ? (
|
||||||
agents={sortedAgents}
|
<SidebarAgentListSkeleton />
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
) : (
|
||||||
onRefresh={handleRefresh}
|
<SidebarAgentList
|
||||||
selectedAgentId={selectedAgentId}
|
sections={sections}
|
||||||
onAgentSelect={handleAgentSelectMobile}
|
checkoutByAgentKey={checkoutByAgentKey}
|
||||||
parentGestureRef={closeGestureRef}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
/>
|
onRefresh={handleRefresh}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onAgentSelect={handleAgentSelectMobile}
|
||||||
|
parentGestureRef={closeGestureRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
@@ -301,12 +301,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Middle: scrollable agent list */}
|
{/* Middle: scrollable agent list */}
|
||||||
<GroupedAgentList
|
{isInitialLoad ? (
|
||||||
agents={sortedAgents}
|
<SidebarAgentListSkeleton />
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
) : (
|
||||||
onRefresh={handleRefresh}
|
<SidebarAgentList
|
||||||
selectedAgentId={selectedAgentId}
|
sections={sections}
|
||||||
/>
|
checkoutByAgentKey={checkoutByAgentKey}
|
||||||
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ function normalizeAgentSnapshot(
|
|||||||
attentionTimestamp,
|
attentionTimestamp,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
labels: snapshot.labels,
|
labels: snapshot.labels,
|
||||||
|
projectPlacement: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +558,10 @@ export function SessionProvider({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = normalizeAgentSnapshot(update.agent, serverId);
|
const agent = {
|
||||||
|
...normalizeAgentSnapshot(update.agent, serverId),
|
||||||
|
projectPlacement: update.project,
|
||||||
|
};
|
||||||
|
|
||||||
console.log("[Session] Agent update:", agent.id, agent.status);
|
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
|
import {
|
||||||
|
deriveProjectKey,
|
||||||
|
deriveProjectName,
|
||||||
|
} from "@/utils/agent-grouping";
|
||||||
|
import {
|
||||||
|
useSectionOrderStore,
|
||||||
|
sortProjectsByStoredOrder,
|
||||||
|
} from "@/stores/section-order-store";
|
||||||
|
import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages";
|
||||||
|
|
||||||
|
const SIDEBAR_GROUPS_STALE_TIME = 15_000;
|
||||||
|
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
|
||||||
|
const MAX_AGENTS_PER_PROJECT = 5;
|
||||||
|
|
||||||
|
type SidebarGroupsPayload = FetchAgentsGroupedByProjectResponseMessage["payload"];
|
||||||
|
export type SidebarCheckoutLite =
|
||||||
|
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
|
||||||
|
type MutableSidebarGroup = {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
agents: AggregatedAgent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SidebarSectionData {
|
||||||
|
key: string;
|
||||||
|
projectKey: string;
|
||||||
|
title: string;
|
||||||
|
agents: AggregatedAgent[];
|
||||||
|
firstAgentServerId?: string;
|
||||||
|
firstAgentId?: string;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarAgentsGroupedResult {
|
||||||
|
sections: SidebarSectionData[];
|
||||||
|
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isInitialLoad: boolean;
|
||||||
|
isRevalidating: boolean;
|
||||||
|
refreshAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAggregatedAgent(params: {
|
||||||
|
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
||||||
|
serverId: string;
|
||||||
|
serverLabel: string;
|
||||||
|
}): AggregatedAgent {
|
||||||
|
const source = params.source;
|
||||||
|
return {
|
||||||
|
id: source.id,
|
||||||
|
serverId: params.serverId,
|
||||||
|
serverLabel: params.serverLabel,
|
||||||
|
title: source.title ?? null,
|
||||||
|
status: source.status,
|
||||||
|
lastActivityAt: source.lastActivityAt,
|
||||||
|
cwd: source.cwd,
|
||||||
|
provider: source.provider,
|
||||||
|
requiresAttention: source.requiresAttention,
|
||||||
|
attentionReason: source.attentionReason,
|
||||||
|
attentionTimestamp: source.attentionTimestamp ?? null,
|
||||||
|
archivedAt: source.archivedAt ?? null,
|
||||||
|
labels: source.labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarAgentsGrouped(options?: {
|
||||||
|
isOpen?: boolean;
|
||||||
|
}): SidebarAgentsGroupedResult {
|
||||||
|
const { connectionStates } = useDaemonConnections();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isOpen = options?.isOpen ?? true;
|
||||||
|
|
||||||
|
const sessionClients = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const result: Record<
|
||||||
|
string,
|
||||||
|
NonNullable<typeof state.sessions[string]["client"]> | null
|
||||||
|
> = {};
|
||||||
|
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||||
|
result[serverId] = session.client ?? null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionAgents = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||||
|
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||||
|
result[serverId] = session.agents;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionConnections = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||||
|
result[serverId] = session.connection.isConnected;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const serverEntries = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(sessionClients).map((serverId) => ({
|
||||||
|
serverId,
|
||||||
|
client: sessionClients[serverId] ?? null,
|
||||||
|
isConnected: sessionConnections[serverId] ?? false,
|
||||||
|
})),
|
||||||
|
[sessionClients, sessionConnections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedQueries = useQueries({
|
||||||
|
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
|
||||||
|
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client not available");
|
||||||
|
}
|
||||||
|
return await client.fetchAgentsGroupedByProject({
|
||||||
|
filter: { labels: { ui: "true" } },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: Boolean(client) && isConnected,
|
||||||
|
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
||||||
|
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
|
||||||
|
refetchIntervalInBackground: isOpen,
|
||||||
|
refetchOnMount: "always" as const,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||||
|
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||||
|
|
||||||
|
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
|
||||||
|
const groupsByKey = new Map<string, MutableSidebarGroup>();
|
||||||
|
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
|
||||||
|
const seenAgentKeys = new Set<string>();
|
||||||
|
const groupedFetchReadyByServer = new Map<string, boolean>();
|
||||||
|
|
||||||
|
for (let idx = 0; idx < serverEntries.length; idx++) {
|
||||||
|
const { serverId } = serverEntries[idx] ?? {};
|
||||||
|
if (!serverId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
groupedFetchReadyByServer.set(serverId, groupedQueries[idx]?.isFetched ?? false);
|
||||||
|
const payload = groupedQueries[idx]?.data as SidebarGroupsPayload | undefined;
|
||||||
|
if (!payload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||||
|
const liveAgents = sessionAgents[serverId];
|
||||||
|
|
||||||
|
for (const group of payload.groups) {
|
||||||
|
const existing: MutableSidebarGroup =
|
||||||
|
groupsByKey.get(group.projectKey) ??
|
||||||
|
{
|
||||||
|
projectKey: group.projectKey,
|
||||||
|
projectName: group.projectName,
|
||||||
|
agents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of group.agents) {
|
||||||
|
const normalized = normalizeAgentSnapshot(entry.agent, serverId);
|
||||||
|
const live = liveAgents?.get(entry.agent.id);
|
||||||
|
const nextAgent = toAggregatedAgent({
|
||||||
|
source: live ?? normalized,
|
||||||
|
serverId,
|
||||||
|
serverLabel,
|
||||||
|
});
|
||||||
|
if (nextAgent.archivedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentKey = `${serverId}:${entry.agent.id}`;
|
||||||
|
seenAgentKeys.add(agentKey);
|
||||||
|
checkoutLookup.set(agentKey, live?.projectPlacement?.checkout ?? entry.checkout);
|
||||||
|
existing.agents.push(nextAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
groupsByKey.set(group.projectKey, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { serverId } of serverEntries) {
|
||||||
|
if (!groupedFetchReadyByServer.get(serverId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||||
|
const liveAgents = sessionAgents[serverId];
|
||||||
|
if (!liveAgents) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const live of liveAgents.values()) {
|
||||||
|
if (live.archivedAt || live.labels.ui !== "true") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const agentKey = `${serverId}:${live.id}`;
|
||||||
|
if (seenAgentKeys.has(agentKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const livePlacement = live.projectPlacement;
|
||||||
|
const projectKey = livePlacement?.projectKey ?? deriveProjectKey(live.cwd);
|
||||||
|
const existing: MutableSidebarGroup =
|
||||||
|
groupsByKey.get(projectKey) ??
|
||||||
|
{
|
||||||
|
projectKey,
|
||||||
|
projectName: livePlacement?.projectName ?? deriveProjectName(projectKey),
|
||||||
|
agents: [],
|
||||||
|
};
|
||||||
|
existing.agents.push(
|
||||||
|
toAggregatedAgent({
|
||||||
|
source: live,
|
||||||
|
serverId,
|
||||||
|
serverLabel,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (livePlacement) {
|
||||||
|
checkoutLookup.set(agentKey, livePlacement.checkout);
|
||||||
|
}
|
||||||
|
groupsByKey.set(projectKey, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedGroups = Array.from(groupsByKey.values())
|
||||||
|
.map((group) => {
|
||||||
|
const agents = [...group.agents].sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
agents: agents.slice(0, MAX_AGENTS_PER_PROJECT),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((group) => group.agents.length > 0)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftRecent = left.agents[0]?.lastActivityAt.getTime() ?? 0;
|
||||||
|
const rightRecent = right.agents[0]?.lastActivityAt.getTime() ?? 0;
|
||||||
|
return rightRecent - leftRecent;
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder);
|
||||||
|
const nextSections = orderedGroups.map((group) => {
|
||||||
|
const firstAgent = group.agents[0];
|
||||||
|
return {
|
||||||
|
key: `project:${group.projectKey}`,
|
||||||
|
projectKey: group.projectKey,
|
||||||
|
title: group.projectName,
|
||||||
|
agents: group.agents,
|
||||||
|
firstAgentServerId: firstAgent?.serverId,
|
||||||
|
firstAgentId: firstAgent?.id,
|
||||||
|
workingDir: firstAgent?.cwd,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: nextSections,
|
||||||
|
checkoutByAgentKey: checkoutLookup,
|
||||||
|
hasAnyData: nextSections.length > 0,
|
||||||
|
};
|
||||||
|
}, [serverEntries, groupedQueries, connectionStates, sessionAgents, projectOrder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentKeys = sections.map((section) => section.projectKey);
|
||||||
|
const storedKeys = new Set(projectOrder);
|
||||||
|
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
||||||
|
if (newKeys.length > 0) {
|
||||||
|
setProjectOrder([...projectOrder, ...newKeys]);
|
||||||
|
}
|
||||||
|
}, [sections, projectOrder, setProjectOrder]);
|
||||||
|
|
||||||
|
const refreshAll = useCallback(() => {
|
||||||
|
for (const { serverId } of serverEntries) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["sidebarAgentsGrouped", serverId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [queryClient, serverEntries]);
|
||||||
|
|
||||||
|
const isFetching = groupedQueries.some(
|
||||||
|
(query) => query.isPending || query.isFetching
|
||||||
|
);
|
||||||
|
const isInitialLoad = isFetching && !hasAnyData;
|
||||||
|
const isRevalidating = isFetching && hasAnyData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
checkoutByAgentKey,
|
||||||
|
isLoading: isFetching,
|
||||||
|
isInitialLoad,
|
||||||
|
isRevalidating,
|
||||||
|
refreshAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,7 +17,11 @@ import type {
|
|||||||
AgentUsage,
|
AgentUsage,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { FileDownloadTokenResponse, GitSetupOptions } from "@server/shared/messages";
|
import type {
|
||||||
|
FileDownloadTokenResponse,
|
||||||
|
GitSetupOptions,
|
||||||
|
ProjectPlacementPayload,
|
||||||
|
} from "@server/shared/messages";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
|
|
||||||
// Re-export types that were in session-context
|
// Re-export types that were in session-context
|
||||||
@@ -95,6 +99,7 @@ export interface Agent {
|
|||||||
attentionTimestamp?: Date | null;
|
attentionTimestamp?: Date | null;
|
||||||
archivedAt?: Date | null;
|
archivedAt?: Date | null;
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
projectPlacement?: ProjectPlacementPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExplorerEntryKind = "file" | "directory";
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
|
|||||||
@@ -151,6 +151,56 @@ describe("DaemonClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fetches project-grouped agents via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.fetchAgentsGroupedByProject({
|
||||||
|
filter: { labels: { ui: "true" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "fetch_agents_grouped_by_project_request";
|
||||||
|
requestId: string;
|
||||||
|
filter?: { labels?: Record<string, string> };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("fetch_agents_grouped_by_project_request");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "fetch_agents_grouped_by_project_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.message.requestId,
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
requestId: request.message.requestId,
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("cancels waiters when send fails (no leaked timeouts)", async () => {
|
test("cancels waiters when send fails (no leaked timeouts)", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
|
|||||||
@@ -117,9 +117,7 @@ export type DaemonEvent =
|
|||||||
| {
|
| {
|
||||||
type: "agent_update";
|
type: "agent_update";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
payload:
|
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||||
| { kind: "upsert"; agent: AgentSnapshotPayload }
|
|
||||||
| { kind: "remove"; agentId: string };
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "agent_stream";
|
type: "agent_stream";
|
||||||
@@ -218,6 +216,10 @@ type AgentRefreshedStatusPayload = z.infer<
|
|||||||
type RestartRequestedStatusPayload = z.infer<
|
type RestartRequestedStatusPayload = z.infer<
|
||||||
typeof RestartRequestedStatusPayloadSchema
|
typeof RestartRequestedStatusPayloadSchema
|
||||||
>;
|
>;
|
||||||
|
type FetchAgentsGroupedByProjectPayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "fetch_agents_grouped_by_project_response" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
export type WaitForFinishResult = {
|
export type WaitForFinishResult = {
|
||||||
status: "idle" | "error" | "permission" | "timeout";
|
status: "idle" | "error" | "permission" | "timeout";
|
||||||
@@ -854,6 +856,33 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchAgentsGroupedByProject(options?: {
|
||||||
|
filter?: { labels?: Record<string, string> };
|
||||||
|
requestId?: string;
|
||||||
|
}): Promise<FetchAgentsGroupedByProjectPayload> {
|
||||||
|
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "fetch_agents_grouped_by_project_request",
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
|
});
|
||||||
|
return this.sendRequest({
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
message,
|
||||||
|
timeout: 15000,
|
||||||
|
options: { skipQueue: true },
|
||||||
|
select: (msg) => {
|
||||||
|
if (msg.type !== "fetch_agents_grouped_by_project_response") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.payload.requestId !== resolvedRequestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async fetchAgent(agentId: string, requestId?: string): Promise<AgentSnapshotPayload | null> {
|
async fetchAgent(agentId: string, requestId?: string): Promise<AgentSnapshotPayload | null> {
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
|||||||
@@ -513,6 +513,14 @@ export class AgentManager {
|
|||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyAgentState(agentId: string): void {
|
||||||
|
const agent = this.agents.get(agentId);
|
||||||
|
if (!agent || agent.internal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitState(agent);
|
||||||
|
}
|
||||||
|
|
||||||
async clearAgentAttention(agentId: string): Promise<void> {
|
async clearAgentAttention(agentId: string): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
if (agent.attention.requiresAttention) {
|
if (agent.attention.requiresAttention) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type UnsubscribeTerminalRequest,
|
type UnsubscribeTerminalRequest,
|
||||||
type TerminalInput,
|
type TerminalInput,
|
||||||
type KillTerminalRequest,
|
type KillTerminalRequest,
|
||||||
|
type ProjectCheckoutLitePayload,
|
||||||
|
type ProjectPlacementPayload,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
||||||
@@ -90,6 +92,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
|
getCheckoutStatusLite,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
MergeFromBaseConflictError,
|
MergeFromBaseConflictError,
|
||||||
@@ -119,6 +122,8 @@ const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
|||||||
let restartRequested = false;
|
let restartRequested = false;
|
||||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||||
const RESTART_EXIT_DELAY_MS = 250;
|
const RESTART_EXIT_DELAY_MS = 250;
|
||||||
|
const PROJECT_PLACEMENT_CACHE_TTL_MS = 10_000;
|
||||||
|
const MAX_AGENTS_PER_PROJECT = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default model used for auto-generating commit messages and PR descriptions.
|
* Default model used for auto-generating commit messages and PR descriptions.
|
||||||
@@ -138,6 +143,78 @@ const VOICE_AGENT_SYSTEM_INSTRUCTION = [
|
|||||||
"Only use the paseo MCP tools.",
|
"Only use the paseo MCP tools.",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
|
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||||
|
if (!remoteUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = remoteUrl.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host: string | null = null;
|
||||||
|
let path: string | null = null;
|
||||||
|
|
||||||
|
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
||||||
|
if (scpLike) {
|
||||||
|
host = scpLike[1] ?? null;
|
||||||
|
path = scpLike[2] ?? null;
|
||||||
|
} else if (trimmed.includes("://")) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
host = parsed.hostname || null;
|
||||||
|
path = parsed.pathname ? parsed.pathname.replace(/^\//, "") : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!host || !path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
if (cleanedPath.endsWith(".git")) {
|
||||||
|
cleanedPath = cleanedPath.slice(0, -4);
|
||||||
|
}
|
||||||
|
if (!cleanedPath.includes("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedHost = host.toLowerCase();
|
||||||
|
if (cleanedHost === "github.com") {
|
||||||
|
return `remote:github.com/${cleanedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
|
||||||
|
const remoteKey = deriveRemoteProjectKey(remoteUrl);
|
||||||
|
if (remoteKey) {
|
||||||
|
return remoteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreeMarker = ".paseo/worktrees/";
|
||||||
|
const idx = cwd.indexOf(worktreeMarker);
|
||||||
|
if (idx !== -1) {
|
||||||
|
return cwd.slice(0, idx).replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveProjectGroupingName(projectKey: string): string {
|
||||||
|
const githubRemotePrefix = "remote:github.com/";
|
||||||
|
if (projectKey.startsWith(githubRemotePrefix)) {
|
||||||
|
return projectKey.slice(githubRemotePrefix.length) || projectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = projectKey.split(/[\\/]/).filter(Boolean);
|
||||||
|
return segments[segments.length - 1] || projectKey;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeXmlText(value: string): string {
|
function escapeXmlText(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -370,6 +447,10 @@ export class Session {
|
|||||||
filter?: { labels?: Record<string, string>; agentId?: string };
|
filter?: { labels?: Record<string, string>; agentId?: string };
|
||||||
}
|
}
|
||||||
| null = null;
|
| null = null;
|
||||||
|
private readonly projectPlacementCache = new Map<
|
||||||
|
string,
|
||||||
|
{ expiresAt: number; promise: Promise<ProjectPlacementPayload> }
|
||||||
|
>();
|
||||||
private clientActivity: {
|
private clientActivity: {
|
||||||
deviceType: "web" | "mobile";
|
deviceType: "web" | "mobile";
|
||||||
focusedAgentId: string | null;
|
focusedAgentId: string | null;
|
||||||
@@ -819,6 +900,73 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
isGit: false,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toProjectCheckoutLite(
|
||||||
|
cwd: string,
|
||||||
|
status: Awaited<ReturnType<typeof getCheckoutStatusLite>>
|
||||||
|
): ProjectCheckoutLitePayload {
|
||||||
|
if (!status.isGit) {
|
||||||
|
return this.buildFallbackProjectCheckout(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.isPaseoOwnedWorktree) {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: status.currentBranch,
|
||||||
|
remoteUrl: status.remoteUrl,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
mainRepoRoot: status.mainRepoRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: status.currentBranch,
|
||||||
|
remoteUrl: status.remoteUrl,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||||
|
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||||
|
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
||||||
|
.catch(() => this.buildFallbackProjectCheckout(cwd));
|
||||||
|
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
|
||||||
|
return {
|
||||||
|
projectKey,
|
||||||
|
projectName: deriveProjectGroupingName(projectKey),
|
||||||
|
checkout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.projectPlacementCache.get(cwd);
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cached.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.buildProjectPlacement(cwd);
|
||||||
|
this.projectPlacementCache.set(cwd, {
|
||||||
|
expiresAt: now + PROJECT_PLACEMENT_CACHE_TTL_MS,
|
||||||
|
promise,
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const subscription = this.agentUpdatesSubscription;
|
const subscription = this.agentUpdatesSubscription;
|
||||||
@@ -830,9 +978,10 @@ export class Session {
|
|||||||
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
|
const project = await this.getProjectPlacement(payload.cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "agent_update",
|
type: "agent_update",
|
||||||
payload: { kind: "upsert", agent: payload },
|
payload: { kind: "upsert", agent: payload, project },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -868,6 +1017,10 @@ export class Session {
|
|||||||
await this.handleFetchAgents(msg.requestId, msg.filter);
|
await this.handleFetchAgents(msg.requestId, msg.filter);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "fetch_agents_grouped_by_project_request":
|
||||||
|
await this.handleFetchAgentsGroupedByProject(msg.requestId, msg.filter);
|
||||||
|
break;
|
||||||
|
|
||||||
case "fetch_agent_request":
|
case "fetch_agent_request":
|
||||||
await this.handleFetchAgent(msg.agentId, msg.requestId);
|
await this.handleFetchAgent(msg.agentId, msg.requestId);
|
||||||
break;
|
break;
|
||||||
@@ -1254,6 +1407,7 @@ export class Session {
|
|||||||
archivedAt,
|
archivedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.agentManager.notifyAgentState(agentId);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
@@ -4076,6 +4230,87 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async listAgentsGroupedByProjectPayload(filter?: {
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}): Promise<Array<{
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
agents: Array<{
|
||||||
|
agent: AgentSnapshotPayload;
|
||||||
|
checkout: ProjectCheckoutLitePayload;
|
||||||
|
}>;
|
||||||
|
}>> {
|
||||||
|
const agents = await this.listAgentPayloads(filter);
|
||||||
|
const visibleAgents = agents
|
||||||
|
.filter((agent) => !agent.archivedAt)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Date.parse(right.updatedAt || "") - Date.parse(left.updatedAt || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
agents: Array<{
|
||||||
|
agent: AgentSnapshotPayload;
|
||||||
|
checkout: ProjectCheckoutLitePayload;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Warm project placement status for all visible roots up front to avoid serial N+1 latency.
|
||||||
|
for (const agent of visibleAgents) {
|
||||||
|
void this.getProjectPlacement(agent.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of visibleAgents) {
|
||||||
|
const project = await this.getProjectPlacement(agent.cwd);
|
||||||
|
const projectKey = project.projectKey;
|
||||||
|
|
||||||
|
let group = grouped.get(projectKey);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
projectKey,
|
||||||
|
projectName: project.projectName,
|
||||||
|
agents: [],
|
||||||
|
};
|
||||||
|
grouped.set(projectKey, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.agents.length >= MAX_AGENTS_PER_PROJECT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.agents.push({ agent, checkout: project.checkout });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFetchAgentsGroupedByProject(
|
||||||
|
requestId: string,
|
||||||
|
filter?: { labels?: Record<string, string> }
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const groups = await this.listAgentsGroupedByProjectPayload(filter);
|
||||||
|
this.emit({
|
||||||
|
type: "fetch_agents_grouped_by_project_response",
|
||||||
|
payload: { requestId, groups },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.error(
|
||||||
|
{ err: error },
|
||||||
|
"Failed to handle fetch_agents_grouped_by_project_request"
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "fetch_agents_grouped_by_project_response",
|
||||||
|
payload: { requestId, groups: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||||||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
|
|||||||
@@ -454,6 +454,16 @@ export const FetchAgentsRequestMessageSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FetchAgentsGroupedByProjectRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("fetch_agents_grouped_by_project_request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
filter: z
|
||||||
|
.object({
|
||||||
|
labels: z.record(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const FetchAgentRequestMessageSchema = z.object({
|
export const FetchAgentRequestMessageSchema = z.object({
|
||||||
type: z.literal("fetch_agent_request"),
|
type: z.literal("fetch_agent_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -948,6 +958,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
FetchAgentsRequestMessageSchema,
|
FetchAgentsRequestMessageSchema,
|
||||||
|
FetchAgentsGroupedByProjectRequestMessageSchema,
|
||||||
FetchAgentRequestMessageSchema,
|
FetchAgentRequestMessageSchema,
|
||||||
SubscribeAgentUpdatesMessageSchema,
|
SubscribeAgentUpdatesMessageSchema,
|
||||||
UnsubscribeAgentUpdatesMessageSchema,
|
UnsubscribeAgentUpdatesMessageSchema,
|
||||||
@@ -1191,12 +1202,52 @@ export const ArtifactMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
isGit: z.literal(false),
|
||||||
|
currentBranch: z.null(),
|
||||||
|
remoteUrl: z.null(),
|
||||||
|
isPaseoOwnedWorktree: z.literal(false),
|
||||||
|
mainRepoRoot: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
isGit: z.literal(true),
|
||||||
|
currentBranch: z.string().nullable(),
|
||||||
|
remoteUrl: z.string().nullable(),
|
||||||
|
isPaseoOwnedWorktree: z.literal(false),
|
||||||
|
mainRepoRoot: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
isGit: z.literal(true),
|
||||||
|
currentBranch: z.string().nullable(),
|
||||||
|
remoteUrl: z.string().nullable(),
|
||||||
|
isPaseoOwnedWorktree: z.literal(true),
|
||||||
|
mainRepoRoot: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectCheckoutLitePayloadSchema = z.union([
|
||||||
|
ProjectCheckoutLiteNotGitPayloadSchema,
|
||||||
|
ProjectCheckoutLiteGitNonPaseoPayloadSchema,
|
||||||
|
ProjectCheckoutLiteGitPaseoPayloadSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ProjectPlacementPayloadSchema = z.object({
|
||||||
|
projectKey: z.string(),
|
||||||
|
projectName: z.string(),
|
||||||
|
checkout: ProjectCheckoutLitePayloadSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const AgentUpdateMessageSchema = z.object({
|
export const AgentUpdateMessageSchema = z.object({
|
||||||
type: z.literal("agent_update"),
|
type: z.literal("agent_update"),
|
||||||
payload: z.discriminatedUnion("kind", [
|
payload: z.discriminatedUnion("kind", [
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("upsert"),
|
kind: z.literal("upsert"),
|
||||||
agent: AgentSnapshotPayloadSchema,
|
agent: AgentSnapshotPayloadSchema,
|
||||||
|
project: ProjectPlacementPayloadSchema,
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("remove"),
|
kind: z.literal("remove"),
|
||||||
@@ -1251,6 +1302,25 @@ export const FetchAgentsResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ProjectGroupedAgentEntryPayloadSchema = z.object({
|
||||||
|
agent: AgentSnapshotPayloadSchema,
|
||||||
|
checkout: ProjectCheckoutLitePayloadSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ProjectGroupPayloadSchema = z.object({
|
||||||
|
projectKey: z.string(),
|
||||||
|
projectName: z.string(),
|
||||||
|
agents: z.array(ProjectGroupedAgentEntryPayloadSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FetchAgentsGroupedByProjectResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("fetch_agents_grouped_by_project_response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
groups: z.array(ProjectGroupPayloadSchema),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const FetchAgentResponseMessageSchema = z.object({
|
export const FetchAgentResponseMessageSchema = z.object({
|
||||||
type: z.literal("fetch_agent_response"),
|
type: z.literal("fetch_agent_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1715,6 +1785,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentStreamSnapshotMessageSchema,
|
AgentStreamSnapshotMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
|
FetchAgentsGroupedByProjectResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
SendAgentMessageResponseMessageSchema,
|
SendAgentMessageResponseMessageSchema,
|
||||||
SetVoiceModeResponseMessageSchema,
|
SetVoiceModeResponseMessageSchema,
|
||||||
@@ -1772,9 +1843,14 @@ export type AgentStreamSnapshotMessage = z.infer<
|
|||||||
typeof AgentStreamSnapshotMessageSchema
|
typeof AgentStreamSnapshotMessageSchema
|
||||||
>;
|
>;
|
||||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||||
|
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||||
|
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||||
export type FetchAgentsResponseMessage = z.infer<
|
export type FetchAgentsResponseMessage = z.infer<
|
||||||
typeof FetchAgentsResponseMessageSchema
|
typeof FetchAgentsResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
export type FetchAgentsGroupedByProjectResponseMessage = z.infer<
|
||||||
|
typeof FetchAgentsGroupedByProjectResponseMessageSchema
|
||||||
|
>;
|
||||||
export type FetchAgentResponseMessage = z.infer<
|
export type FetchAgentResponseMessage = z.infer<
|
||||||
typeof FetchAgentResponseMessageSchema
|
typeof FetchAgentResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
@@ -1803,6 +1879,9 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
|||||||
// Type exports for inbound message types
|
// Type exports for inbound message types
|
||||||
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
||||||
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
||||||
|
export type FetchAgentsGroupedByProjectRequestMessage = z.infer<
|
||||||
|
typeof FetchAgentsGroupedByProjectRequestMessageSchema
|
||||||
|
>;
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
commitAll,
|
commitAll,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
|
getCheckoutStatusLite,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
mergeFromBase,
|
mergeFromBase,
|
||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
@@ -78,6 +79,14 @@ describe("checkout git utilities", () => {
|
|||||||
expect(message).toBe("update file");
|
expect(message).toBe("update file");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns lightweight checkout status for normal repos", async () => {
|
||||||
|
const status = await getCheckoutStatusLite(repoDir);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
expect(status.currentBranch).toBe("main");
|
||||||
|
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||||
|
expect(status.mainRepoRoot).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes hasRemote when origin is configured", async () => {
|
it("exposes hasRemote when origin is configured", async () => {
|
||||||
const remoteDir = join(tempDir, "remote.git");
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
execSync(`git init --bare -b main ${remoteDir}`);
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
@@ -196,6 +205,21 @@ describe("checkout git utilities", () => {
|
|||||||
expect(message).toBe("worktree update");
|
expect(message).toBe("worktree update");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns lightweight checkout status for .paseo worktrees", async () => {
|
||||||
|
const result = await createWorktree({
|
||||||
|
branchName: "main",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "lite-alpha",
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||||
|
expect(status.mainRepoRoot).toBe(repoDir);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
|
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
|
||||||
const bareRepoDir = join(tempDir, "bare-repo");
|
const bareRepoDir = join(tempDir, "bare-repo");
|
||||||
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
|
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
|
||||||
|
|||||||
@@ -296,6 +296,35 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
|
|||||||
|
|
||||||
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
||||||
|
|
||||||
|
export type CheckoutStatusLiteNotGit = {
|
||||||
|
isGit: false;
|
||||||
|
currentBranch: null;
|
||||||
|
remoteUrl: null;
|
||||||
|
isPaseoOwnedWorktree: false;
|
||||||
|
mainRepoRoot: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckoutStatusLiteGitNonPaseo = {
|
||||||
|
isGit: true;
|
||||||
|
currentBranch: string | null;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
isPaseoOwnedWorktree: false;
|
||||||
|
mainRepoRoot: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckoutStatusLiteGitPaseo = {
|
||||||
|
isGit: true;
|
||||||
|
currentBranch: string | null;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
isPaseoOwnedWorktree: true;
|
||||||
|
mainRepoRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckoutStatusLiteResult =
|
||||||
|
| CheckoutStatusLiteNotGit
|
||||||
|
| CheckoutStatusLiteGitNonPaseo
|
||||||
|
| CheckoutStatusLiteGitPaseo;
|
||||||
|
|
||||||
export interface CheckoutDiffResult {
|
export interface CheckoutDiffResult {
|
||||||
diff: string;
|
diff: string;
|
||||||
structured?: ParsedDiffFile[];
|
structured?: ParsedDiffFile[];
|
||||||
@@ -466,6 +495,11 @@ async function getConfiguredBaseRefForCwd(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext
|
context?: CheckoutContext
|
||||||
): Promise<ConfiguredBaseRefForCwd> {
|
): Promise<ConfiguredBaseRefForCwd> {
|
||||||
|
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||||
|
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||||
|
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||||
|
}
|
||||||
|
|
||||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
|
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
|
||||||
if (!ownership.allowed) {
|
if (!ownership.allowed) {
|
||||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||||
@@ -636,6 +670,43 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CheckoutInspectionContext = {
|
||||||
|
worktreeRoot: string;
|
||||||
|
currentBranch: string | null;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
configured: ConfiguredBaseRefForCwd;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function inspectCheckoutContext(
|
||||||
|
cwd: string,
|
||||||
|
context?: CheckoutContext
|
||||||
|
): Promise<CheckoutInspectionContext | null> {
|
||||||
|
try {
|
||||||
|
const root = await getWorktreeRoot(cwd);
|
||||||
|
if (!root) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentBranch, remoteUrl, configured] = await Promise.all([
|
||||||
|
getCurrentBranch(cwd),
|
||||||
|
getOriginRemoteUrl(cwd),
|
||||||
|
getConfiguredBaseRefForCwd(cwd, context),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worktreeRoot: root,
|
||||||
|
currentBranch,
|
||||||
|
remoteUrl,
|
||||||
|
configured,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isGitError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
||||||
@@ -762,25 +833,17 @@ export async function getCheckoutStatus(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext
|
context?: CheckoutContext
|
||||||
): Promise<CheckoutStatusResult> {
|
): Promise<CheckoutStatusResult> {
|
||||||
let worktreeRoot: string;
|
const inspected = await inspectCheckoutContext(cwd, context);
|
||||||
try {
|
if (!inspected) {
|
||||||
const root = await getWorktreeRoot(cwd);
|
return { isGit: false };
|
||||||
if (!root) {
|
|
||||||
return { isGit: false };
|
|
||||||
}
|
|
||||||
worktreeRoot = root;
|
|
||||||
} catch (error) {
|
|
||||||
if (isGitError(error)) {
|
|
||||||
return { isGit: false };
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const worktreeRoot = inspected.worktreeRoot;
|
||||||
|
const currentBranch = inspected.currentBranch;
|
||||||
|
const remoteUrl = inspected.remoteUrl;
|
||||||
|
const configured = inspected.configured;
|
||||||
const isDirty = await isWorkingTreeDirty(cwd);
|
const isDirty = await isWorkingTreeDirty(cwd);
|
||||||
const remoteUrl = await getOriginRemoteUrl(cwd);
|
|
||||||
const hasRemote = remoteUrl !== null;
|
const hasRemote = remoteUrl !== null;
|
||||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
|
||||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||||
const aheadBehind =
|
const aheadBehind =
|
||||||
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
||||||
@@ -818,6 +881,40 @@ export async function getCheckoutStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCheckoutStatusLite(
|
||||||
|
cwd: string,
|
||||||
|
context?: CheckoutContext
|
||||||
|
): Promise<CheckoutStatusLiteResult> {
|
||||||
|
const inspected = await inspectCheckoutContext(cwd, context);
|
||||||
|
if (!inspected) {
|
||||||
|
return {
|
||||||
|
isGit: false,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inspected.configured.isPaseoOwnedWorktree) {
|
||||||
|
return {
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: inspected.currentBranch,
|
||||||
|
remoteUrl: inspected.remoteUrl,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
mainRepoRoot: await getMainRepoRoot(cwd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: inspected.currentBranch,
|
||||||
|
remoteUrl: inspected.remoteUrl,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCheckoutDiff(
|
export async function getCheckoutDiff(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: CheckoutDiffCompare,
|
compare: CheckoutDiffCompare,
|
||||||
|
|||||||
Reference in New Issue
Block a user