Files
paseo/packages/app/src/components/grouped-agent-list.tsx
T

854 lines
25 KiB
TypeScript

import {
View,
Text,
Pressable,
Modal,
Image,
Platform,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useCallback,
useMemo,
useState,
useEffect,
useRef,
type ReactElement,
type MutableRefObject,
} from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { useQueryClient } from "@tanstack/react-query";
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
import {
DraggableList,
type DraggableRenderItemInfo,
} from "./draggable-list";
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,
} from "@/utils/navigation-timing";
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 { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { getIsTauri } from "@/constants/layout";
import { AgentStatusDot } from "@/components/agent-status-dot";
type SectionData = SidebarSectionData;
interface GroupedAgentListProps {
agents: AggregatedAgent[];
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
}
interface SectionHeaderProps {
section: SectionData;
isCollapsed: boolean;
onToggle: () => void;
onCreateAgent: (workingDir: string) => void;
onDrag: () => void;
isDragging: boolean;
}
function SectionHeader({
section,
isCollapsed,
onToggle,
onCreateAgent,
onDrag,
isDragging,
}: SectionHeaderProps) {
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 ?? "",
cwd: section.workingDir ?? "",
});
const icon = iconQuery.icon;
// Derive display title: prefer repo name from remote URL, fallback to path-based name
let displayTitle = section.title;
if (checkout?.isGit && checkout.remoteUrl) {
const isGitHubRemote =
checkout.remoteUrl.includes("github.com") ||
checkout.remoteUrl.includes("git@github.com:");
const repoName = isGitHubRemote
? parseRepoNameFromRemoteUrl(checkout.remoteUrl)
: parseRepoShortNameFromRemoteUrl(checkout.remoteUrl);
if (repoName) {
displayTitle = repoName;
}
}
const createAgentWorkingDir =
(checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? section.workingDir;
const handleCreatePress = useCallback(
(e: { stopPropagation: () => void }) => {
e.stopPropagation();
if (createAgentWorkingDir) {
onCreateAgent(createAgentWorkingDir);
}
},
[onCreateAgent, createAgentWorkingDir]
);
return (
<Pressable
style={({ pressed }) => [
styles.sectionHeader,
isHovered && styles.sectionHeaderHovered,
pressed && styles.sectionHeaderPressed,
!isCollapsed && styles.sectionHeaderExpanded,
isDragging && styles.sectionHeaderDragging,
Platform.OS === "web" && ({ cursor: "grab" } as any),
]}
onPress={onToggle}
onLongPress={onDrag}
delayLongPress={200}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
>
<View style={styles.sectionHeaderLeft}>
<View style={styles.chevron}>
{isCollapsed ? (
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
) : (
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
)}
</View>
{icon ? (
<Image
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
style={styles.projectIcon}
/>
) : (
<View style={styles.projectIconPlaceholder}>
<Text style={styles.projectIconPlaceholderText}>
{(section.workingDir?.split("/").pop() ?? displayTitle).charAt(0).toUpperCase()}
</Text>
</View>
)}
<Text style={styles.sectionTitle} numberOfLines={1}>
{displayTitle}
</Text>
</View>
<View style={styles.sectionHeaderRight}>
{createAgentWorkingDir && (
<Pressable
style={styles.createAgentButton}
onPress={handleCreatePress}
hitSlop={20}
onStartShouldSetResponder={() => true}
onStartShouldSetResponderCapture={() => true}
>
{({ hovered, pressed }) => (
<Plus
size={18}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
)}
</View>
</Pressable>
);
}
interface GroupedAgentRowProps {
agent: AggregatedAgent;
isSelected: boolean;
shortcutNumber: number | null;
onPress: () => void;
onLongPress: () => void;
onArchive: (e: { stopPropagation: () => void }) => void;
}
function GroupedAgentRow({
agent,
isSelected,
shortcutNumber,
onPress,
onLongPress,
onArchive,
}: GroupedAgentRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const [isArchiveHovered, setIsArchiveHovered] = useState(false);
const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
return;
}
clearTimeout(hoverOutTimeoutRef.current);
hoverOutTimeoutRef.current = null;
}, []);
useEffect(() => {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const handleHoverIn = useCallback(() => {
clearHoverOutTimeout();
setIsHovered(true);
}, [clearHoverOutTimeout]);
const handleHoverOut = useCallback(() => {
clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
setIsArchiveHovered(false);
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;
const canArchive = agent.status !== "running" && !agent.requiresAttention;
const showArchive = canArchive &&
shortcutNumber === null &&
(isHovered || isArchiveHovered || isArchiveConfirmVisible);
return (
<Pressable
style={({ pressed }) => [
styles.agentItem,
!isSelected && styles.agentItemUnselected,
isSelected && styles.agentItemSelected,
isHovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={onPress}
onPressIn={() => {
if (Platform.OS !== "web") {
return;
}
handleHoverIn();
}}
onLongPress={onLongPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : showArchive ? (
<Pressable
style={[
styles.branchBadge,
isArchiveConfirmVisible && styles.archiveConfirmBadge,
]}
onHoverIn={() => {
clearHoverOutTimeout();
setIsHovered(true);
setIsArchiveHovered(true);
}}
onHoverOut={() => {
setIsArchiveHovered(false);
}}
onPress={(e) => {
e.stopPropagation();
if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true);
return;
}
onArchive(e);
setIsArchiveConfirmVisible(false);
}}
testID={
isArchiveConfirmVisible
? `agent-archive-confirm-${agent.serverId}-${agent.id}`
: `agent-archive-${agent.serverId}-${agent.id}`
}
>
{({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? (
<Text
style={[
styles.branchBadgeText,
styles.archiveConfirmText,
archiveHovered && styles.archiveConfirmTextHovered,
]}
numberOfLines={1}
>
Confirm
</Text>
) : (
<Archive
size={12}
color={
archiveHovered ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
}
export function GroupedAgentList({
agents,
isRefreshing = false,
onRefresh,
selectedAgentId,
onAgentSelect,
listFooterComponent,
parentGestureRef,
}: GroupedAgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
const queryClient = useQueryClient();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed);
const altDown = useKeyboardNavStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown);
const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys);
const isTauri = getIsTauri();
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown);
const shortcutIndexByAgentKey = useMemo(() => {
const map = new Map<string, number>();
for (let i = 0; i < sidebarShortcutAgentKeys.length; i++) {
const key = sidebarShortcutAgentKeys[i];
if (!key) continue;
map.set(key, i + 1);
}
return map;
}, [sidebarShortcutAgentKeys]);
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
);
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(serverId: string, agentId: string) => {
if (isActionSheetVisible) {
return;
}
// Clear attention flag when opening agent
const session = useSessionStore.getState().sessions[serverId];
if (session?.client) {
session.client.clearAgentAttention(agentId);
}
const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, {
from: "home",
to: "agent",
params: { serverId, agentId },
});
const shouldReplace = pathname.startsWith("/agent/");
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.();
navigate(`/agent/${serverId}/${agentId}` as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent);
}, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null);
}, []);
const handleArchiveFromSheet = useCallback(() => {
if (!actionAgent || !actionClient) {
return;
}
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, actionClient]);
const handleCreateAgentInProject = useCallback(
(workingDir: string) => {
onAgentSelect?.();
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
},
[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);
setProjectOrder(newOrder);
},
[setProjectOrder]
);
const handleArchiveAgent = useCallback(
(e: { stopPropagation: () => void }, agent: AggregatedAgent) => {
e.stopPropagation();
const session = useSessionStore.getState().sessions[agent.serverId];
const client = session?.client ?? null;
if (client) {
void client.archiveAgent(agent.id).catch((error) => {
console.warn("[archive_agent] failed", error);
});
}
},
[]
);
const renderSection = useCallback(
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
return (
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
<SectionHeader
section={section}
isCollapsed={isCollapsed}
onToggle={() => toggleProjectCollapsed(section.projectKey)}
onCreateAgent={handleCreateAgentInProject}
onDrag={drag}
isDragging={isActive}
/>
{!isCollapsed &&
section.agents.map((agent) => (
<GroupedAgentRow
key={`${agent.serverId}:${agent.id}`}
agent={agent}
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
shortcutNumber={
showShortcutBadges
? (shortcutIndexByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null)
: null
}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
onArchive={(e) => handleArchiveAgent(e, agent)}
/>
))}
</View>
);
},
[
collapsedProjectKeys,
handleAgentLongPress,
handleAgentPress,
handleArchiveAgent,
handleCreateAgentInProject,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
toggleProjectCollapsed,
]
);
const keyExtractor = useCallback(
(section: SectionData) => section.key,
[]
);
return (
<>
<DraggableList
data={sections}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
renderItem={renderSection}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
ListFooterComponent={listFooterComponent}
refreshing={isRefreshing}
onRefresh={onRefresh}
simultaneousGestureRef={parentGestureRef}
/>
<Modal
visible={isActionSheetVisible}
animationType="fade"
transparent
onRequestClose={handleCloseActionSheet}
>
<View style={styles.sheetOverlay}>
<Pressable
style={styles.sheetBackdrop}
onPress={handleCloseActionSheet}
/>
<View style={[styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }]}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
? "Host offline"
: "Archive this agent?"}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
style={[styles.sheetButton, styles.sheetCancelButton]}
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
<Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveFromSheet}
testID="agent-action-archive"
>
<Text
style={[
styles.sheetArchiveText,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive
</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create((theme) => ({
list: {
flex: 1,
},
listContent: {
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
sectionHeaderHovered: {
backgroundColor: theme.colors.surface1,
},
sectionHeaderPressed: {
backgroundColor: theme.colors.surface2,
},
sectionHeaderExpanded: {
marginBottom: theme.spacing[2],
},
sectionHeaderDragging: {
backgroundColor: theme.colors.surface2,
},
sectionContainer: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
sectionDragging: {
opacity: 0.9,
transform: [{ scale: 1.02 }],
},
chevron: {
opacity: 0.5,
},
sectionHeaderLeft: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
flex: 1,
minWidth: 0,
gap: theme.spacing[2],
},
projectIcon: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
},
projectIconPlaceholder: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconPlaceholderText: {
fontSize: 10,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
sectionHeaderRight: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
marginLeft: theme.spacing[2],
},
createAgentButton: {
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: "normal",
color: theme.colors.foregroundMuted,
flex: 1,
textAlign: "left",
},
agentItem: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
marginLeft: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
},
agentItemUnselected: {
opacity: 0.75,
},
agentItemSelected: {
backgroundColor: theme.colors.surface2,
},
agentItemHovered: {
backgroundColor: theme.colors.surface1,
},
agentItemPressed: {
backgroundColor: theme.colors.surface2,
},
agentContent: {
flex: 1,
gap: theme.spacing[0],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentTitle: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: "400",
color: theme.colors.foreground,
opacity: 0.8,
},
archiveButton: {
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
branchBadge: {
minWidth: 20,
maxWidth: "40%",
height: 20,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
branchBadgeText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
lineHeight: 12,
},
archiveConfirmBadge: {
minWidth: 76,
maxWidth: 9999,
flexShrink: 0,
},
archiveConfirmText: {
color: theme.colors.foreground,
},
archiveConfirmTextHovered: {
opacity: 0.9,
},
agentTitleHighlighted: {
color: theme.colors.foreground,
opacity: 1,
},
secondaryRow: {
fontSize: theme.fontSize.sm,
fontWeight: "300",
color: theme.colors.foregroundMuted,
},
sheetOverlay: {
flex: 1,
justifyContent: "flex-end",
},
sheetBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "rgba(0,0,0,0.35)",
},
sheetContainer: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
gap: theme.spacing[4],
},
sheetHandle: {
alignSelf: "center",
width: 40,
height: 4,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundMuted,
opacity: 0.3,
},
sheetTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
sheetButtonRow: {
flexDirection: "row",
gap: theme.spacing[3],
},
sheetButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[4],
alignItems: "center",
justifyContent: "center",
},
sheetArchiveButton: {
backgroundColor: theme.colors.primary,
},
sheetArchiveText: {
color: theme.colors.primaryForeground,
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
sheetArchiveTextDisabled: {
opacity: 0.5,
},
sheetCancelButton: {
backgroundColor: theme.colors.surface1,
},
sheetCancelText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
}));