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 { StyleSheet } from "react-native-unistyles";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { AgentList } from "@/components/agent-list";
|
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() {
|
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
|
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
@@ -36,6 +36,7 @@ export default function AgentsScreen() {
|
|||||||
<BackHeader title="All agents" />
|
<BackHeader title="All agents" />
|
||||||
<AgentList
|
<AgentList
|
||||||
agents={sortedAgents}
|
agents={sortedAgents}
|
||||||
|
showCheckoutInfo={false}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
|
showCheckoutInfo?: boolean;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
@@ -74,6 +75,7 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
|
|||||||
|
|
||||||
export function AgentList({
|
export function AgentList({
|
||||||
agents,
|
agents,
|
||||||
|
showCheckoutInfo = true,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
@@ -143,8 +145,11 @@ export function AgentList({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onViewableItemsChanged = useRef(
|
const onViewableItemsChanged = useCallback(
|
||||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||||
|
if (!showCheckoutInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const token of viewableItems) {
|
for (const token of viewableItems) {
|
||||||
const agent = token.item as AggregatedAgent | undefined;
|
const agent = token.item as AggregatedAgent | undefined;
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -176,7 +181,8 @@ export function AgentList({
|
|||||||
console.warn("[checkout_status] prefetch failed", error);
|
console.warn("[checkout_status] prefetch failed", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[queryClient, showCheckoutInfo]
|
||||||
);
|
);
|
||||||
|
|
||||||
const AgentListRow = useCallback(
|
const AgentListRow = useCallback(
|
||||||
@@ -190,8 +196,10 @@ export function AgentList({
|
|||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
});
|
});
|
||||||
const checkout = checkoutQuery.data ?? null;
|
const checkout = checkoutQuery.data ?? null;
|
||||||
const projectPath = deriveProjectPath(agent.cwd, checkout);
|
const projectPath = showCheckoutInfo
|
||||||
const branchLabel = deriveBranchLabel(checkout);
|
? deriveProjectPath(agent.cwd, checkout)
|
||||||
|
: agent.cwd;
|
||||||
|
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -233,6 +241,7 @@ export function AgentList({
|
|||||||
handleAgentLongPress,
|
handleAgentLongPress,
|
||||||
handleAgentPress,
|
handleAgentPress,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
|
showCheckoutInfo,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,7 +302,7 @@ export function AgentList({
|
|||||||
updateCellsBatchingPeriod={16}
|
updateCellsBatchingPeriod={16}
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
ListFooterComponent={listFooterComponent}
|
ListFooterComponent={listFooterComponent}
|
||||||
onViewableItemsChanged={onViewableItemsChanged.current}
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
viewabilityConfig={viewabilityConfig}
|
viewabilityConfig={viewabilityConfig}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
onRefresh ? (
|
onRefresh ? (
|
||||||
|
|||||||
@@ -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;
|
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({
|
function AgentScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -363,6 +380,10 @@ function AgentScreenContent({
|
|||||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||||
);
|
);
|
||||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||||
|
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||||
|
kind: "idle",
|
||||||
|
});
|
||||||
|
const initAttemptTokenRef = useRef(0);
|
||||||
const setFocusedAgentId = useCallback(
|
const setFocusedAgentId = useCallback(
|
||||||
(agentId: string | null) => {
|
(agentId: string | null) => {
|
||||||
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
|
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
|
||||||
@@ -544,6 +565,59 @@ function AgentScreenContent({
|
|||||||
});
|
});
|
||||||
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected, needsAuthoritativeSync]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return;
|
return;
|
||||||
@@ -600,11 +674,28 @@ function AgentScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!effectiveAgent) {
|
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 (
|
return (
|
||||||
<View style={styles.container} testID="agent-not-found">
|
<View style={styles.container} testID="agent-loading">
|
||||||
<MenuHeader title="Agent" />
|
<MenuHeader title="Agent" />
|
||||||
<View style={styles.errorContainer}>
|
<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>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -970,6 +1061,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
loadingSubtext: {
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
},
|
||||||
centerState: {
|
centerState: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
Reference in New Issue
Block a user