Merge branch 'enhance-agent-fetch-sidebar'

This commit is contained in:
Mohamed Boudra
2026-02-09 15:45:33 +07:00
13 changed files with 1063 additions and 117 deletions

View File

@@ -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,
},
}));

View File

@@ -19,7 +19,6 @@ import {
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import { useQueryClient } from "@tanstack/react-query";
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
import {
DraggableList,
@@ -28,11 +27,6 @@ import {
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
useCheckoutStatusCacheOnly,
} from "@/hooks/use-checkout-status-query";
import {
buildAgentNavigationKey,
startNavigationTiming,
@@ -41,7 +35,10 @@ import {
useSectionOrderStore,
} from "@/stores/section-order-store";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useSidebarAgentSections, type SidebarSectionData } from "@/hooks/use-sidebar-agent-sections";
import {
type SidebarCheckoutLite,
type SidebarSectionData,
} from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { getIsTauri } from "@/constants/layout";
@@ -49,8 +46,9 @@ import { AgentStatusDot } from "@/components/agent-status-dot";
type SectionData = SidebarSectionData;
interface GroupedAgentListProps {
agents: AggregatedAgent[];
interface SidebarAgentListProps {
sections: SidebarSectionData[];
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
@@ -62,6 +60,7 @@ interface GroupedAgentListProps {
interface SectionHeaderProps {
section: SectionData;
checkout: SidebarCheckoutLite | null;
isCollapsed: boolean;
onToggle: () => void;
onCreateAgent: (workingDir: string) => void;
@@ -71,6 +70,7 @@ interface SectionHeaderProps {
function SectionHeader({
section,
checkout,
isCollapsed,
onToggle,
onCreateAgent,
@@ -80,13 +80,6 @@ function SectionHeader({
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
// For project sections, try to get repo name from checkout status
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: section.firstAgentServerId ?? "",
cwd: section.workingDir ?? "",
});
const checkout = checkoutQuery.data ?? null;
// Get project icon
const iconQuery = useProjectIconQuery({
serverId: section.firstAgentServerId ?? "",
@@ -185,8 +178,9 @@ function SectionHeader({
);
}
interface GroupedAgentRowProps {
interface SidebarAgentRowProps {
agent: AggregatedAgent;
checkout: SidebarCheckoutLite | null;
isSelected: boolean;
shortcutNumber: number | null;
onPress: () => void;
@@ -194,14 +188,15 @@ interface GroupedAgentRowProps {
onArchive: (e: { stopPropagation: () => void }) => void;
}
function GroupedAgentRow({
function SidebarAgentRow({
agent,
checkout,
isSelected,
shortcutNumber,
onPress,
onLongPress,
onArchive,
}: GroupedAgentRowProps) {
}: SidebarAgentRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const [isArchiveHovered, setIsArchiveHovered] = useState(false);
@@ -233,17 +228,10 @@ function GroupedAgentRow({
setIsArchiveConfirmVisible(false);
}, 50);
}, [clearHoverOutTimeout]);
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null;
@@ -355,18 +343,18 @@ function GroupedAgentRow({
);
}
export function GroupedAgentList({
agents,
export function SidebarAgentList({
sections,
checkoutByAgentKey,
isRefreshing = false,
onRefresh,
selectedAgentId,
onAgentSelect,
listFooterComponent,
parentGestureRef,
}: GroupedAgentListProps) {
}: SidebarAgentListProps) {
const { theme } = useUnistyles();
const pathname = usePathname();
const queryClient = useQueryClient();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
@@ -448,43 +436,9 @@ export function GroupedAgentList({
[onAgentSelect]
);
// Prefetch checkout status for all agents in the sidebar.
// The sidebar shows a limited number of agents, so we fetch all of them upfront
// to ensure project grouping (by remote URL) is stable from the start.
useEffect(() => {
for (const agent of agents) {
const session = useSessionStore.getState().sessions[agent.serverId];
const client = session?.client ?? null;
const isConnected = session?.connection.isConnected ?? false;
if (!client || !isConnected) {
continue;
}
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
const queryState = queryClient.getQueryState(queryKey);
const isFetching = queryState?.fetchStatus === "fetching";
const isFresh =
typeof queryState?.dataUpdatedAt === "number" &&
Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME;
if (isFetching || isFresh) {
continue;
}
void queryClient.prefetchQuery({
queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
staleTime: CHECKOUT_STATUS_STALE_TIME,
}).catch((error) => {
console.warn("[checkout_status] prefetch failed", error);
});
}
}, [agents, queryClient]);
// Section order from store
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const sections: SectionData[] = useSidebarAgentSections(agents);
const handleDragEnd = useCallback(
(newData: SectionData[]) => {
const newOrder = newData.map((section) => section.projectKey);
@@ -510,11 +464,19 @@ export function GroupedAgentList({
const renderSection = useCallback(
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
const firstAgent = section.agents[0];
const firstAgentKey = firstAgent
? `${firstAgent.serverId}:${firstAgent.id}`
: null;
const firstAgentCheckout = firstAgentKey
? (checkoutByAgentKey.get(firstAgentKey) ?? null)
: null;
return (
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
<SectionHeader
section={section}
checkout={firstAgentCheckout}
isCollapsed={isCollapsed}
onToggle={() => toggleProjectCollapsed(section.projectKey)}
onCreateAgent={handleCreateAgentInProject}
@@ -523,9 +485,12 @@ export function GroupedAgentList({
/>
{!isCollapsed &&
section.agents.map((agent) => (
<GroupedAgentRow
<SidebarAgentRow
key={`${agent.serverId}:${agent.id}`}
agent={agent}
checkout={
checkoutByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null
}
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
shortcutNumber={
showShortcutBadges
@@ -546,6 +511,7 @@ export function GroupedAgentList({
handleAgentPress,
handleArchiveAgent,
handleCreateAgentInProject,
checkoutByAgentKey,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,

View File

@@ -12,17 +12,16 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { Plus, Settings, Users } from "lucide-react-native";
import { router } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { GroupedAgentList } from "./grouped-agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 50; // temporary for screenshots
interface SlidingSidebarProps {
selectedAgentId?: string;
@@ -40,7 +39,13 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
const {
sections,
checkoutByAgentKey,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({ isOpen });
const {
translateX,
backdropOpacity,
@@ -68,21 +73,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}
}, [isRevalidating, isManualRefresh]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => {
if (a.requiresAttention && !b.requiresAttention) return -1;
if (!a.requiresAttention && b.requiresAttention) return 1;
return 0;
});
}, [agents]);
// Pass all agents to grouping, limit is applied inside groupAgents per-project
const sidebarSections = useSidebarAgentSections(sortedAgents);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
const sidebarShortcutAgentKeys = useMemo(() => {
return deriveSidebarShortcutAgentKeys(sidebarSections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sidebarSections]);
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sections]);
useEffect(() => {
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
@@ -226,14 +221,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</View>
{/* Middle: scrollable agent list */}
<GroupedAgentList
agents={sortedAgents}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
@@ -301,12 +301,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</View>
{/* Middle: scrollable agent list */}
<GroupedAgentList
agents={sortedAgents}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>

View File

@@ -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);

View File

@@ -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,
};
}

View File

@@ -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<string, string>;
projectPlacement?: ProjectPlacementPayload | null;
}
export type ExplorerEntryKind = "file" | "directory";

View File

@@ -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 () => {
vi.useFakeTimers();
const logger = createMockLogger();

View File

@@ -117,9 +117,7 @@ export type DaemonEvent =
| {
type: "agent_update";
agentId: string;
payload:
| { kind: "upsert"; agent: AgentSnapshotPayload }
| { kind: "remove"; agentId: string };
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["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<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> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({

View File

@@ -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<void> {
const agent = this.requireAgent(agentId);
if (agent.attention.requiresAttention) {

View File

@@ -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<string, Promise<ManagedAgent>>();
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, "&amp;")
@@ -370,6 +447,10 @@ export class Session {
filter?: { labels?: Record<string, string>; agentId?: string };
}
| null = null;
private readonly projectPlacementCache = new Map<
string,
{ expiresAt: number; promise: Promise<ProjectPlacementPayload> }
>();
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<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> {
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<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> {
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
if (!resolved.ok) {

View File

@@ -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<typeof AgentStatusMessageSchema>;
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
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<typeof ActivityLogPayloadSchema>;
// Type exports for inbound message types
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
export type FetchAgentsGroupedByProjectRequestMessage = z.infer<
typeof FetchAgentsGroupedByProjectRequestMessageSchema
>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;

View File

@@ -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}`);

View File

@@ -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<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 });
if (!ownership.allowed) {
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 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<CheckoutStatusResult> {
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<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(
cwd: string,
compare: CheckoutDiffCompare,