diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8660eb321..133bdc574 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -33,6 +33,7 @@ import * as FileSystem from 'expo-file-system'; import { useDaemonConnections } from "./daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; import { getNowMs, isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; +import type { AgentDirectoryEntry } from "@/types/agent-directory"; const SESSION_CONTEXT_LOG_TAG = "[SessionContext]"; @@ -298,6 +299,22 @@ function buildSessionStateFromSnapshots(serverId: string, snapshots: AgentSnapsh return { agents, pendingPermissions }; } +function buildAgentDirectoryEntries(serverId: string, agents: Map): AgentDirectoryEntry[] { + const entries: AgentDirectoryEntry[] = []; + for (const agent of agents.values()) { + entries.push({ + id: agent.id, + serverId, + title: agent.title ?? null, + status: agent.status, + lastActivityAt: agent.lastActivityAt, + cwd: agent.cwd, + provider: agent.provider, + }); + } + return entries; +} + export interface SessionContextValue { serverId: string; @@ -400,6 +417,8 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const setSession = useSessionStore((state) => state.setSession); const updateSession = useSessionStore((state) => state.updateSession); const clearSession = useSessionStore((state) => state.clearSession); + const setAgentDirectory = useSessionStore((state) => state.setAgentDirectory); + const clearAgentDirectory = useSessionStore((state) => state.clearAgentDirectory); useEffect(() => { if (ws.isConnected) { @@ -455,6 +474,13 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const [hasHydratedAgents, setHasHydratedAgents] = useState(false); const [agents, setAgents] = useState>(new Map()); + const lightweightAgentDirectory = useMemo( + () => buildAgentDirectoryEntries(serverId, agents), + [agents, serverId] + ); + useEffect(() => { + setAgentDirectory(serverId, lightweightAgentDirectory); + }, [lightweightAgentDirectory, serverId, setAgentDirectory]); const [commands, setCommands] = useState>(new Map()); const [pendingPermissions, setPendingPermissions] = useState>(new Map()); const [gitDiffs, setGitDiffs] = useState>(new Map()); @@ -1892,8 +1918,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid useEffect(() => { return () => { clearSession(serverId); + clearAgentDirectory(serverId); }; - }, [clearSession, serverId]); + }, [clearAgentDirectory, clearSession, serverId]); return ( diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 36752e537..a7a93e8d8 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,9 +1,9 @@ -import type { Agent } from "@/contexts/session-context"; import { useMemo } from "react"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; +import type { AgentDirectoryEntry } from "@/types/agent-directory"; -export interface AggregatedAgent extends Agent { +export interface AggregatedAgent extends AgentDirectoryEntry { serverId: string; serverLabel: string; } @@ -15,26 +15,17 @@ export interface AggregatedAgentsResult { export function useAggregatedAgents(): AggregatedAgentsResult { const { connectionStates } = useDaemonConnections(); - const sessions = useSessionStore((state) => state.sessions); - const sessionAgents = useMemo(() => { - const agentsByServer: Record> = {}; - for (const [serverId, session] of Object.entries(sessions)) { - if (session?.agents) { - agentsByServer[serverId] = session.agents; - } - } - return agentsByServer; - }, [sessions]); + const agentDirectory = useSessionStore((state) => state.agentDirectory); return useMemo(() => { const allAgents: AggregatedAgent[] = []; - for (const [serverId, agents] of Object.entries(sessionAgents)) { - if (!agents || agents.size === 0) { + for (const [serverId, agents] of Object.entries(agentDirectory)) { + if (!agents || agents.length === 0) { continue; } const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; - for (const agent of agents.values()) { + for (const agent of agents) { allAgents.push({ ...agent, serverId, @@ -53,8 +44,8 @@ export function useAggregatedAgents(): AggregatedAgentsResult { if (!leftRunning && rightRunning) { return 1; } - const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime(); - const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime(); + const leftTime = left.lastActivityAt.getTime(); + const rightTime = right.lastActivityAt.getTime(); return rightTime - leftTime; }); @@ -70,5 +61,5 @@ export function useAggregatedAgents(): AggregatedAgentsResult { }); return { agents: allAgents, isLoading }; - }, [sessionAgents, connectionStates]); + }, [agentDirectory, connectionStates]); } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index e5432fd4a..adea9e0de 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from "react"; import type { SessionContextValue } from "@/contexts/session-context"; +import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; // SessionData mirrors SessionContextValue so consumers can subscribe to a single source of truth. @@ -7,6 +8,7 @@ export type SessionData = SessionContextValue; interface SessionStoreState { sessions: Record; + agentDirectory: Record; } interface SessionStore extends SessionStoreState { @@ -14,11 +16,14 @@ interface SessionStore extends SessionStoreState { updateSession: (serverId: string, partial: Partial) => void; clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionData | undefined; + setAgentDirectory: (serverId: string, agents: AgentDirectoryEntry[]) => void; + clearAgentDirectory: (serverId: string) => void; + getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; } type SessionListener = () => void; -let storeState: SessionStoreState = { sessions: {} }; +let storeState: SessionStoreState = { sessions: {}, agentDirectory: {} }; const listeners = new Set(); const SESSION_STORE_LOG_TAG = "[SessionStore]"; let sessionStoreUpdateCount = 0; @@ -30,7 +35,7 @@ const emit = () => { }; const logSessionStoreUpdate = ( - type: "setSession" | "updateSession" | "clearSession", + type: "setSession" | "updateSession" | "clearSession" | "setAgentDirectory" | "clearAgentDirectory", serverId: string, payload?: unknown ) => { @@ -65,6 +70,7 @@ const setSession: SessionStore["setSession"] = (serverId, data) => { } logSessionStoreUpdate("setSession", serverId, data); return { + ...prev, sessions: { ...prev.sessions, [serverId]: data, @@ -92,6 +98,7 @@ const updateSession: SessionStore["updateSession"] = (serverId, partial) => { logSessionStoreUpdate("updateSession", serverId, partial); return { + ...prev, sessions: { ...prev.sessions, [serverId]: next, @@ -108,7 +115,7 @@ const clearSession: SessionStore["clearSession"] = (serverId) => { logSessionStoreUpdate("clearSession", serverId); const nextSessions = { ...prev.sessions }; delete nextSessions[serverId]; - return { sessions: nextSessions }; + return { ...prev, sessions: nextSessions }; }); }; @@ -116,6 +123,39 @@ const getSession: SessionStore["getSession"] = (serverId) => { return storeState.sessions[serverId]; }; +const setAgentDirectory: SessionStore["setAgentDirectory"] = (serverId, agents) => { + updateStoreState((prev) => { + const existing = prev.agentDirectory[serverId]; + if (existing && areAgentDirectoriesEqual(existing, agents)) { + return prev; + } + logSessionStoreUpdate("setAgentDirectory", serverId, { agentCount: agents.length }); + return { + ...prev, + agentDirectory: { + ...prev.agentDirectory, + [serverId]: agents, + }, + }; + }); +}; + +const clearAgentDirectory: SessionStore["clearAgentDirectory"] = (serverId) => { + updateStoreState((prev) => { + if (!(serverId in prev.agentDirectory)) { + return prev; + } + logSessionStoreUpdate("clearAgentDirectory", serverId); + const nextDirectory = { ...prev.agentDirectory }; + delete nextDirectory[serverId]; + return { ...prev, agentDirectory: nextDirectory }; + }); +}; + +const getAgentDirectory: SessionStore["getAgentDirectory"] = (serverId) => { + return storeState.agentDirectory[serverId]; +}; + const subscribe = (listener: SessionListener) => { listeners.add(listener); return () => { @@ -125,10 +165,14 @@ const subscribe = (listener: SessionListener) => { const buildSnapshot = (): SessionStore => ({ sessions: storeState.sessions, + agentDirectory: storeState.agentDirectory, setSession, updateSession, clearSession, getSession, + setAgentDirectory, + clearAgentDirectory, + getAgentDirectory, }); const shallowEqual = (left: SessionData, right: SessionData): boolean => { @@ -155,3 +199,33 @@ export function useSessionStore(selector: (state: SessionStore) => T): T { () => selector(buildSnapshot()) ); } + +function areAgentDirectoriesEqual( + left: AgentDirectoryEntry[] | undefined, + right: AgentDirectoryEntry[] +): boolean { + if (!left) { + return false; + } + if (left.length !== right.length) { + return false; + } + const leftById = new Map(left.map((entry) => [entry.id, entry])); + for (const entry of right) { + const previous = leftById.get(entry.id); + if (!previous) { + return false; + } + if ( + previous.serverId !== entry.serverId || + previous.title !== entry.title || + previous.status !== entry.status || + previous.provider !== entry.provider || + previous.cwd !== entry.cwd || + previous.lastActivityAt.getTime() !== entry.lastActivityAt.getTime() + ) { + return false; + } + } + return true; +} diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts new file mode 100644 index 000000000..e1b7e8ee9 --- /dev/null +++ b/packages/app/src/types/agent-directory.ts @@ -0,0 +1,12 @@ +import type { AgentLifecycleStatus } from "@server/server/agent/agent-manager"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; + +export interface AgentDirectoryEntry { + id: string; + serverId: string; + title: string | null; + status: AgentLifecycleStatus; + lastActivityAt: Date; + cwd: string; + provider: AgentProvider; +}