Merge pull request #61 from getpaseo/fix/agent-archive-loading-state

Fix archive UX with shared pending state and agent-screen overlay
This commit is contained in:
Mohamed Boudra
2026-02-18 18:27:03 +07:00
committed by GitHub
6 changed files with 329 additions and 37 deletions
@@ -4,6 +4,7 @@ import {
Pressable, Pressable,
Image, Image,
Platform, Platform,
ActivityIndicator,
} from "react-native"; } from "react-native";
import { useQueries } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
import { import {
@@ -49,6 +50,7 @@ import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping"; import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
type EntryData = SidebarAgentListEntry; type EntryData = SidebarAgentListEntry;
@@ -127,10 +129,11 @@ interface SidebarAgentRowProps {
isSelected: boolean; isSelected: boolean;
isInSelectionMode: boolean; isInSelectionMode: boolean;
isBatchSelected: boolean; isBatchSelected: boolean;
isArchiving: boolean;
shortcutNumber: number | null; shortcutNumber: number | null;
onPress: () => void; onPress: () => void;
onLongPress: () => void; onLongPress: () => void;
onArchive: () => void; onArchive: () => Promise<void>;
onToggleBatch: () => void; onToggleBatch: () => void;
} }
@@ -153,6 +156,7 @@ function SidebarAgentRow({
isSelected, isSelected,
isInSelectionMode, isInSelectionMode,
isBatchSelected, isBatchSelected,
isArchiving,
shortcutNumber, shortcutNumber,
onPress, onPress,
onLongPress, onLongPress,
@@ -176,7 +180,7 @@ function SidebarAgentRow({
const showArchive = const showArchive =
!isInSelectionMode && !isInSelectionMode &&
shortcutNumber === null && shortcutNumber === null &&
(isHovered || isArchiveHovered || isArchiveConfirmVisible); (isHovered || isArchiveHovered || isArchiveConfirmVisible || isArchiving);
const clearHoverOutTimeout = useCallback(() => { const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) { if (!hoverOutTimeoutRef.current) {
@@ -190,6 +194,12 @@ function SidebarAgentRow({
return () => clearHoverOutTimeout(); return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]); }, [clearHoverOutTimeout]);
useEffect(() => {
if (!isArchiving) {
setIsArchiveConfirmVisible(false);
}
}, [isArchiving]);
const handleHoverIn = useCallback(() => { const handleHoverIn = useCallback(() => {
clearHoverOutTimeout(); clearHoverOutTimeout();
setIsHovered(true); setIsHovered(true);
@@ -270,22 +280,27 @@ function SidebarAgentRow({
}} }}
onPress={(event) => { onPress={(event) => {
event.stopPropagation(); event.stopPropagation();
if (isArchiving) {
return;
}
if (!isArchiveConfirmVisible) { if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true); setIsArchiveConfirmVisible(true);
return; return;
} }
onArchive(); void onArchive();
setIsArchiveConfirmVisible(false);
}} }}
style={styles.archiveButton} style={styles.archiveButton}
disabled={isArchiving}
testID={ testID={
isArchiveConfirmVisible isArchiveConfirmVisible || isArchiving
? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}` ? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}`
: `agent-archive-${entry.agent.serverId}-${entry.agent.id}` : `agent-archive-${entry.agent.serverId}-${entry.agent.id}`
} }
> >
{({ hovered: archiveHovered }) => {({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? ( isArchiving ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : isArchiveConfirmVisible ? (
<Check size={12} color={theme.colors.foreground} /> <Check size={12} color={theme.colors.foreground} />
) : ( ) : (
<Archive <Archive
@@ -446,6 +461,7 @@ export function SidebarAgentList({
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set()); const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set());
const { archiveAgent, isArchivingAgent } = useArchiveAgent();
const altDown = useKeyboardShortcutsStore((s) => s.altDown); const altDown = useKeyboardShortcutsStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown); const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
@@ -621,15 +637,17 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set([key])); setSelectedBatchKeys(new Set([key]));
}, []); }, []);
const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => { const handleArchiveSingle = useCallback(
const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null; async (entry: SidebarAgentListEntry): Promise<void> => {
if (!client) { await archiveAgent({
return; serverId: entry.agent.serverId,
} agentId: entry.agent.id,
void client.archiveAgent(entry.agent.id).catch((error) => { }).catch((error) => {
console.warn("[archive_agent] failed", error); console.warn("[archive_agent] failed", error);
}); });
}, []); },
[archiveAgent]
);
const handleArchiveBatch = useCallback(() => { const handleArchiveBatch = useCallback(() => {
if (selectedBatchKeys.size === 0) { if (selectedBatchKeys.size === 0) {
@@ -638,23 +656,18 @@ export function SidebarAgentList({
} }
const requests: Promise<void>[] = []; const requests: Promise<void>[] = [];
const store = useSessionStore.getState();
for (const key of selectedBatchKeys) { for (const key of selectedBatchKeys) {
const parsed = parseSidebarAgentKey(key); const parsed = parseSidebarAgentKey(key);
if (!parsed) { if (!parsed) {
continue; continue;
} }
const client = store.sessions[parsed.serverId]?.client ?? null;
if (!client) {
continue;
}
requests.push( requests.push(
client archiveAgent({
.archiveAgent(parsed.agentId) serverId: parsed.serverId,
.then(() => undefined) agentId: parsed.agentId,
.catch((error) => { }).catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error }); console.warn("[archive_agent_batch] failed", { key, error });
}) })
); );
} }
@@ -662,7 +675,7 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set()); setSelectedBatchKeys(new Set());
setIsSelectionMode(false); setIsSelectionMode(false);
}); });
}, [selectedBatchKeys]); }, [archiveAgent, selectedBatchKeys]);
const handleSelectionBack = useCallback(() => { const handleSelectionBack = useCallback(() => {
setSelectedBatchKeys(new Set()); setSelectedBatchKeys(new Set());
@@ -687,6 +700,10 @@ export function SidebarAgentList({
isSelected={selectedAgentId === key} isSelected={selectedAgentId === key}
isInSelectionMode={isSelectionMode} isInSelectionMode={isSelectionMode}
isBatchSelected={selectedBatchKeys.has(key)} isBatchSelected={selectedBatchKeys.has(key)}
isArchiving={isArchivingAgent({
serverId: item.agent.serverId,
agentId: item.agent.id,
})}
shortcutNumber={ shortcutNumber={
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
} }
@@ -711,6 +728,7 @@ export function SidebarAgentList({
handleAgentLongPress, handleAgentLongPress,
handleAgentPress, handleAgentPress,
handleArchiveSingle, handleArchiveSingle,
isArchivingAgent,
isSelectionMode, isSelectionMode,
selectedAgentId, selectedAgentId,
selectedBatchKeys, selectedBatchKeys,
+16 -1
View File
@@ -1,10 +1,11 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native"; import { AppState, Platform } from "react-native";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client"; import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { import {
applyStreamEvent, applyStreamEvent,
generateMessageId, generateMessageId,
@@ -219,6 +220,7 @@ export function SessionProvider({
activeConnection, activeConnection,
daemonPublicKeyB64, daemonPublicKeyB64,
}: SessionProviderProps) { }: SessionProviderProps) {
const queryClient = useQueryClient();
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 }); const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
const [connectionSnapshot, setConnectionSnapshot] = const [connectionSnapshot, setConnectionSnapshot] =
useState<DaemonConnectionSnapshot>(() => useState<DaemonConnectionSnapshot>(() =>
@@ -489,6 +491,7 @@ export function SessionProvider({
const agentId = update.agentId; const agentId = update.agentId;
previousAgentStatusRef.current.delete(agentId); previousAgentStatusRef.current.delete(agentId);
pendingAgentUpdatesRef.current.delete(agentId); pendingAgentUpdatesRef.current.delete(agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => { setAgents(serverId, (prev) => {
if (!prev.has(agentId)) { if (!prev.has(agentId)) {
@@ -552,6 +555,14 @@ export function SessionProvider({
return next; return next;
}); });
if (agent.archivedAt) {
clearArchiveAgentPending({
queryClient,
serverId,
agentId: agent.id,
});
}
// Update agentLastActivity slice (top-level) // Update agentLastActivity slice (top-level)
setAgentLastActivity(agent.id, agent.lastActivityAt); setAgentLastActivity(agent.id, agent.lastActivityAt);
@@ -595,6 +606,7 @@ export function SessionProvider({
previousAgentStatusRef.current.set(agent.id, agent.status); previousAgentStatusRef.current.set(agent.id, agent.status);
}, },
[ [
queryClient,
serverId, serverId,
setAgents, setAgents,
setAgentLastActivity, setAgentLastActivity,
@@ -1277,6 +1289,7 @@ export function SessionProvider({
const { agentId } = message.payload; const { agentId } = message.payload;
console.log("[Session] Agent deleted:", agentId); console.log("[Session] Agent deleted:", agentId);
pendingAgentUpdatesRef.current.delete(agentId); pendingAgentUpdatesRef.current.delete(agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => { setAgents(serverId, (prev) => {
if (!prev.has(agentId)) { if (!prev.has(agentId)) {
@@ -1358,6 +1371,7 @@ export function SessionProvider({
} }
const { agentId, archivedAt } = message.payload; const { agentId, archivedAt } = message.payload;
console.log("[Session] Agent archived:", agentId); console.log("[Session] Agent archived:", agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => { setAgents(serverId, (prev) => {
const existing = prev.get(agentId); const existing = prev.get(agentId);
@@ -1390,6 +1404,7 @@ export function SessionProvider({
}, [ }, [
client, client,
audioPlayer, audioPlayer,
queryClient,
serverId, serverId,
setIsPlayingAudio, setIsPlayingAudio,
setMessages, setMessages,
@@ -86,13 +86,15 @@ export function useAllAgentsList(options?: {
const snapshot = entry.agent; const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId); const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id); const live = liveAgents?.get(snapshot.id);
list.push( const aggregated = toAggregatedAgent({
toAggregatedAgent({ source: live ?? normalized,
source: live ?? normalized, serverId,
serverId, serverLabel,
serverLabel, });
}) if (aggregated.archivedAt) {
); continue;
}
list.push(aggregated);
} }
list.sort((left, right) => { list.sort((left, right) => {
@@ -0,0 +1,54 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-archive-agent";
describe("useArchiveAgent", () => {
it("tracks pending archive state in shared react-query cache", () => {
const queryClient = new QueryClient();
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
).toBe(false);
__private__.setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
isArchiving: true,
});
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
).toBe(true);
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-2",
})
).toBe(false);
__private__.setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
isArchiving: false,
});
expect(
__private__.isAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
})
).toBe(false);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
export interface ArchiveAgentInput {
serverId: string;
agentId: string;
}
type ArchiveAgentPendingState = Record<string, true>;
interface SetAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient;
isArchiving: boolean;
}
interface IsAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient;
}
function toArchiveKey(input: ArchiveAgentInput): string {
const serverId = input.serverId.trim();
const agentId = input.agentId.trim();
if (!serverId || !agentId) {
return "";
}
return `${serverId}:${agentId}`;
}
function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState {
return (
queryClient.getQueryData<ArchiveAgentPendingState>(
ARCHIVE_AGENT_PENDING_QUERY_KEY
) ?? {}
);
}
function setAgentArchiving(input: SetAgentArchivingInput): void {
const key = toArchiveKey(input);
if (!key) {
return;
}
input.queryClient.setQueryData<ArchiveAgentPendingState>(
ARCHIVE_AGENT_PENDING_QUERY_KEY,
(current) => {
const state = current ?? {};
if (input.isArchiving) {
if (state[key]) {
return state;
}
return { ...state, [key]: true };
}
if (!state[key]) {
return state;
}
const next = { ...state };
delete next[key];
return next;
}
);
}
function isAgentArchiving(input: IsAgentArchivingInput): boolean {
const key = toArchiveKey(input);
if (!key) {
return false;
}
return Boolean(readPendingState(input.queryClient)[key]);
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({
...input,
isArchiving: false,
});
}
export function useArchiveAgent() {
const queryClient = useQueryClient();
const pendingQuery = useQuery({
queryKey: ARCHIVE_AGENT_PENDING_QUERY_KEY,
queryFn: async (): Promise<ArchiveAgentPendingState> => ({}),
initialData: {} as ArchiveAgentPendingState,
staleTime: Infinity,
gcTime: Infinity,
});
const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput) => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
if (!client) {
throw new Error("Daemon client not available");
}
await client.archiveAgent(input.agentId);
},
onMutate: (input) => {
setAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
isArchiving: true,
});
},
onError: (_error, input) => {
clearArchiveAgentPending({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
});
},
});
const archiveAgent = useCallback(
async (input: ArchiveAgentInput): Promise<void> => {
if (
isAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
})
) {
return;
}
await archiveMutation.mutateAsync(input);
},
[archiveMutation, queryClient]
);
const isArchivingAgent = useCallback(
(input: ArchiveAgentInput): boolean => {
const key = toArchiveKey(input);
if (!key) {
return false;
}
return Boolean((pendingQuery.data ?? {})[key]);
},
[pendingQuery.data]
);
return {
archiveAgent,
isArchivingAgent,
};
}
export const __private__ = {
toArchiveKey,
readPendingState,
setAgentArchiving,
isAgentArchiving,
};
@@ -66,6 +66,7 @@ import {
useCheckoutStatusQuery, useCheckoutStatusQuery,
} from "@/hooks/use-checkout-status-query"; } from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { import {
@@ -221,6 +222,7 @@ function AgentScreenContent({
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const resolvedAgentId = agentId; const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
@@ -605,6 +607,10 @@ function AgentScreenContent({
}, [showConnectedNotice]); }, [showConnectedNotice]);
const isGitCheckout = activeExplorerCheckout?.isGit ?? false; const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
const isArchivingCurrentAgent = Boolean(
resolvedAgentId &&
isArchivingAgent({ serverId, agentId: resolvedAgentId })
);
useEffect(() => { useEffect(() => {
if (!resolvedAgentId) { if (!resolvedAgentId) {
@@ -985,7 +991,10 @@ function AgentScreenContent({
const mainContent = ( const mainContent = (
<View style={styles.outerContainer}> <View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing || shouldBlockForHistorySync}> <FileDropZone
onFilesDropped={handleFilesDropped}
disabled={isInitializing || shouldBlockForHistorySync || isArchivingCurrentAgent}
>
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<MenuHeader <MenuHeader
@@ -1247,7 +1256,10 @@ function AgentScreenContent({
</View> </View>
{/* Agent Input Area */} {/* Agent Input Area */}
{agent && resolvedAgentId && !shouldBlockForHistorySync && ( {agent &&
resolvedAgentId &&
!shouldBlockForHistorySync &&
!isArchivingCurrentAgent && (
<AgentInputArea <AgentInputArea
agentId={resolvedAgentId} agentId={resolvedAgentId}
serverId={serverId} serverId={serverId}
@@ -1268,6 +1280,16 @@ function AgentScreenContent({
isGit={isGitCheckout} isGit={isGitCheckout}
/> />
)} )}
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>
Please wait while we archive this agent.
</Text>
</View>
) : null}
</View> </View>
); );
@@ -1429,6 +1451,30 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center", justifyContent: "center",
gap: 16, gap: 16,
}, },
archivingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: "rgba(8, 10, 14, 0.86)",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[8],
gap: theme.spacing[3],
zIndex: 50,
},
archivingTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
archivingSubtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
loadingText: { loadingText: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,