Add toast provider, agent details sheet, and remote-based grouping
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await page.getByTestId("agent-menu-details").click();
|
||||
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
||||
"Not available"
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available");
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Platform } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||
import {
|
||||
@@ -338,26 +339,28 @@ export default function RootLayout() {
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="agent" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen
|
||||
name="agent/[serverId]/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
<ToastProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="agent" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen
|
||||
name="agent/[serverId]/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Copy, Info } from "lucide-react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
|
||||
export type AgentDetailsSheetProps = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
agentId: string;
|
||||
persistenceSessionId: string | null;
|
||||
};
|
||||
|
||||
export function AgentDetailsSheet({
|
||||
visible,
|
||||
onClose,
|
||||
agentId,
|
||||
persistenceSessionId,
|
||||
}: AgentDetailsSheetProps) {
|
||||
const toast = useToast();
|
||||
|
||||
const details = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Agent ID",
|
||||
value: agentId,
|
||||
testID: "agent-details-agent-id",
|
||||
copyLabel: "agent id",
|
||||
},
|
||||
{
|
||||
label: "Persistence session ID",
|
||||
value: persistenceSessionId,
|
||||
testID: "agent-details-persistence-session-id",
|
||||
copyLabel: "session id",
|
||||
},
|
||||
],
|
||||
[agentId, persistenceSessionId]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (value: string | null, label: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await Clipboard.setStringAsync(value);
|
||||
toast.copied(label);
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Agent Details"
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="agent-details-sheet"
|
||||
snapPoints={["45%", "70%"]}
|
||||
>
|
||||
{details.map((row) => (
|
||||
<DetailRow
|
||||
key={row.label}
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
onCopy={() => void handleCopy(row.value, row.copyLabel)}
|
||||
testID={row.testID}
|
||||
disabled={!row.value}
|
||||
/>
|
||||
))}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
onCopy,
|
||||
disabled,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
onCopy: () => void;
|
||||
disabled?: boolean;
|
||||
testID: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${label} copy`}
|
||||
accessibilityHint={disabled ? "Not available" : "Copies to clipboard"}
|
||||
disabled={disabled}
|
||||
onPress={onCopy}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
disabled ? styles.rowDisabled : null,
|
||||
pressed && !disabled ? styles.rowPressed : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.rowTop}>
|
||||
<View style={styles.labelRow}>
|
||||
<Info size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
<View style={styles.copyPill}>
|
||||
<Copy size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.copyText}>Copy</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
testID={`${testID}-value`}
|
||||
style={[styles.value, !value ? styles.valueEmpty : null]}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{value ?? "Not available"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingHorizontal: theme.spacing[5],
|
||||
paddingVertical: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowDisabled: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
rowTop: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
flex: 1,
|
||||
},
|
||||
copyPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
copyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
valueEmpty: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: Fonts.sans,
|
||||
},
|
||||
}));
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
@@ -32,6 +32,7 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
type CheckoutStatusPayload,
|
||||
checkoutStatusQueryKey,
|
||||
useCheckoutStatusCacheOnly,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
@@ -210,10 +211,43 @@ export function GroupedAgentList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Subscribe to checkout status cache entries so project grouping can react
|
||||
// to remote URL updates (e.g. git worktrees in different directories).
|
||||
const checkoutCacheQueries = useQueries({
|
||||
queries: agents.map(
|
||||
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
||||
queryKey: checkoutStatusQueryKey(agent.serverId, agent.id),
|
||||
enabled: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
||||
throw new Error("checkout status cache-only query should not run");
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const remoteUrlByAgentKey = useMemo(() => {
|
||||
const result = new Map<string, string | null>();
|
||||
for (let i = 0; i < agents.length; i++) {
|
||||
const agent = agents[i];
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
const checkout = checkoutCacheQueries[i]?.data ?? null;
|
||||
const remoteUrl = checkout?.remoteUrl ?? null;
|
||||
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
|
||||
}
|
||||
return result;
|
||||
}, [agents, checkoutCacheQueries]);
|
||||
|
||||
// Group agents
|
||||
const { activeGroups, inactiveGroups } = useMemo(
|
||||
() => groupAgents(agents),
|
||||
[agents]
|
||||
() =>
|
||||
groupAgents(agents, {
|
||||
getRemoteUrl: (agent) =>
|
||||
remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
|
||||
}),
|
||||
[agents, remoteUrlByAgentKey]
|
||||
);
|
||||
|
||||
// Build sections for SectionList
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react-native";
|
||||
|
||||
type ToastVariant = "default" | "success" | "error";
|
||||
|
||||
export type ToastShowOptions = {
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
/**
|
||||
* On Android we prefer the OS toast by default.
|
||||
* Set to false to force the in-app toast.
|
||||
*/
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
type ToastState = {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastApi = {
|
||||
show: (message: string, options?: ToastShowOptions) => void;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback(
|
||||
(message: string, options?: ToastShowOptions) => {
|
||||
const resolvedMessage = message.trim();
|
||||
if (!resolvedMessage) return;
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const nativeAndroid = options?.nativeAndroid ?? true;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid) {
|
||||
const duration =
|
||||
durationMs <= 2500
|
||||
? ToastAndroid.SHORT
|
||||
: ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(
|
||||
resolvedMessage,
|
||||
duration,
|
||||
ToastAndroid.BOTTOM
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
message: resolvedMessage,
|
||||
variant,
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", { variant: "success" }),
|
||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<ToastViewport toast={toast} onDismiss={() => setToast(null)} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastViewport({
|
||||
toast,
|
||||
onDismiss,
|
||||
}: {
|
||||
toast: ToastState | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateOut = useCallback(() => {
|
||||
clearTimer();
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 8,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
onDismiss();
|
||||
}
|
||||
});
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(8);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(8);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon =
|
||||
toast.variant === "success" ? (
|
||||
<CheckCircle2 size={18} color={theme.colors.primary} />
|
||||
) : toast.variant === "error" ? (
|
||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
style={[
|
||||
styles.toast,
|
||||
{
|
||||
marginBottom: theme.spacing[4] + insets.bottom,
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
<Text
|
||||
testID="app-toast-message"
|
||||
style={[
|
||||
styles.message,
|
||||
toast.variant === "error" ? styles.messageError : null,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{toast.message}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
bottom: 0,
|
||||
zIndex: 1100,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
messageError: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -28,11 +28,13 @@ import {
|
||||
Folder,
|
||||
RotateCcw,
|
||||
PanelRight,
|
||||
Info,
|
||||
} from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
@@ -179,6 +181,7 @@ function AgentScreenContent({
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
@@ -734,6 +737,16 @@ function AgentScreenContent({
|
||||
>
|
||||
{isInitializing ? "Refreshing..." : "Refresh"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
testID="agent-menu-details"
|
||||
leading={<Info size={16} color={theme.colors.foreground} />}
|
||||
onSelect={() => setDetailsOpen(true)}
|
||||
>
|
||||
Details
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
@@ -773,6 +786,13 @@ function AgentScreenContent({
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
<AgentDetailsSheet
|
||||
visible={detailsOpen}
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
agentId={effectiveAgent.id}
|
||||
persistenceSessionId={effectiveAgent.persistence?.sessionId ?? null}
|
||||
/>
|
||||
|
||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveRemoteProjectKey, groupAgents } from "./agent-grouping";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
|
||||
function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: overrides.id ?? "a1",
|
||||
serverId: overrides.serverId ?? "s1",
|
||||
serverLabel: (overrides as any).serverLabel ?? "server",
|
||||
title: overrides.title ?? null,
|
||||
status: overrides.status ?? ("running" as AggregatedAgent["status"]),
|
||||
lastActivityAt: overrides.lastActivityAt ?? now,
|
||||
cwd: overrides.cwd ?? "/tmp/repo",
|
||||
provider: overrides.provider ?? ("openai" as any),
|
||||
requiresAttention: overrides.requiresAttention ?? false,
|
||||
attentionReason: overrides.attentionReason ?? null,
|
||||
attentionTimestamp: overrides.attentionTimestamp ?? null,
|
||||
parentAgentId: overrides.parentAgentId ?? null,
|
||||
} as AggregatedAgent;
|
||||
}
|
||||
|
||||
describe("deriveRemoteProjectKey", () => {
|
||||
it("normalizes GitHub SSH and HTTPS to the same key", () => {
|
||||
const ssh = "git@github.com:owner/repo.git";
|
||||
const https = "https://github.com/owner/repo";
|
||||
expect(deriveRemoteProjectKey(ssh)).toBe("remote:github.com/owner/repo");
|
||||
expect(deriveRemoteProjectKey(https)).toBe("remote:github.com/owner/repo");
|
||||
});
|
||||
|
||||
it("includes host for non-GitHub remotes", () => {
|
||||
const gitlab = "git@gitlab.example.com:group/repo.git";
|
||||
expect(deriveRemoteProjectKey(gitlab)).toBe(
|
||||
"remote:gitlab.example.com/group/repo"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupAgents", () => {
|
||||
it("groups active agents by remote URL when available", () => {
|
||||
const agents = [
|
||||
makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }),
|
||||
makeAgent({ id: "a2", cwd: "/Users/me/dev/paseo-fix/worktree" }),
|
||||
];
|
||||
|
||||
const { activeGroups } = groupAgents(agents, {
|
||||
getRemoteUrl: () => "git@github.com:moboudra/paseo.git",
|
||||
});
|
||||
|
||||
expect(activeGroups).toHaveLength(1);
|
||||
expect(activeGroups[0]?.agents.map((a) => a.id).sort()).toEqual([
|
||||
"a1",
|
||||
"a2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to cwd grouping when remote URL is unavailable", () => {
|
||||
const agents = [
|
||||
makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }),
|
||||
makeAgent({ id: "a2", cwd: "/Users/me/dev/paseo-fix/worktree" }),
|
||||
];
|
||||
|
||||
const { activeGroups } = groupAgents(agents, {
|
||||
getRemoteUrl: () => null,
|
||||
});
|
||||
|
||||
expect(activeGroups).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,71 @@ export function deriveProjectKey(cwd: string): string {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a stable grouping key from a git remote URL.
|
||||
*
|
||||
* Waterfall:
|
||||
* - Prefer a GitHub key (normalizes SSH/HTTPS to the same key).
|
||||
* - Fallback to a generic host/path key (still normalized across SSH/HTTPS).
|
||||
*/
|
||||
export function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Support the common forms:
|
||||
// - git@github.com:owner/repo.git
|
||||
// - https://github.com/owner/repo(.git)
|
||||
// - ssh://git@github.com/owner/repo(.git)
|
||||
let host: string | null = null;
|
||||
let path: string | null = null;
|
||||
|
||||
// SSH scp-like form: user@host:owner/repo(.git)
|
||||
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 {
|
||||
// Fall through to best-effort parsing below.
|
||||
}
|
||||
}
|
||||
|
||||
if (!host || !path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
if (cleanedPath.endsWith(".git")) {
|
||||
cleanedPath = cleanedPath.slice(0, -4);
|
||||
}
|
||||
|
||||
// Best-effort: owner/repo is the common case.
|
||||
// If the path is longer (e.g. groups/subgroups/repo), still keep it.
|
||||
if (!cleanedPath.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedHost = host.toLowerCase();
|
||||
|
||||
// GitHub normalization: treat github.com as a special "well-known" host to
|
||||
// match the intended UX: group by repo even across different local worktrees.
|
||||
if (cleanedHost === "github.com") {
|
||||
return `remote:github.com/${cleanedPath}`;
|
||||
}
|
||||
|
||||
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the repo name from a git remote URL.
|
||||
* Examples:
|
||||
@@ -137,11 +202,22 @@ export interface GroupedAgents {
|
||||
|
||||
const ACTIVE_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface GroupAgentsOptions {
|
||||
/**
|
||||
* Optional function to read a remote URL for an agent.
|
||||
* If present and a remote URL is available, agents are grouped by remote.
|
||||
*/
|
||||
getRemoteUrl?: (agent: AggregatedAgent) => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups agents into active (by project) and inactive (by date) sections.
|
||||
* Active = running, requires attention, or had activity within the last 5 minutes.
|
||||
*/
|
||||
export function groupAgents(agents: AggregatedAgent[]): GroupedAgents {
|
||||
export function groupAgents(
|
||||
agents: AggregatedAgent[],
|
||||
options?: GroupAgentsOptions
|
||||
): GroupedAgents {
|
||||
const activeAgents: AggregatedAgent[] = [];
|
||||
const inactiveAgents: AggregatedAgent[] = [];
|
||||
const now = Date.now();
|
||||
@@ -163,7 +239,10 @@ export function groupAgents(agents: AggregatedAgent[]): GroupedAgents {
|
||||
// Group active agents by project
|
||||
const projectMap = new Map<string, AggregatedAgent[]>();
|
||||
for (const agent of activeAgents) {
|
||||
const projectKey = deriveProjectKey(agent.cwd);
|
||||
const remoteKey = deriveRemoteProjectKey(
|
||||
options?.getRemoteUrl?.(agent) ?? null
|
||||
);
|
||||
const projectKey = remoteKey ?? deriveProjectKey(agent.cwd);
|
||||
const existing = projectMap.get(projectKey) || [];
|
||||
existing.push(agent);
|
||||
projectMap.set(projectKey, existing);
|
||||
|
||||
Reference in New Issue
Block a user