From 6fe1c9a3386f191d6d194986f66a9d5920edfc74 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Feb 2026 16:50:25 +0700 Subject: [PATCH] fix: gate agent not-found on initialize and load all-agents via RPC --- packages/app/src/app/agents.tsx | 5 +- packages/app/src/components/agent-list.tsx | 19 ++- packages/app/src/hooks/use-all-agents-list.ts | 159 ++++++++++++++++++ .../src/screens/agent/agent-ready-screen.tsx | 102 ++++++++++- 4 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/hooks/use-all-agents-list.ts diff --git a/packages/app/src/app/agents.tsx b/packages/app/src/app/agents.tsx index 3a209135a..4b803c9fe 100644 --- a/packages/app/src/app/agents.tsx +++ b/packages/app/src/app/agents.tsx @@ -3,10 +3,10 @@ import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; import { AgentList } from "@/components/agent-list"; -import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; +import { useAllAgentsList } from "@/hooks/use-all-agents-list"; export default function AgentsScreen() { - const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); + const { agents, isRevalidating, refreshAll } = useAllAgentsList(); // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -36,6 +36,7 @@ export default function AgentsScreen() { diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 7fe680f65..963c28fa4 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -31,6 +31,7 @@ import { interface AgentListProps { agents: AggregatedAgent[]; + showCheckoutInfo?: boolean; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; @@ -74,6 +75,7 @@ function deriveDateSectionLabel(lastActivityAt: Date): string { export function AgentList({ agents, + showCheckoutInfo = true, isRefreshing = false, onRefresh, selectedAgentId, @@ -143,8 +145,11 @@ export function AgentList({ [] ); - const onViewableItemsChanged = useRef( + const onViewableItemsChanged = useCallback( ({ viewableItems }: { viewableItems: Array }) => { + if (!showCheckoutInfo) { + return; + } for (const token of viewableItems) { const agent = token.item as AggregatedAgent | undefined; if (!agent) { @@ -176,7 +181,8 @@ export function AgentList({ console.warn("[checkout_status] prefetch failed", error); }); } - } + }, + [queryClient, showCheckoutInfo] ); const AgentListRow = useCallback( @@ -190,8 +196,10 @@ export function AgentList({ cwd: agent.cwd, }); const checkout = checkoutQuery.data ?? null; - const projectPath = deriveProjectPath(agent.cwd, checkout); - const branchLabel = deriveBranchLabel(checkout); + const projectPath = showCheckoutInfo + ? deriveProjectPath(agent.cwd, checkout) + : agent.cwd; + const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null; return ( ; + serverId: string; + serverLabel: string; +}): AggregatedAgent { + const source = params.source; + return { + id: source.id, + serverId: params.serverId, + serverLabel: params.serverLabel, + title: source.title ?? null, + status: source.status, + lastActivityAt: source.lastActivityAt, + cwd: source.cwd, + provider: source.provider, + requiresAttention: source.requiresAttention, + attentionReason: source.attentionReason, + attentionTimestamp: source.attentionTimestamp ?? null, + archivedAt: source.archivedAt ?? null, + labels: source.labels, + }; +} + +export function useAllAgentsList(): AggregatedAgentsResult { + const { connectionStates } = useDaemonConnections(); + const queryClient = useQueryClient(); + + const sessionClients = useSessionStore( + useShallow((state) => { + const result: Record< + string, + NonNullable | null + > = {}; + for (const [serverId, session] of Object.entries(state.sessions)) { + result[serverId] = session.client ?? null; + } + return result; + }) + ); + + const sessionConnections = useSessionStore( + useShallow((state) => { + const result: Record = {}; + for (const [serverId, session] of Object.entries(state.sessions)) { + result[serverId] = session.connection.isConnected; + } + return result; + }) + ); + + const liveAgents = useSessionStore( + useShallow((state) => { + const result: Record | undefined> = {}; + for (const [serverId, session] of Object.entries(state.sessions)) { + result[serverId] = session.agents; + } + return result; + }) + ); + + const serverEntries = useMemo( + () => + Object.keys(sessionClients).map((serverId) => ({ + serverId, + client: sessionClients[serverId] ?? null, + isConnected: sessionConnections[serverId] ?? false, + })), + [sessionClients, sessionConnections] + ); + + const queries = useQueries({ + queries: serverEntries.map(({ serverId, client, isConnected }) => ({ + queryKey: ["allAgents", serverId] as const, + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); + } + return await client.fetchAgents(); + }, + enabled: Boolean(client) && isConnected, + staleTime: ALL_AGENTS_STALE_TIME, + refetchOnMount: "always" as const, + })), + }); + + const refreshAll = useCallback(() => { + for (const { serverId } of serverEntries) { + void queryClient.invalidateQueries({ + queryKey: ["allAgents", serverId], + }); + } + }, [queryClient, serverEntries]); + + const agents = useMemo(() => { + const all: AggregatedAgent[] = []; + + for (let idx = 0; idx < serverEntries.length; idx++) { + const entry = serverEntries[idx]; + if (!entry) { + continue; + } + const data = queries[idx]?.data; + if (!data) { + continue; + } + const serverLabel = + connectionStates.get(entry.serverId)?.daemon.label ?? entry.serverId; + const liveById = liveAgents[entry.serverId]; + + for (const snapshot of data) { + const normalized = normalizeAgentSnapshot(snapshot, entry.serverId); + const live = liveById?.get(snapshot.id); + all.push( + toAggregatedAgent({ + source: live ?? normalized, + serverId: entry.serverId, + serverLabel, + }) + ); + } + } + + all.sort((left, right) => { + const leftRunning = left.status === "running"; + const rightRunning = right.status === "running"; + if (leftRunning && !rightRunning) { + return -1; + } + if (!leftRunning && rightRunning) { + return 1; + } + return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); + }); + + return all; + }, [serverEntries, queries, connectionStates, liveAgents]); + + const isFetching = queries.some((query) => query.isPending || query.isFetching); + const isInitialLoad = isFetching && agents.length === 0; + const isRevalidating = isFetching && agents.length > 0; + + return { + agents, + isLoading: isFetching, + isInitialLoad, + isRevalidating, + refreshAll, + }; +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index abb9001ee..2d6576315 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -174,6 +174,23 @@ type AgentScreenContentProps = { agentId?: string; }; +type MissingAgentState = + | { kind: "idle" } + | { kind: "resolving" } + | { kind: "not_found"; message: string } + | { kind: "error"; message: string }; + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function isNotFoundErrorMessage(message: string): boolean { + return /agent not found|not found/i.test(message); +} + function AgentScreenContent({ serverId, agentId, @@ -363,6 +380,10 @@ function AgentScreenContent({ (state) => state.sessions[serverId]?.connection.isConnected ?? false ); const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId); + const [missingAgentState, setMissingAgentState] = useState({ + kind: "idle", + }); + const initAttemptTokenRef = useRef(0); const setFocusedAgentId = useCallback( (agentId: string | null) => { useSessionStore.getState().setFocusedAgentId(serverId, agentId); @@ -544,6 +565,59 @@ function AgentScreenContent({ }); }, [resolvedAgentId, ensureAgentIsInitialized, isConnected, needsAuthoritativeSync]); + useEffect(() => { + // Clear stale resolution state when route target changes. + initAttemptTokenRef.current += 1; + setMissingAgentState({ kind: "idle" }); + }, [serverId, resolvedAgentId]); + + useEffect(() => { + if (!resolvedAgentId || !ensureAgentIsInitialized) { + return; + } + if (agent || shouldUseOptimisticStream) { + if (missingAgentState.kind !== "idle") { + setMissingAgentState({ kind: "idle" }); + } + return; + } + if (!isConnected) { + return; + } + if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + return; + } + + setMissingAgentState({ kind: "resolving" }); + const attemptToken = ++initAttemptTokenRef.current; + + ensureAgentIsInitialized(resolvedAgentId) + .then(() => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + setMissingAgentState({ kind: "idle" }); + }) + .catch((error) => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setMissingAgentState({ kind: "not_found", message }); + return; + } + setMissingAgentState({ kind: "error", message }); + }); + }, [ + agent, + ensureAgentIsInitialized, + isConnected, + missingAgentState.kind, + resolvedAgentId, + shouldUseOptimisticStream, + ]); + useEffect(() => { if (Platform.OS !== "web") { return; @@ -600,11 +674,28 @@ function AgentScreenContent({ ); if (!effectiveAgent) { + if (missingAgentState.kind === "not_found") { + return ( + + + + Agent not found + + + ); + } + return ( - + - Agent not found + + Loading agent… + {missingAgentState.kind === "error" ? ( + + {missingAgentState.message} + + ) : null} ); @@ -970,6 +1061,13 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, }, + loadingSubtext: { + marginTop: theme.spacing[1], + textAlign: "center", + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + paddingHorizontal: theme.spacing[6], + }, centerState: { flex: 1, alignItems: "center",