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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user