import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@server/client/daemon-client"; import type { useAudioPlayer } from "@/hooks/use-audio-player"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { AgentPermissionResponse, AgentPermissionRequest, AgentSessionConfig, AgentProvider, AgentMode, AgentCapabilityFlags, AgentModelDefinition, AgentUsage, AgentPersistenceHandle, } from "@server/server/agent/agent-sdk-types"; import type { FileDownloadTokenResponse, GitSetupOptions, ProjectPlacementPayload, } from "@server/shared/messages"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; // Re-export types that were in session-context export type MessageEntry = | { type: "user"; id: string; timestamp: number; message: string; } | { type: "assistant"; id: string; timestamp: number; message: string; } | { type: "activity"; id: string; timestamp: number; activityType: "system" | "info" | "success" | "error"; message: string; metadata?: Record; } | { type: "artifact"; id: string; timestamp: number; artifactId: string; artifactType: string; title: string; } | { type: "tool_call"; id: string; timestamp: number; toolName: string; args: unknown | null; result?: unknown | null; error?: unknown | null; status: "executing" | "completed" | "failed"; }; export interface AgentRuntimeInfo { provider: AgentProvider; sessionId: string | null; model?: string | null; modeId?: string | null; extra?: Record; } export interface Agent { serverId: string; id: string; provider: AgentProvider; status: AgentLifecycleStatus; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; lastActivityAt: Date; capabilities: AgentCapabilityFlags; currentModeId: string | null; availableModes: AgentMode[]; pendingPermissions: AgentPermissionRequest[]; persistence: AgentPersistenceHandle | null; runtimeInfo?: AgentRuntimeInfo; lastUsage?: AgentUsage; lastError?: string | null; title: string | null; cwd: string; model: string | null; thinkingOptionId?: string | null; requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; archivedAt?: Date | null; labels: Record; projectPlacement?: ProjectPlacementPayload | null; } export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; export type ExplorerEncoding = "utf-8" | "base64" | "none"; export interface ExplorerEntry { name: string; path: string; kind: ExplorerEntryKind; size: number; modifiedAt: string; } export interface ExplorerFile { path: string; kind: ExplorerFileKind; encoding: ExplorerEncoding; content?: string; mimeType?: string; size: number; modifiedAt: string; } interface ExplorerDirectory { path: string; entries: ExplorerEntry[]; } interface ExplorerRequestState { path: string; mode: "list" | "file"; } export interface AgentFileExplorerState { directories: Map; files: Map; isLoading: boolean; lastError: string | null; pendingRequest: ExplorerRequestState | null; currentPath: string; history: string[]; lastVisitedPath: string; selectedEntryPath: string | null; } export interface DaemonConnectionSnapshot { isConnected: boolean; isConnecting: boolean; lastError: string | null; } export type DaemonServerInfo = { serverId: string; hostname: string | null; }; // Per-session state export interface SessionState { serverId: string; // Daemon client (immutable reference) client: DaemonClient | null; // Connection snapshot (mutable) connection: DaemonConnectionSnapshot; // Server metadata (from server_info handshake) serverInfo: DaemonServerInfo | null; // Audio player (immutable reference) audioPlayer: ReturnType | null; // Hydration status hasHydratedAgents: boolean; // Audio state isPlayingAudio: boolean; // Focus focusedAgentId: string | null; // Messages messages: MessageEntry[]; currentAssistantMessage: string; // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; historySyncGeneration: number; agentHistorySyncGeneration: Map; // Initializing agents (used for UI loading state) initializingAgents: Map; // Agents agents: Map; // Permissions pendingPermissions: Map; // File explorer fileExplorer: Map; // Queued messages queuedMessages: Map }>>; } // Global store state interface SessionStoreState { sessions: Record; // Agent activity timestamps (top-level, keyed by agentId to prevent cascade rerenders) agentLastActivity: Map; } // Action types interface SessionStoreActions { // Session management initializeSession: (serverId: string, client: DaemonClient, audioPlayer: ReturnType) => void; clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient) => void; updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void; updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state setIsPlayingAudio: (serverId: string, playing: boolean) => void; // Focus setFocusedAgentId: (serverId: string, agentId: string | null) => void; // Messages setMessages: (serverId: string, messages: MessageEntry[] | ((prev: MessageEntry[]) => MessageEntry[])) => void; setCurrentAssistantMessage: (serverId: string, message: string | ((prev: string) => string)) => void; // Stream state (head/tail model) setAgentStreamTail: (serverId: string, state: Map | ((prev: Map) => Map)) => void; setAgentStreamHead: (serverId: string, state: Map | ((prev: Map) => Map)) => void; clearAgentStreamHead: (serverId: string, agentId: string) => void; bumpHistorySyncGeneration: (serverId: string) => void; markAgentHistorySynchronized: (serverId: string, agentId: string) => void; // Initializing agents setInitializingAgents: (serverId: string, state: Map | ((prev: Map) => Map)) => void; // Agents setAgents: (serverId: string, agents: Map | ((prev: Map) => Map)) => void; // Agent activity timestamps setAgentLastActivity: (agentId: string, timestamp: Date) => void; // Permissions setPendingPermissions: (serverId: string, perms: Map | ((prev: Map) => Map)) => void; // File explorer setFileExplorer: (serverId: string, state: Map | ((prev: Map) => Map)) => void; // Queued messages setQueuedMessages: (serverId: string, value: Map }>> | ((prev: Map }>>) => Map }>>)) => void; // Hydration setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; // Agent directory (derived from agents) getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; } type SessionStore = SessionStoreState & SessionStoreActions; const SESSION_STORE_LOG_TAG = "[SessionStore]"; let sessionStoreUpdateCount = 0; function logSessionStoreUpdate( type: string, serverId: string, payload?: unknown ) { if (!isPerfLoggingEnabled()) { return; } sessionStoreUpdateCount += 1; const metrics = payload ? measurePayload(payload) : null; perfLog(SESSION_STORE_LOG_TAG, { event: type, serverId, updateCount: sessionStoreUpdateCount, payloadApproxBytes: metrics?.approxBytes ?? 0, payloadFieldCount: metrics?.fieldCount ?? 0, timestamp: Date.now(), }); } function createDefaultConnectionSnapshot(client?: DaemonClient | null): DaemonConnectionSnapshot { if (!client) { return { isConnected: false, isConnecting: false, lastError: null }; } const state = client.getConnectionState(); return { isConnected: state.status === "connected", isConnecting: state.status === "connecting", lastError: state.status === "disconnected" ? state.reason ?? client.lastError ?? null : null, }; } // Helper to create initial session state function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType): SessionState { return { serverId, client, connection: createDefaultConnectionSnapshot(client), serverInfo: null, audioPlayer, hasHydratedAgents: false, isPlayingAudio: false, focusedAgentId: null, messages: [], currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), historySyncGeneration: 0, agentHistorySyncGeneration: new Map(), initializingAgents: new Map(), agents: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), queuedMessages: new Map(), }; } export const useSessionStore = create()( subscribeWithSelector((set, get) => ({ sessions: {}, agentLastActivity: new Map(), // Session management initializeSession: (serverId, client, audioPlayer) => { set((prev) => { if (prev.sessions[serverId]) { return prev; } logSessionStoreUpdate("initializeSession", serverId); return { ...prev, sessions: { ...prev.sessions, [serverId]: createInitialSessionState(serverId, client, audioPlayer), }, }; }); }, clearSession: (serverId) => { set((prev) => { if (!(serverId in prev.sessions)) { return prev; } logSessionStoreUpdate("clearSession", serverId); const nextSessions = { ...prev.sessions }; delete nextSessions[serverId]; return { ...prev, sessions: nextSessions }; }); }, updateSessionClient: (serverId, client) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } if (session.client === client) { return prev; } logSessionStoreUpdate("updateSessionClient", serverId, { wasNull: session.client === null, isNowConnected: client.isConnected, isNowConnecting: client.isConnecting, }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, client, }, }, }; }); }, updateSessionConnection: (serverId, connection) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } if ( session.connection.isConnected === connection.isConnected && session.connection.isConnecting === connection.isConnecting && session.connection.lastError === connection.lastError ) { return prev; } logSessionStoreUpdate("updateSessionConnection", serverId, connection); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, connection, }, }, }; }); }, updateSessionServerInfo: (serverId, info) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextHostname = info.hostname?.trim() || null; const prevHostname = session.serverInfo?.hostname?.trim() || null; if (session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname) { return prev; } logSessionStoreUpdate("updateSessionServerInfo", serverId, { serverId: info.serverId, hostname: nextHostname, }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, serverInfo: { serverId: info.serverId, hostname: nextHostname }, }, }, }; }); }, getSession: (serverId) => { return get().sessions[serverId]; }, // Audio state setIsPlayingAudio: (serverId, playing) => { set((prev) => { const session = prev.sessions[serverId]; if (!session || session.isPlayingAudio === playing) { return prev; } logSessionStoreUpdate("setIsPlayingAudio", serverId, { playing }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, isPlayingAudio: playing }, }, }; }); }, // Focus setFocusedAgentId: (serverId, agentId) => { set((prev) => { const session = prev.sessions[serverId]; if (!session || session.focusedAgentId === agentId) { return prev; } logSessionStoreUpdate("setFocusedAgentId", serverId, { agentId }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, focusedAgentId: agentId }, }, }; }); }, // Messages setMessages: (serverId, messages) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextMessages = typeof messages === "function" ? messages(session.messages) : messages; if (session.messages === nextMessages) { return prev; } logSessionStoreUpdate("setMessages", serverId, { count: nextMessages.length }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, messages: nextMessages }, }, }; }); }, setCurrentAssistantMessage: (serverId, message) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextMessage = typeof message === "function" ? message(session.currentAssistantMessage) : message; if (session.currentAssistantMessage === nextMessage) { return prev; } logSessionStoreUpdate("setCurrentAssistantMessage", serverId, { length: nextMessage.length }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, currentAssistantMessage: nextMessage }, }, }; }); }, // Stream state (head/tail model) setAgentStreamTail: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextState = typeof state === "function" ? state(session.agentStreamTail) : state; if (session.agentStreamTail === nextState) { return prev; } logSessionStoreUpdate("setAgentStreamTail", serverId, { agentCount: nextState.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, agentStreamTail: nextState }, }, }; }); }, setAgentStreamHead: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextState = typeof state === "function" ? state(session.agentStreamHead) : state; if (session.agentStreamHead === nextState) { return prev; } logSessionStoreUpdate("setAgentStreamHead", serverId, { agentCount: nextState.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, agentStreamHead: nextState }, }, }; }); }, clearAgentStreamHead: (serverId, agentId) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } if (!session.agentStreamHead.has(agentId)) { return prev; } const nextHead = new Map(session.agentStreamHead); nextHead.delete(agentId); logSessionStoreUpdate("clearAgentStreamHead", serverId, { agentId }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, agentStreamHead: nextHead }, }, }; }); }, bumpHistorySyncGeneration: (serverId) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextGeneration = session.historySyncGeneration + 1; logSessionStoreUpdate("bumpHistorySyncGeneration", serverId, { generation: nextGeneration, }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, historySyncGeneration: nextGeneration, }, }, }; }); }, markAgentHistorySynchronized: (serverId, agentId) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const currentGeneration = session.historySyncGeneration; const previousGeneration = session.agentHistorySyncGeneration.get(agentId); if (previousGeneration === currentGeneration) { return prev; } const nextMap = new Map(session.agentHistorySyncGeneration); nextMap.set(agentId, currentGeneration); logSessionStoreUpdate("markAgentHistorySynchronized", serverId, { agentId, generation: currentGeneration, }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, agentHistorySyncGeneration: nextMap, }, }, }; }); }, // Initializing agents setInitializingAgents: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextState = typeof state === "function" ? state(session.initializingAgents) : state; if (session.initializingAgents === nextState) { return prev; } logSessionStoreUpdate("setInitializingAgents", serverId, { count: nextState.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, initializingAgents: nextState }, }, }; }); }, // Agents setAgents: (serverId, agents) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextAgents = typeof agents === "function" ? agents(session.agents) : agents; if (session.agents === nextAgents) { return prev; } logSessionStoreUpdate("setAgents", serverId, { count: nextAgents.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, agents: nextAgents }, }, }; }); }, // Agent activity timestamps (top-level, does NOT mutate session object) setAgentLastActivity: (agentId, timestamp) => { set((prev) => { const currentTimestamp = prev.agentLastActivity.get(agentId); if (currentTimestamp && currentTimestamp.getTime() === timestamp.getTime()) { return prev; } const nextActivity = new Map(prev.agentLastActivity); nextActivity.set(agentId, timestamp); return { ...prev, agentLastActivity: nextActivity, }; }); }, // Permissions setPendingPermissions: (serverId, perms) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextPerms = typeof perms === "function" ? perms(session.pendingPermissions) : perms; if (session.pendingPermissions === nextPerms) { return prev; } logSessionStoreUpdate("setPendingPermissions", serverId, { count: nextPerms.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, pendingPermissions: nextPerms }, }, }; }); }, // File explorer setFileExplorer: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextState = typeof state === "function" ? state(session.fileExplorer) : state; if (session.fileExplorer === nextState) { return prev; } logSessionStoreUpdate("setFileExplorer", serverId, { agentCount: nextState.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, fileExplorer: nextState }, }, }; }); }, // Queued messages setQueuedMessages: (serverId, value) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } const nextValue = typeof value === "function" ? value(session.queuedMessages) : value; if (session.queuedMessages === nextValue) { return prev; } logSessionStoreUpdate("setQueuedMessages", serverId, { agentCount: nextValue.size }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, queuedMessages: nextValue }, }, }; }); }, // Hydration setHasHydratedAgents: (serverId, hydrated) => { set((prev) => { const session = prev.sessions[serverId]; if (!session || session.hasHydratedAgents === hydrated) { return prev; } logSessionStoreUpdate("setHasHydratedAgents", serverId, { hydrated }); return { ...prev, sessions: { ...prev.sessions, [serverId]: { ...session, hasHydratedAgents: hydrated }, }, }; }); }, // Agent directory - derived from agents (computed on-demand) getAgentDirectory: (serverId) => { const state = get(); const session = state.sessions[serverId]; if (!session) { return undefined; } const entries: AgentDirectoryEntry[] = []; for (const agent of session.agents.values()) { // Get lastActivityAt from top-level slice, fallback to agent.lastActivityAt const lastActivityAt = state.agentLastActivity.get(agent.id) ?? agent.lastActivityAt; entries.push({ id: agent.id, serverId, title: agent.title ?? null, status: agent.status, lastActivityAt, cwd: agent.cwd, provider: agent.provider, requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp ?? null, labels: agent.labels, }); } return entries; }, })) );