mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'enhance-agent-fetch-sidebar'
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
159
packages/app/src/hooks/use-all-agents-list.ts
Normal file
159
packages/app/src/hooks/use-all-agents-list.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user