import type { StateCreator } from "zustand"; import type { SessionRuntimeSlice, SessionRuntimeSliceState, SessionPollMode, GitStatusEntry, FileInfo, } from "./types"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; const debugGit = createDebugLogger("git-status:store"); const maxProcessOutputBytes = 2 * 1024 * 1024; /** Compute total additions/deletions across all files. */ function computeFileStats(files: Record | undefined): { additions: number; deletions: number; } { if (!files) return { additions: 0, deletions: 0 }; let additions = 0; let deletions = 0; for (const f of Object.values(files)) { additions += f.additions || 0; deletions += f.deletions || 0; } return { additions, deletions }; } function sameStringList(existing: string[] | undefined, incoming: string[] | undefined): boolean { const a = existing ?? []; const b = incoming ?? []; if (a.length !== b.length) return false; const sortedA = [...a].sort(); const sortedB = [...b].sort(); return sortedA.every((value, index) => value === sortedB[index]); } const COMPARABLE_FILE_FIELDS = [ "path", "status", "staged", "additions", "deletions", "old_path", "diff", "diff_skip_reason", "repository_name", ] as const; function comparableFileInfo(file: FileInfo) { return { path: file.path, status: file.status, staged: file.staged, additions: file.additions ?? 0, deletions: file.deletions ?? 0, old_path: file.old_path ?? "", diff: file.diff ?? "", diff_skip_reason: file.diff_skip_reason ?? "", repository_name: file.repository_name ?? "", }; } function sameFileInfo(existing: FileInfo | undefined, incoming: FileInfo | undefined): boolean { if (!existing || !incoming) return existing === incoming; const a = comparableFileInfo(existing); const b = comparableFileInfo(incoming); return COMPARABLE_FILE_FIELDS.every((field) => a[field] === b[field]); } function sameFiles( existingFiles: Record | undefined, newFiles: Record | undefined, ): boolean { if (!existingFiles || !newFiles) return existingFiles === newFiles; const existingFileKeys = Object.keys(existingFiles).sort(); const newFileKeys = Object.keys(newFiles).sort(); if (existingFileKeys.length !== newFileKeys.length) return false; for (let i = 0; i < existingFileKeys.length; i += 1) { const key = existingFileKeys[i]; if (key !== newFileKeys[i]) return false; if (!sameFileInfo(existingFiles[key], newFiles[key])) return false; } return true; } function hasBranchSummaryChanged(existing: GitStatusEntry, incoming: GitStatusEntry): boolean { return ( existing.branch !== incoming.branch || existing.remote_branch !== incoming.remote_branch || existing.ahead !== incoming.ahead || existing.behind !== incoming.behind || (existing.repository_name ?? "") !== (incoming.repository_name ?? "") || existing.branch_additions !== incoming.branch_additions || existing.branch_deletions !== incoming.branch_deletions ); } function hasFileListsChanged(existing: GitStatusEntry, incoming: GitStatusEntry): boolean { return ( !sameStringList(existing.modified, incoming.modified) || !sameStringList(existing.added, incoming.added) || !sameStringList(existing.deleted, incoming.deleted) || !sameStringList(existing.untracked, incoming.untracked) || !sameStringList(existing.renamed, incoming.renamed) ); } function hasFileStatsChanged(existing: GitStatusEntry, incoming: GitStatusEntry): boolean { // Fast early-exit: aggregate totals differ → sameFiles would also return false, // but this avoids the per-file deep comparison when the gross numbers differ. const existingTotal = computeFileStats(existing.files); const newTotal = computeFileStats(incoming.files); return ( existingTotal.additions !== newTotal.additions || existingTotal.deletions !== newTotal.deletions ); } /** True when setGitStatus would write at least one map entry for this update. */ export function gitStatusWouldMutate( gitStatusState: SessionRuntimeSliceState["gitStatus"], envKey: string, gitStatus: GitStatusEntry, ): boolean { const repoName = gitStatus.repository_name ?? ""; const existingEnv = gitStatusState.byEnvironmentId[envKey]; const existingRepo = gitStatusState.byEnvironmentRepo[envKey]?.[repoName]; const repoChanged = !existingRepo || hasGitStatusChanged(existingRepo, gitStatus); if (repoName !== "") { return repoChanged; } const envChanged = !existingEnv || hasGitStatusChanged(existingEnv, gitStatus); return envChanged || repoChanged; } /** Compare two git status entries to determine if a meaningful change occurred. */ export function hasGitStatusChanged(existing: GitStatusEntry, incoming: GitStatusEntry): boolean { // The backend also emits fresh snapshots for focus/startup/poll events. Those // can carry a new timestamp for identical git data, so timestamp alone must // not force a store update or diff-cache invalidation. return ( hasBranchSummaryChanged(existing, incoming) || hasFileListsChanged(existing, incoming) || hasFileStatsChanged(existing, incoming) || !sameFiles(existing.files, incoming.files) ); } function trimProcessOutput(value: string) { if (value.length <= maxProcessOutputBytes) { return value; } return value.slice(value.length - maxProcessOutputBytes); } export const defaultSessionRuntimeState: SessionRuntimeSliceState = { terminal: { terminals: [] }, shell: { outputs: {}, statuses: {} }, processes: { outputsByProcessId: {}, processesById: {}, processIdsBySessionId: {}, activeProcessBySessionId: {}, devProcessBySessionId: {}, }, gitStatus: { byEnvironmentId: {}, byEnvironmentRepo: {} }, environmentIdBySessionId: {}, sessionCommits: { byEnvironmentId: {}, loading: {}, refetchTrigger: {} }, contextWindow: { bySessionId: {} }, agents: { agents: [] }, availableCommands: { bySessionId: {} }, sessionMode: { bySessionId: {} }, agentCapabilities: { bySessionId: {} }, sessionModels: { bySessionId: {} }, promptUsage: { bySessionId: {} }, sessionTodos: { bySessionId: {} }, userShells: { byEnvironmentId: {}, loading: {}, loaded: {} }, prepareProgress: { bySessionId: {} }, sessionPollMode: { bySessionId: {} }, }; type ImmerSet = Parameters[0]; function buildTerminalShellProcessActions(set: ImmerSet) { return { setTerminalOutput: (terminalId: string, data: string) => set((draft) => { const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); if (existing) { existing.output.push(data); } else { draft.terminal.terminals.push({ id: terminalId, output: [data] }); } }), appendShellOutput: (sessionId: string, data: string) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; draft.shell.outputs[envKey] = (draft.shell.outputs[envKey] || "") + data; }), setShellStatus: ( sessionId: string, status: { available: boolean; running?: boolean; shell?: string; cwd?: string }, ) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; draft.shell.statuses[envKey] = status; }), clearShellOutput: (sessionId: string) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; draft.shell.outputs[envKey] = ""; }), appendProcessOutput: (processId: string, data: string) => set((draft) => { const next = (draft.processes.outputsByProcessId[processId] || "") + data; draft.processes.outputsByProcessId[processId] = trimProcessOutput(next); }), upsertProcessStatus: (status: Parameters[0]) => set((draft) => { draft.processes.processesById[status.processId] = status; const list = draft.processes.processIdsBySessionId[status.sessionId] || []; if (!list.includes(status.processId)) { draft.processes.processIdsBySessionId[status.sessionId] = [...list, status.processId]; } if (status.kind === "dev") { draft.processes.devProcessBySessionId[status.sessionId] = status.processId; } }), clearProcessOutput: (processId: string) => set((draft) => { draft.processes.outputsByProcessId[processId] = ""; }), setActiveProcess: (sessionId: string, processId: string) => set((draft) => { draft.processes.activeProcessBySessionId[sessionId] = processId; }), }; } function buildSessionCommitActions(set: ImmerSet) { return { setSessionCommits: ( sessionId: string, commits: Parameters[1], opts?: { allowEmpty?: boolean }, ) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; const existing = draft.sessionCommits.byEnvironmentId[envKey]; // Default guard: prevent a stale empty-array response from overwriting // commits that arrived via incremental notifications while the request // was in flight (race between fetch start and commit_created events). // // Under stale-while-revalidate, a `commits_reset` or `branch_switched` // refetch can *legitimately* return [] — the backend actually has no // commits. The caller must opt in to that path with `allowEmpty: true` // so the panel stops showing the pre-reset list. if (!opts?.allowEmpty && commits.length === 0 && existing && existing.length > 0) { return; } draft.sessionCommits.byEnvironmentId[envKey] = commits; }), setSessionCommitsLoading: (sessionId: string, loading: boolean) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; draft.sessionCommits.loading[envKey] = loading; }), addSessionCommit: ( sessionId: string, commit: Parameters[1], ) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; const existing = draft.sessionCommits.byEnvironmentId[envKey] || []; // For amend: only replace HEAD (first entry) if it has the same parent if (existing.length > 0 && existing[0].parent_sha === commit.parent_sha) { existing[0] = commit; draft.sessionCommits.byEnvironmentId[envKey] = existing; } else { draft.sessionCommits.byEnvironmentId[envKey] = [commit, ...existing]; } }), clearSessionCommits: (sessionId: string) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; delete draft.sessionCommits.byEnvironmentId[envKey]; }), bumpSessionCommitsRefetch: (sessionId: string) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; const prev = draft.sessionCommits.refetchTrigger[envKey] ?? 0; draft.sessionCommits.refetchTrigger[envKey] = prev + 1; }), }; } function buildUserShellActions(set: ImmerSet) { return { setUserShells: ( environmentId: string, shells: Parameters[1], ) => set((draft) => { if (!environmentId) return; draft.userShells.byEnvironmentId[environmentId] = shells; draft.userShells.loaded[environmentId] = true; draft.userShells.loading[environmentId] = false; }), setUserShellsLoading: (environmentId: string, loading: boolean) => set((draft) => { if (!environmentId) return; draft.userShells.loading[environmentId] = loading; }), addUserShell: ( environmentId: string, shell: Parameters[1], ) => set((draft) => { if (!environmentId) return; const existing = draft.userShells.byEnvironmentId[environmentId] || []; if (!existing.some((s) => s.terminalId === shell.terminalId)) { draft.userShells.byEnvironmentId[environmentId] = [...existing, shell]; } }), removeUserShell: (environmentId: string, terminalId: string) => set((draft) => { if (!environmentId) return; const existing = draft.userShells.byEnvironmentId[environmentId] || []; draft.userShells.byEnvironmentId[environmentId] = existing.filter( (s) => s.terminalId !== terminalId, ); }), updateUserShell: ( environmentId: string, terminalId: string, patch: Parameters[2], ) => set((draft) => { if (!environmentId) return; const existing = draft.userShells.byEnvironmentId[environmentId]; if (!existing) return; draft.userShells.byEnvironmentId[environmentId] = existing.map((s) => s.terminalId === terminalId ? { ...s, ...patch } : s, ); }), setSessionPollMode: (sessionId: string, mode: SessionPollMode) => set((draft) => { draft.sessionPollMode.bySessionId[sessionId] = mode; }), }; } /** * Migrate any env-keyed data stored under the fallback `sessionId` key to the * proper `environmentId` key so selectors don't see stale data after the * session→environment mapping is registered. */ export function migrateEnvKeyedData( draft: SessionRuntimeSliceState, sessionId: string, environmentId: string, ) { if (sessionId === environmentId) return; const migrate = (store: Record) => { if (sessionId in store) { if (!(environmentId in store)) { store[environmentId] = store[sessionId]; } delete store[sessionId]; } }; migrate(draft.sessionCommits.byEnvironmentId); migrate(draft.gitStatus.byEnvironmentRepo); migrate(draft.sessionCommits.loading); migrate(draft.sessionCommits.refetchTrigger); migrate(draft.gitStatus.byEnvironmentId); migrate(draft.shell.outputs); migrate(draft.shell.statuses); migrate(draft.userShells.byEnvironmentId); migrate(draft.userShells.loading); migrate(draft.userShells.loaded); } export const createSessionRuntimeSlice: StateCreator< SessionRuntimeSlice, [["zustand/immer", never]], [], SessionRuntimeSlice > = (set) => ({ ...defaultSessionRuntimeState, ...buildTerminalShellProcessActions(set), setGitStatus: (sessionId, gitStatus) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; // Multi-repo: when the update is tagged with repository_name, route it // into the per-repo map. Single-repo updates (no name) keep the legacy // single-status path; the per-repo map mirrors the same entry under an // empty key so consumers using only byEnvironmentRepo still see it. const repoName = gitStatus.repository_name ?? ""; const repoMap = (draft.gitStatus.byEnvironmentRepo[envKey] ??= {}); const existingRepo = repoMap[repoName]; const repoChanged = !existingRepo || hasGitStatusChanged(existingRepo, gitStatus); if (isDebug()) { debugGit("setGitStatus", { sessionId, envKey, usingFallbackKey: envKey === sessionId, repoName, prevFileCount: Object.keys(existingRepo?.files ?? {}).length, nextFileCount: Object.keys(gitStatus.files ?? {}).length, prevRepoKeys: Object.keys(repoMap), willMutate: gitStatusWouldMutate(draft.gitStatus, envKey, gitStatus), }); } if (repoChanged) { repoMap[repoName] = gitStatus; } if (repoName === "") { const existing = draft.gitStatus.byEnvironmentId[envKey]; if (!existing || hasGitStatusChanged(existing, gitStatus)) { draft.gitStatus.byEnvironmentId[envKey] = gitStatus; } } else if (repoChanged) { // Multi-repo: only mirror into the legacy map when this repo's entry changed. draft.gitStatus.byEnvironmentId[envKey] = gitStatus; } }), clearGitStatus: (sessionId) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; delete draft.gitStatus.byEnvironmentId[envKey]; delete draft.gitStatus.byEnvironmentRepo[envKey]; }), clearLegacyGitStatusEntry: (sessionId) => set((draft) => { // Drops the single-repo (empty-repo-name) entries so a session that just // transitioned to multi-repo via add_branch_to_task stops surfacing the // pre-transition snapshot — its workspace tracker was replaced on the // backend and will never emit another update under the empty key. The // per-repo entries (real repo names) are intentionally left in place; // they continue to receive fresh status updates from the new trackers. const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; const repoMap = draft.gitStatus.byEnvironmentRepo[envKey]; if (repoMap && "" in repoMap) { delete repoMap[""]; } delete draft.gitStatus.byEnvironmentId[envKey]; }), registerSessionEnvironment: (sessionId, environmentId) => set((draft) => { draft.environmentIdBySessionId[sessionId] = environmentId; migrateEnvKeyedData(draft, sessionId, environmentId); }), setContextWindow: (sessionId, contextWindow) => set((draft) => { draft.contextWindow.bySessionId[sessionId] = contextWindow; }), ...buildSessionCommitActions(set), setAvailableCommands: (sessionId, commands) => set((draft) => { draft.availableCommands.bySessionId[sessionId] = commands; }), clearAvailableCommands: (sessionId) => set((draft) => { delete draft.availableCommands.bySessionId[sessionId]; }), setSessionMode: (sessionId, modeId, availableModes) => set((draft) => { const existing = draft.sessionMode.bySessionId[sessionId]; draft.sessionMode.bySessionId[sessionId] = { currentModeId: modeId, availableModes: availableModes ?? existing?.availableModes ?? [], }; }), clearSessionMode: (sessionId) => set((draft) => { delete draft.sessionMode.bySessionId[sessionId]; }), setAgentCapabilities: (sessionId, caps) => set((draft) => { draft.agentCapabilities.bySessionId[sessionId] = caps; }), setSessionModels: (sessionId, data) => set((draft) => { draft.sessionModels.bySessionId[sessionId] = data; }), setPromptUsage: (sessionId, usage) => set((draft) => { draft.promptUsage.bySessionId[sessionId] = usage; }), setSessionTodos: (sessionId, entries) => set((draft) => { draft.sessionTodos.bySessionId[sessionId] = entries; }), ...buildUserShellActions(set), });