Merge branch 'enhance-agent-fetch-sidebar'

This commit is contained in:
Mohamed Boudra
2026-02-09 16:50:31 +07:00
4 changed files with 276 additions and 9 deletions

View File

@@ -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() {
<BackHeader title="All agents" />
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
/>

View File

@@ -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<ViewToken> }) => {
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 (
<Pressable
@@ -233,6 +241,7 @@ export function AgentList({
handleAgentLongPress,
handleAgentPress,
selectedAgentId,
showCheckoutInfo,
]
);
@@ -293,7 +302,7 @@ export function AgentList({
updateCellsBatchingPeriod={16}
removeClippedSubviews={true}
ListFooterComponent={listFooterComponent}
onViewableItemsChanged={onViewableItemsChanged.current}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
refreshControl={
onRefresh ? (

View File

@@ -0,0 +1,159 @@
import { useCallback, useMemo } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { useShallow } from "zustand/shallow";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const ALL_AGENTS_STALE_TIME = 60_000;
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
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<typeof state.sessions[string]["client"]> | 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<string, boolean> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.connection.isConnected;
}
return result;
})
);
const liveAgents = useSessionStore(
useShallow((state) => {
const result: Record<string, Map<string, Agent> | 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,
};
}

View File

@@ -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<MissingAgentState>({
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 (
<View style={styles.container} testID="agent-not-found">
<MenuHeader title="Agent" />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
return (
<View style={styles.container} testID="agent-not-found">
<View style={styles.container} testID="agent-loading">
<MenuHeader title="Agent" />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.loadingText}>Loading agent</Text>
{missingAgentState.kind === "error" ? (
<Text style={styles.loadingSubtext} numberOfLines={2}>
{missingAgentState.message}
</Text>
) : null}
</View>
</View>
);
@@ -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",