diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 8488d1048..b58850fdd 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -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; 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 ? ( + + ) : isArchiveConfirmVisible ? ( ) : ( >(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 => { + 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[] = []; - 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, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8a218525c..88444fcc4 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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(() => @@ -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, diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index f4a73af78..bbea4aa94 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -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) => { diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts new file mode 100644 index 000000000..e1a37a400 --- /dev/null +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts new file mode 100644 index 000000000..28b3a97cd --- /dev/null +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -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; + +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( + ARCHIVE_AGENT_PENDING_QUERY_KEY + ) ?? {} + ); +} + +function setAgentArchiving(input: SetAgentArchivingInput): void { + const key = toArchiveKey(input); + if (!key) { + return; + } + + input.queryClient.setQueryData( + 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 => ({}), + 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 => { + 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, +}; diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 05c882c73..339cf2046 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -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 = ( - + {/* Header */} {/* Agent Input Area */} - {agent && resolvedAgentId && !shouldBlockForHistorySync && ( + {agent && + resolvedAgentId && + !shouldBlockForHistorySync && + !isArchivingCurrentAgent && ( )} + + {isArchivingCurrentAgent ? ( + + + Archiving agent... + + Please wait while we archive this agent. + + + ) : null} ); @@ -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,