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,
Image,
Platform,
ActivityIndicator,
} from "react-native";
import { useQueries } from "@tanstack/react-query";
import {
@@ -49,6 +50,7 @@ import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping";
import { formatTimeAgo } from "@/utils/time";
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
type EntryData = SidebarAgentListEntry;
@@ -127,10 +129,11 @@ interface SidebarAgentRowProps {
isSelected: boolean;
isInSelectionMode: boolean;
isBatchSelected: boolean;
isArchiving: boolean;
shortcutNumber: number | null;
onPress: () => void;
onLongPress: () => void;
onArchive: () => void;
onArchive: () => Promise<void>;
onToggleBatch: () => void;
}
@@ -153,6 +156,7 @@ function SidebarAgentRow({
isSelected,
isInSelectionMode,
isBatchSelected,
isArchiving,
shortcutNumber,
onPress,
onLongPress,
@@ -176,7 +180,7 @@ function SidebarAgentRow({
const showArchive =
!isInSelectionMode &&
shortcutNumber === null &&
(isHovered || isArchiveHovered || isArchiveConfirmVisible);
(isHovered || isArchiveHovered || isArchiveConfirmVisible || isArchiving);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
@@ -190,6 +194,12 @@ function SidebarAgentRow({
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
useEffect(() => {
if (!isArchiving) {
setIsArchiveConfirmVisible(false);
}
}, [isArchiving]);
const handleHoverIn = useCallback(() => {
clearHoverOutTimeout();
setIsHovered(true);
@@ -270,22 +280,27 @@ function SidebarAgentRow({
}}
onPress={(event) => {
event.stopPropagation();
if (isArchiving) {
return;
}
if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true);
return;
}
onArchive();
setIsArchiveConfirmVisible(false);
void onArchive();
}}
style={styles.archiveButton}
disabled={isArchiving}
testID={
isArchiveConfirmVisible
isArchiveConfirmVisible || isArchiving
? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}`
: `agent-archive-${entry.agent.serverId}-${entry.agent.id}`
}
>
{({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? (
isArchiving ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : isArchiveConfirmVisible ? (
<Check size={12} color={theme.colors.foreground} />
) : (
<Archive
@@ -446,6 +461,7 @@ export function SidebarAgentList({
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedBatchKeys, setSelectedBatchKeys] = useState<Set<string>>(new Set());
const { archiveAgent, isArchivingAgent } = useArchiveAgent();
const altDown = useKeyboardShortcutsStore((s) => s.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown);
@@ -621,15 +637,17 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set([key]));
}, []);
const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => {
const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null;
if (!client) {
return;
}
void client.archiveAgent(entry.agent.id).catch((error) => {
console.warn("[archive_agent] failed", error);
});
}, []);
const handleArchiveSingle = useCallback(
async (entry: SidebarAgentListEntry): Promise<void> => {
await archiveAgent({
serverId: entry.agent.serverId,
agentId: entry.agent.id,
}).catch((error) => {
console.warn("[archive_agent] failed", error);
});
},
[archiveAgent]
);
const handleArchiveBatch = useCallback(() => {
if (selectedBatchKeys.size === 0) {
@@ -638,23 +656,18 @@ export function SidebarAgentList({
}
const requests: Promise<void>[] = [];
const store = useSessionStore.getState();
for (const key of selectedBatchKeys) {
const parsed = parseSidebarAgentKey(key);
if (!parsed) {
continue;
}
const client = store.sessions[parsed.serverId]?.client ?? null;
if (!client) {
continue;
}
requests.push(
client
.archiveAgent(parsed.agentId)
.then(() => undefined)
.catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error });
})
archiveAgent({
serverId: parsed.serverId,
agentId: parsed.agentId,
}).catch((error) => {
console.warn("[archive_agent_batch] failed", { key, error });
})
);
}
@@ -662,7 +675,7 @@ export function SidebarAgentList({
setSelectedBatchKeys(new Set());
setIsSelectionMode(false);
});
}, [selectedBatchKeys]);
}, [archiveAgent, selectedBatchKeys]);
const handleSelectionBack = useCallback(() => {
setSelectedBatchKeys(new Set());
@@ -687,6 +700,10 @@ export function SidebarAgentList({
isSelected={selectedAgentId === key}
isInSelectionMode={isSelectionMode}
isBatchSelected={selectedBatchKeys.has(key)}
isArchiving={isArchivingAgent({
serverId: item.agent.serverId,
agentId: item.agent.id,
})}
shortcutNumber={
showShortcutBadges ? (shortcutIndexByAgentKey.get(key) ?? null) : null
}
@@ -711,6 +728,7 @@ export function SidebarAgentList({
handleAgentLongPress,
handleAgentPress,
handleArchiveSingle,
isArchivingAgent,
isSelectionMode,
selectedAgentId,
selectedBatchKeys,
+16 -1
View File
@@ -1,10 +1,11 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
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 { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import {
applyStreamEvent,
generateMessageId,
@@ -219,6 +220,7 @@ export function SessionProvider({
activeConnection,
daemonPublicKeyB64,
}: SessionProviderProps) {
const queryClient = useQueryClient();
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
const [connectionSnapshot, setConnectionSnapshot] =
useState<DaemonConnectionSnapshot>(() =>
@@ -489,6 +491,7 @@ export function SessionProvider({
const agentId = update.agentId;
previousAgentStatusRef.current.delete(agentId);
pendingAgentUpdatesRef.current.delete(agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
@@ -552,6 +555,14 @@ export function SessionProvider({
return next;
});
if (agent.archivedAt) {
clearArchiveAgentPending({
queryClient,
serverId,
agentId: agent.id,
});
}
// Update agentLastActivity slice (top-level)
setAgentLastActivity(agent.id, agent.lastActivityAt);
@@ -595,6 +606,7 @@ export function SessionProvider({
previousAgentStatusRef.current.set(agent.id, agent.status);
},
[
queryClient,
serverId,
setAgents,
setAgentLastActivity,
@@ -1277,6 +1289,7 @@ export function SessionProvider({
const { agentId } = message.payload;
console.log("[Session] Agent deleted:", agentId);
pendingAgentUpdatesRef.current.delete(agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
@@ -1358,6 +1371,7 @@ export function SessionProvider({
}
const { agentId, archivedAt } = message.payload;
console.log("[Session] Agent archived:", agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
const existing = prev.get(agentId);
@@ -1390,6 +1404,7 @@ export function SessionProvider({
}, [
client,
audioPlayer,
queryClient,
serverId,
setIsPlayingAudio,
setMessages,
@@ -86,13 +86,15 @@ export function useAllAgentsList(options?: {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
list.push(
toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
})
);
const aggregated = toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
});
if (aggregated.archivedAt) {
continue;
}
list.push(aggregated);
}
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,
} from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useToast } from "@/contexts/toast-context";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import {
@@ -221,6 +222,7 @@ function AgentScreenContent({
const router = useRouter();
const queryClient = useQueryClient();
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
@@ -605,6 +607,10 @@ function AgentScreenContent({
}, [showConnectedNotice]);
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
const isArchivingCurrentAgent = Boolean(
resolvedAgentId &&
isArchivingAgent({ serverId, agentId: resolvedAgentId })
);
useEffect(() => {
if (!resolvedAgentId) {
@@ -985,7 +991,10 @@ function AgentScreenContent({
const mainContent = (
<View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing || shouldBlockForHistorySync}>
<FileDropZone
onFilesDropped={handleFilesDropped}
disabled={isInitializing || shouldBlockForHistorySync || isArchivingCurrentAgent}
>
<View style={styles.container}>
{/* Header */}
<MenuHeader
@@ -1247,7 +1256,10 @@ function AgentScreenContent({
</View>
{/* Agent Input Area */}
{agent && resolvedAgentId && !shouldBlockForHistorySync && (
{agent &&
resolvedAgentId &&
!shouldBlockForHistorySync &&
!isArchivingCurrentAgent && (
<AgentInputArea
agentId={resolvedAgentId}
serverId={serverId}
@@ -1268,6 +1280,16 @@ function AgentScreenContent({
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>
);
@@ -1429,6 +1451,30 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
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: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,