diff --git a/packages/app/src/components/sidebar-agent-list-skeleton.tsx b/packages/app/src/components/sidebar-agent-list-skeleton.tsx new file mode 100644 index 000000000..de4619730 --- /dev/null +++ b/packages/app/src/components/sidebar-agent-list-skeleton.tsx @@ -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; +}) { + const opacity = pulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.45, 0.95], + }); + + return ; +} + +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 ( + + {Array.from({ length: 4 }).map((_, sectionIdx) => ( + + + + + + + + {Array.from({ length: 3 }).map((__, rowIdx) => ( + + + + + + + + ))} + + + ))} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx similarity index 90% rename from packages/app/src/components/grouped-agent-list.tsx rename to packages/app/src/components/sidebar-agent-list.tsx index 4f3fb5180..98dd48c8a 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -19,7 +19,6 @@ import { import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; -import { useQueryClient } from "@tanstack/react-query"; import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native"; import { DraggableList, @@ -28,11 +27,6 @@ import { import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; -import { - CHECKOUT_STATUS_STALE_TIME, - checkoutStatusQueryKey, - useCheckoutStatusCacheOnly, -} from "@/hooks/use-checkout-status-query"; import { buildAgentNavigationKey, startNavigationTiming, @@ -41,7 +35,10 @@ import { useSectionOrderStore, } from "@/stores/section-order-store"; import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; -import { useSidebarAgentSections, type SidebarSectionData } from "@/hooks/use-sidebar-agent-sections"; +import { + type SidebarCheckoutLite, + type SidebarSectionData, +} from "@/hooks/use-sidebar-agents-grouped"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { getIsTauri } from "@/constants/layout"; @@ -49,8 +46,9 @@ import { AgentStatusDot } from "@/components/agent-status-dot"; type SectionData = SidebarSectionData; -interface GroupedAgentListProps { - agents: AggregatedAgent[]; +interface SidebarAgentListProps { + sections: SidebarSectionData[]; + checkoutByAgentKey: Map; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; @@ -62,6 +60,7 @@ interface GroupedAgentListProps { interface SectionHeaderProps { section: SectionData; + checkout: SidebarCheckoutLite | null; isCollapsed: boolean; onToggle: () => void; onCreateAgent: (workingDir: string) => void; @@ -71,6 +70,7 @@ interface SectionHeaderProps { function SectionHeader({ section, + checkout, isCollapsed, onToggle, onCreateAgent, @@ -80,13 +80,6 @@ function SectionHeader({ const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); - // For project sections, try to get repo name from checkout status - const checkoutQuery = useCheckoutStatusCacheOnly({ - serverId: section.firstAgentServerId ?? "", - cwd: section.workingDir ?? "", - }); - const checkout = checkoutQuery.data ?? null; - // Get project icon const iconQuery = useProjectIconQuery({ serverId: section.firstAgentServerId ?? "", @@ -185,8 +178,9 @@ function SectionHeader({ ); } -interface GroupedAgentRowProps { +interface SidebarAgentRowProps { agent: AggregatedAgent; + checkout: SidebarCheckoutLite | null; isSelected: boolean; shortcutNumber: number | null; onPress: () => void; @@ -194,14 +188,15 @@ interface GroupedAgentRowProps { onArchive: (e: { stopPropagation: () => void }) => void; } -function GroupedAgentRow({ +function SidebarAgentRow({ agent, + checkout, isSelected, shortcutNumber, onPress, onLongPress, onArchive, -}: GroupedAgentRowProps) { +}: SidebarAgentRowProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const [isArchiveHovered, setIsArchiveHovered] = useState(false); @@ -233,17 +228,10 @@ function GroupedAgentRow({ setIsArchiveConfirmVisible(false); }, 50); }, [clearHoverOutTimeout]); - - const checkoutQuery = useCheckoutStatusCacheOnly({ - serverId: agent.serverId, - cwd: agent.cwd, - }); - const checkout = checkoutQuery.data ?? null; const activeBranchLabel = checkout?.isGit ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD" ? checkout.currentBranch : null) ?? - checkout.baseRef ?? "git") : null; @@ -355,18 +343,18 @@ function GroupedAgentRow({ ); } -export function GroupedAgentList({ - agents, +export function SidebarAgentList({ + sections, + checkoutByAgentKey, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect, listFooterComponent, parentGestureRef, -}: GroupedAgentListProps) { +}: SidebarAgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); - const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); @@ -448,43 +436,9 @@ export function GroupedAgentList({ [onAgentSelect] ); - // Prefetch checkout status for all agents in the sidebar. - // The sidebar shows a limited number of agents, so we fetch all of them upfront - // to ensure project grouping (by remote URL) is stable from the start. - useEffect(() => { - for (const agent of agents) { - const session = useSessionStore.getState().sessions[agent.serverId]; - const client = session?.client ?? null; - const isConnected = session?.connection.isConnected ?? false; - if (!client || !isConnected) { - continue; - } - - const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd); - const queryState = queryClient.getQueryState(queryKey); - const isFetching = queryState?.fetchStatus === "fetching"; - const isFresh = - typeof queryState?.dataUpdatedAt === "number" && - Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME; - if (isFetching || isFresh) { - continue; - } - - void queryClient.prefetchQuery({ - queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.cwd), - staleTime: CHECKOUT_STATUS_STALE_TIME, - }).catch((error) => { - console.warn("[checkout_status] prefetch failed", error); - }); - } - }, [agents, queryClient]); - // Section order from store const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); - const sections: SectionData[] = useSidebarAgentSections(agents); - const handleDragEnd = useCallback( (newData: SectionData[]) => { const newOrder = newData.map((section) => section.projectKey); @@ -510,11 +464,19 @@ export function GroupedAgentList({ const renderSection = useCallback( ({ item: section, drag, isActive }: DraggableRenderItemInfo) => { 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 ( toggleProjectCollapsed(section.projectKey)} onCreateAgent={handleCreateAgentInProject} @@ -523,9 +485,12 @@ export function GroupedAgentList({ /> {!isCollapsed && section.agents.map((agent) => ( - { - return [...agents].sort((a, b) => { - if (a.requiresAttention && !b.requiresAttention) return -1; - if (!a.requiresAttention && b.requiresAttention) return 1; - return 0; - }); - }, [agents]); - - // Pass all agents to grouping, limit is applied inside groupAgents per-project - const sidebarSections = useSidebarAgentSections(sortedAgents); const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys); const sidebarShortcutAgentKeys = useMemo(() => { - return deriveSidebarShortcutAgentKeys(sidebarSections, collapsedProjectKeys, 9); - }, [collapsedProjectKeys, sidebarSections]); + return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9); + }, [collapsedProjectKeys, sections]); useEffect(() => { setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys); @@ -226,14 +221,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Middle: scrollable agent list */} - + {isInitialLoad ? ( + + ) : ( + + )} {/* Footer */} @@ -301,12 +301,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Middle: scrollable agent list */} - + {isInitialLoad ? ( + + ) : ( + + )} {/* Footer */} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 46acb4ec9..dadb11723 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -238,6 +238,7 @@ function normalizeAgentSnapshot( attentionTimestamp, archivedAt, labels: snapshot.labels, + projectPlacement: null, }; } @@ -583,7 +584,10 @@ export function SessionProvider({ 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); diff --git a/packages/app/src/hooks/use-sidebar-agents-grouped.ts b/packages/app/src/hooks/use-sidebar-agents-grouped.ts new file mode 100644 index 000000000..6a613cb78 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-agents-grouped.ts @@ -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; + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; + refreshAll: () => void; +} + +function toAggregatedAgent(params: { + source: Agent | ReturnType; + 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 | 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 | undefined> = {}; + for (const [serverId, session] of Object.entries(state.sessions)) { + result[serverId] = session.agents; + } + return result; + }) + ); + + const sessionConnections = useSessionStore( + useShallow((state) => { + const result: Record = {}; + 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(); + const checkoutLookup = new Map(); + const seenAgentKeys = new Set(); + const groupedFetchReadyByServer = new Map(); + + 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, + }; +} diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 795235712..53991d9fc 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -17,7 +17,11 @@ import type { AgentUsage, AgentPersistenceHandle, } 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"; // Re-export types that were in session-context @@ -95,6 +99,7 @@ export interface Agent { attentionTimestamp?: Date | null; archivedAt?: Date | null; labels: Record; + projectPlacement?: ProjectPlacementPayload | null; } export type ExplorerEntryKind = "file" | "directory"; diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 399c465d8..1981c30c1 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -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 }; + }; + }; + 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 () => { vi.useFakeTimers(); const logger = createMockLogger(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 322afb169..6bcf83e29 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -117,9 +117,7 @@ export type DaemonEvent = | { type: "agent_update"; agentId: string; - payload: - | { kind: "upsert"; agent: AgentSnapshotPayload } - | { kind: "remove"; agentId: string }; + payload: Extract["payload"]; } | { type: "agent_stream"; @@ -218,6 +216,10 @@ type AgentRefreshedStatusPayload = z.infer< type RestartRequestedStatusPayload = z.infer< typeof RestartRequestedStatusPayloadSchema >; +type FetchAgentsGroupedByProjectPayload = Extract< + SessionOutboundMessage, + { type: "fetch_agents_grouped_by_project_response" } +>["payload"]; export type WaitForFinishResult = { status: "idle" | "error" | "permission" | "timeout"; @@ -856,6 +858,33 @@ export class DaemonClient { }); } + async fetchAgentsGroupedByProject(options?: { + filter?: { labels?: Record }; + requestId?: string; + }): Promise { + 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 { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d96561d4a..66a775794 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -513,6 +513,14 @@ export class AgentManager { 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 { const agent = this.requireAgent(agentId); if (agent.attention.requiresAttention) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0d3cd9913..6c35c387a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -19,6 +19,8 @@ import { type UnsubscribeTerminalRequest, type TerminalInput, type KillTerminalRequest, + type ProjectCheckoutLitePayload, + type ProjectPlacementPayload, } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js"; @@ -90,6 +92,7 @@ import { import { getCheckoutDiff, getCheckoutStatus, + getCheckoutStatusLite, NotGitRepoError, MergeConflictError, MergeFromBaseConflictError, @@ -119,6 +122,8 @@ const pendingAgentInitializations = new Map>(); let restartRequested = false; const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; 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. @@ -138,6 +143,78 @@ const VOICE_AGENT_SYSTEM_INSTRUCTION = [ "Only use the paseo MCP tools.", ].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 { return value .replace(/&/g, "&") @@ -370,6 +447,10 @@ export class Session { filter?: { labels?: Record; agentId?: string }; } | null = null; + private readonly projectPlacementCache = new Map< + string, + { expiresAt: number; promise: Promise } + >(); private clientActivity: { deviceType: "web" | "mobile"; focusedAgentId: string | null; @@ -825,6 +906,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> + ): 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 { + 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 { + 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 { try { const subscription = this.agentUpdatesSubscription; @@ -836,9 +984,10 @@ export class Session { const matches = this.matchesAgentFilter(payload, subscription.filter); if (matches) { + const project = await this.getProjectPlacement(payload.cwd); this.emit({ type: "agent_update", - payload: { kind: "upsert", agent: payload }, + payload: { kind: "upsert", agent: payload, project }, }); return; } @@ -874,6 +1023,10 @@ export class Session { await this.handleFetchAgents(msg.requestId, msg.filter); break; + case "fetch_agents_grouped_by_project_request": + await this.handleFetchAgentsGroupedByProject(msg.requestId, msg.filter); + break; + case "fetch_agent_request": await this.handleFetchAgent(msg.agentId, msg.requestId); break; @@ -1260,6 +1413,7 @@ export class Session { archivedAt, }); } + this.agentManager.notifyAgentState(agentId); } catch (error: any) { this.sessionLogger.error( { err: error, agentId }, @@ -4087,6 +4241,87 @@ export class Session { } } + private async listAgentsGroupedByProjectPayload(filter?: { + labels?: Record; + }): Promise; + }>> { + 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 } + ): Promise { + 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 { const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 411bc9d19..5fbfa4eef 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -454,6 +454,16 @@ export const FetchAgentsRequestMessageSchema = z.object({ .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({ type: z.literal("fetch_agent_request"), requestId: z.string(), @@ -949,6 +959,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AbortRequestMessageSchema, AudioPlayedMessageSchema, FetchAgentsRequestMessageSchema, + FetchAgentsGroupedByProjectRequestMessageSchema, FetchAgentRequestMessageSchema, SubscribeAgentUpdatesMessageSchema, UnsubscribeAgentUpdatesMessageSchema, @@ -1192,12 +1203,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({ type: z.literal("agent_update"), payload: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("upsert"), agent: AgentSnapshotPayloadSchema, + project: ProjectPlacementPayloadSchema, }), z.object({ kind: z.literal("remove"), @@ -1252,6 +1303,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({ type: z.literal("fetch_agent_response"), payload: z.object({ @@ -1716,6 +1786,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentStreamSnapshotMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, + FetchAgentsGroupedByProjectResponseMessageSchema, FetchAgentResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, @@ -1773,9 +1844,14 @@ export type AgentStreamSnapshotMessage = z.infer< typeof AgentStreamSnapshotMessageSchema >; export type AgentStatusMessage = z.infer; +export type ProjectCheckoutLitePayload = z.infer; +export type ProjectPlacementPayload = z.infer; export type FetchAgentsResponseMessage = z.infer< typeof FetchAgentsResponseMessageSchema >; +export type FetchAgentsGroupedByProjectResponseMessage = z.infer< + typeof FetchAgentsGroupedByProjectResponseMessageSchema +>; export type FetchAgentResponseMessage = z.infer< typeof FetchAgentResponseMessageSchema >; @@ -1804,6 +1880,9 @@ export type ActivityLogPayload = z.infer; // Type exports for inbound message types export type VoiceAudioChunkMessage = z.infer; export type FetchAgentsRequestMessage = z.infer; +export type FetchAgentsGroupedByProjectRequestMessage = z.infer< + typeof FetchAgentsGroupedByProjectRequestMessageSchema +>; export type FetchAgentRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; export type WaitForFinishRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 781f4dedc..139b4eb53 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -7,6 +7,7 @@ import { commitAll, getCheckoutDiff, getCheckoutStatus, + getCheckoutStatusLite, mergeToBase, mergeFromBase, MergeConflictError, @@ -78,6 +79,14 @@ describe("checkout git utilities", () => { 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 () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); @@ -196,6 +205,21 @@ describe("checkout git utilities", () => { 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 () => { const bareRepoDir = join(tempDir, "bare-repo"); execSync(`git clone --bare ${repoDir} ${bareRepoDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 88d53d1c1..81fd6366c 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -296,6 +296,35 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas 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 { diff: string; structured?: ParsedDiffFile[]; @@ -466,6 +495,11 @@ async function getConfiguredBaseRefForCwd( cwd: string, context?: CheckoutContext ): Promise { + // 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 }); if (!ownership.allowed) { return { baseRef: null, isPaseoOwnedWorktree: false }; @@ -636,6 +670,43 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise { + 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 TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024; @@ -762,25 +833,17 @@ export async function getCheckoutStatus( cwd: string, context?: CheckoutContext ): Promise { - let worktreeRoot: string; - try { - const root = await getWorktreeRoot(cwd); - if (!root) { - return { isGit: false }; - } - worktreeRoot = root; - } catch (error) { - if (isGitError(error)) { - return { isGit: false }; - } - throw error; + const inspected = await inspectCheckoutContext(cwd, context); + if (!inspected) { + return { isGit: false }; } - 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 remoteUrl = await getOriginRemoteUrl(cwd); const hasRemote = remoteUrl !== null; - const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); const aheadBehind = 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 { + 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( cwd: string, compare: CheckoutDiffCompare,