import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk"; import { opencodeClient } from "@/lib/opencode/client"; import { getSafeStorage } from "./utils/safeStorage"; import type { WorktreeMetadata } from "@/types/worktree"; import { archiveWorktree, getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService"; import { useDirectoryStore } from "./useDirectoryStore"; import { checkIsGitRepository } from "@/lib/gitApi"; interface SessionState { sessions: Session[]; currentSessionId: string | null; lastLoadedDirectory: string | null; isLoading: boolean; error: string | null; webUICreatedSessions: Set; worktreeMetadata: Map; availableWorktrees: WorktreeMetadata[]; } interface SessionActions { loadSessions: () => Promise; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise; deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; setCurrentSession: (id: string | null) => void; clearError: () => void; getSessionsByDirectory: (directory: string) => Session[]; applySessionMetadata: (sessionId: string, metadata: Partial) => void; isOpenChamberCreatedSession: (sessionId: string) => boolean; markSessionAsOpenChamberCreated: (sessionId: string) => void; initializeNewOpenChamberSession: (sessionId: string, agents: Record[]) => void; setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void; getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined; setSessionDirectory: (sessionId: string, directory: string | null) => void; updateSession: (session: Session) => void; } type SessionStore = SessionState & SessionActions; const safeStorage = getSafeStorage(); const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory"; const WORKTREE_ROOT = ".openchamber"; type SessionSelectionMap = Record; const readSessionSelectionMap = (): SessionSelectionMap => { try { const raw = safeStorage.getItem(SESSION_SELECTION_STORAGE_KEY); if (!raw) { return {}; } const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { return {}; } return Object.entries(parsed as Record).reduce((acc, [directory, sessionId]) => { if (typeof directory === "string" && typeof sessionId === "string" && directory.length > 0 && sessionId.length > 0) { acc[directory] = sessionId; } return acc; }, {}); } catch { return {}; } }; let sessionSelectionCache: SessionSelectionMap | null = null; const getSessionSelectionMap = (): SessionSelectionMap => { if (!sessionSelectionCache) { sessionSelectionCache = readSessionSelectionMap(); } return sessionSelectionCache; }; const persistSessionSelectionMap = (map: SessionSelectionMap) => { sessionSelectionCache = map; try { safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(map)); } catch { /* ignored */ } }; const getStoredSessionForDirectory = (directory: string | null | undefined): string | null => { if (!directory) { return null; } const map = getSessionSelectionMap(); const selection = map[directory]; return typeof selection === "string" ? selection : null; }; const storeSessionForDirectory = (directory: string | null | undefined, sessionId: string | null) => { if (!directory) { return; } const map = { ...getSessionSelectionMap() }; if (sessionId) { map[directory] = sessionId; } else { delete map[directory]; } persistSessionSelectionMap(map); }; const clearInvalidSessionSelection = (directory: string | null | undefined, validIds: Iterable) => { if (!directory) { return; } const storedSelection = getStoredSessionForDirectory(directory); if (!storedSelection) { return; } const validSet = new Set(validIds); if (!validSet.has(storedSelection)) { const map = { ...getSessionSelectionMap() }; delete map[directory]; persistSessionSelectionMap(map); } }; const archiveSessionWorktree = async ( metadata: WorktreeMetadata, options?: { deleteRemoteBranch?: boolean; remoteName?: string } ) => { const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined)); await archiveWorktree({ projectDirectory: metadata.projectDirectory, path: metadata.path, branch: metadata.branch, force: Boolean(status?.isDirty), deleteRemote: Boolean(options?.deleteRemoteBranch), remote: options?.remoteName, }); }; const normalizePath = (value?: string | null): string | null => { if (typeof value !== "string") { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } const replaced = trimmed.replace(/\\/g, "/"); if (replaced === "/") { return "/"; } return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; }; const getSessionDirectory = (sessions: Session[], sessionId: string): string | null => { const target = sessions.find((session) => session.id === sessionId); if (!target) { return null; } return normalizePath((target as { directory?: string | null }).directory ?? null); }; const hydrateSessionWorktreeMetadata = async ( sessions: Session[], projectDirectory: string | null, existingMetadata: Map ): Promise | null> => { const normalizedProject = normalizePath(projectDirectory); if (!normalizedProject || sessions.length === 0) { return null; } const sessionsWithDirectory = sessions .map((session) => ({ id: session.id, directory: normalizePath((session as { directory?: string }).directory) })) .filter((entry): entry is { id: string; directory: string } => Boolean(entry.directory)); if (sessionsWithDirectory.length === 0) { return null; } let worktreeEntries; try { worktreeEntries = await listWorktrees(normalizedProject); } catch (error) { console.debug("Failed to hydrate worktree metadata from git worktree list:", error); return null; } if (!Array.isArray(worktreeEntries) || worktreeEntries.length === 0) { let mutated = false; const next = new Map(existingMetadata); sessionsWithDirectory.forEach(({ id }) => { if (next.delete(id)) { mutated = true; } }); return mutated ? next : null; } const worktreeMapByPath = new Map(); worktreeEntries.forEach((info) => { const metadata = mapWorktreeToMetadata(normalizedProject, info); const normalizedPath = normalizePath(metadata.path) ?? metadata.path; if (normalizedPath === normalizedProject) { return; } worktreeMapByPath.set(normalizedPath, metadata); }); let mutated = false; const next = new Map(existingMetadata); sessionsWithDirectory.forEach(({ id, directory }) => { const metadata = worktreeMapByPath.get(directory); if (!metadata) { if (next.delete(id)) { mutated = true; } return; } const previous = next.get(id); if (!previous || previous.path !== metadata.path || previous.branch !== metadata.branch || previous.label !== metadata.label) { next.set(id, metadata); mutated = true; } }); return mutated ? next : null; }; export const useSessionStore = create()( devtools( persist( (set, get) => ({ sessions: [], currentSessionId: null, lastLoadedDirectory: null, isLoading: false, error: null, webUICreatedSessions: new Set(), worktreeMetadata: new Map(), availableWorktrees: [], loadSessions: async () => { set({ isLoading: true, error: null }); try { const directoryStore = useDirectoryStore.getState(); const projectDirectory = directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null; const apiClient = opencodeClient.getApiClient(); const fetchSessions = async (directoryParam?: string | null): Promise => { const response = await apiClient.session.list({ query: directoryParam ? { directory: directoryParam } : undefined, }); return Array.isArray(response.data) ? response.data : []; }; const normalizedProject = normalizePath(projectDirectory); const isGitRepo = normalizedProject ? await checkIsGitRepository(normalizedProject).catch(() => false) : false; const parentSessions = await fetchSessions(normalizedProject || null); const subdirectorySessions: Session[] = []; let discoveredWorktrees: WorktreeMetadata[] = []; if (projectDirectory && isGitRepo && normalizedProject) { const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`; try { const candidates = new Set(); // Check if .openchamber directory exists before trying to list it const projectEntries = await opencodeClient.listLocalDirectory(normalizedProject); const worktreeDirExists = projectEntries.some( (entry) => entry.isDirectory && entry.name === WORKTREE_ROOT ); if (worktreeDirExists) { const entries = await opencodeClient.listLocalDirectory(worktreeRoot); entries .filter((entry) => entry.isDirectory) .forEach((entry) => { const isAbsolutePath = /^([A-Za-z]:)?\//.test(entry.path); const resolvedPath = isAbsolutePath ? entry.path : `${worktreeRoot}/${entry.name}`; candidates.add(normalizePath(resolvedPath) ?? resolvedPath); }); } const listedWorktrees = await listWorktrees(normalizedProject); if (Array.isArray(listedWorktrees)) { discoveredWorktrees = listedWorktrees .map((info) => mapWorktreeToMetadata(normalizedProject, info)) .filter((meta) => meta.path.includes(`/${WORKTREE_ROOT}/`)); discoveredWorktrees.forEach((meta) => candidates.add(meta.path)); } if (candidates.size > 0) { const results = await Promise.allSettled( Array.from(candidates).map((path) => fetchSessions(path)) ); results.forEach((result) => { if (result.status === "fulfilled" && Array.isArray(result.value)) { subdirectorySessions.push(...result.value); } }); } } catch { discoveredWorktrees = []; } } const validPaths = new Set(); if (normalizedProject) { validPaths.add(normalizedProject); } discoveredWorktrees.forEach((meta) => { const normalized = normalizePath(meta.path); if (normalized) { validPaths.add(normalized); } }); const mergedSessions = [...parentSessions, ...subdirectorySessions].filter((session) => { const rawDir = (session as { directory?: string | null }).directory ?? normalizedProject ?? null; const normalizedDir = normalizePath(rawDir); if (!normalizedDir) { return false; } return validPaths.has(normalizedDir); }); const stateSnapshot = get(); const previousDirectory = stateSnapshot.lastLoadedDirectory ?? null; const directoryChanged = projectDirectory !== previousDirectory; let nextSessions = [...mergedSessions]; let nextCurrentId = stateSnapshot.currentSessionId; const ensureSessionPresent = (session: Session) => { nextSessions = [session, ...nextSessions.filter((item) => item.id !== session.id)]; }; if (directoryChanged) { nextCurrentId = nextSessions.length > 0 ? nextSessions[0].id : null; } else { if (nextCurrentId) { const hasCurrent = nextSessions.some((session) => session.id === nextCurrentId); if (!hasCurrent) { const persistedSession = stateSnapshot.sessions.find((session) => session.id === nextCurrentId); if (persistedSession) { ensureSessionPresent(persistedSession); } else { try { const resolvedSession = await opencodeClient.getSession(nextCurrentId); ensureSessionPresent(resolvedSession); } catch { nextCurrentId = nextSessions.length > 0 ? nextSessions[0].id : null; } } } } else { nextCurrentId = nextSessions.length > 0 ? nextSessions[0].id : null; } } const dedupedSessions = nextSessions.reduce((accumulator, session) => { if (!accumulator.some((existing) => existing.id === session.id)) { accumulator.push(session); } return accumulator; }, []); if (nextCurrentId && !dedupedSessions.some((session) => session.id === nextCurrentId)) { nextCurrentId = dedupedSessions.length > 0 ? dedupedSessions[0].id : null; } const validSessionIds = new Set(dedupedSessions.map((session) => session.id)); const resolveSelectionDirectory = (sessionId: string | null): string | null => { if (!sessionId) { return null; } const sessionDir = getSessionDirectory(dedupedSessions, sessionId); if (sessionDir) { return sessionDir; } const persistedDir = getSessionDirectory(stateSnapshot.sessions, sessionId); if (persistedDir) { return persistedDir; } return null; }; const selectionDirectoryKey = resolveSelectionDirectory(nextCurrentId) ?? normalizedProject ?? projectDirectory ?? null; if (projectDirectory) { clearInvalidSessionSelection(projectDirectory, validSessionIds); } if (selectionDirectoryKey) { clearInvalidSessionSelection(selectionDirectoryKey, validSessionIds); const storedSelection = getStoredSessionForDirectory(selectionDirectoryKey); if (storedSelection && validSessionIds.has(storedSelection)) { nextCurrentId = storedSelection; } } let hydratedMetadata: Map | null = null; try { hydratedMetadata = await hydrateSessionWorktreeMetadata( dedupedSessions, projectDirectory, stateSnapshot.worktreeMetadata ); } catch (metadataError) { console.debug("Failed to refresh worktree metadata during session load:", metadataError); } const nextWorktreeMetadata = (() => { const source = hydratedMetadata ?? stateSnapshot.worktreeMetadata; if (!directoryChanged || !normalizedProject) { return source; } const filtered = new Map(); source.forEach((meta, key) => { if (normalizePath(meta.projectDirectory) === normalizedProject) { filtered.set(key, meta); } }); return filtered; })(); const resolvedDirectoryForCurrent = (() => { if (!nextCurrentId) { return normalizedProject ?? null; } const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path; if (metadataPath) { return normalizePath(metadataPath) ?? metadataPath; } const sessionDir = getSessionDirectory(dedupedSessions, nextCurrentId); if (sessionDir) { return sessionDir; } return normalizedProject ?? null; })(); try { opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined); } catch (error) { console.warn("Failed to sync OpenCode directory after session load:", error); } set({ sessions: dedupedSessions, currentSessionId: nextCurrentId, lastLoadedDirectory: projectDirectory, isLoading: false, worktreeMetadata: nextWorktreeMetadata, availableWorktrees: discoveredWorktrees, }); storeSessionForDirectory(resolvedDirectoryForCurrent ?? projectDirectory, nextCurrentId); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to load sessions", isLoading: false, }); } }, createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => { set({ error: null }); const directoryStore = useDirectoryStore.getState(); const fallbackDirectory = normalizePath(directoryStore.currentDirectory); const targetDirectory = normalizePath(directoryOverride ?? opencodeClient.getDirectory() ?? fallbackDirectory); const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const previousState = get(); const existingIds = new Set(previousState.sessions.map((s) => s.id)); const optimisticSession: Session = { id: tempId, title: title || "New session", parentID: parentID ?? undefined, directory: targetDirectory ?? null, projectID: (previousState.sessions[0] as { projectID?: string })?.projectID ?? "", version: "0.0.0", time: { created: Date.now(), updated: Date.now(), }, summary: undefined, share: undefined, } as Session; set((state) => ({ sessions: [optimisticSession, ...state.sessions], currentSessionId: tempId, webUICreatedSessions: new Set([...state.webUICreatedSessions, tempId]), isLoading: false, })); if (targetDirectory) { try { opencodeClient.setDirectory(targetDirectory); } catch (error) { console.warn("Failed to sync OpenCode directory after session creation:", error); } } const replaceOptimistic = (real: Session) => { set((state) => { const updatedSessions = state.sessions.map((item) => (item.id === tempId ? real : item)); return { sessions: updatedSessions, currentSessionId: real.id, webUICreatedSessions: new Set([ ...Array.from(state.webUICreatedSessions).filter((id) => id !== tempId), real.id, ]), }; }); storeSessionForDirectory(targetDirectory ?? null, real.id); }; const pollForSession = async (): Promise => { const apiClient = opencodeClient.getApiClient(); const attempts = 20; for (let attempt = 0; attempt < attempts; attempt += 1) { try { const response = await apiClient.session.list({ query: targetDirectory ? { directory: targetDirectory } : undefined, }); const list = Array.isArray(response.data) ? response.data : []; const candidate = list.find((entry) => { if (existingIds.has(entry.id)) return false; if (title && entry.title && entry.title !== title) return false; return true; }); if (candidate) { return candidate as Session; } } catch (pollError) { console.debug("Session poll attempt failed:", pollError); } await new Promise((resolve) => setTimeout(resolve, 2000)); } return null; }; try { const createRequest = () => opencodeClient.createSession({ title, parentID: parentID ?? undefined }); let session: Session | null = null; try { session = targetDirectory ? await opencodeClient.withDirectory(targetDirectory, createRequest) : await createRequest(); } catch (creationError) { console.warn("Direct session create failed or timed out, falling back to polling:", creationError); } if (!session) { session = await pollForSession(); } if (session) { replaceOptimistic(session); return session; } set((state) => ({ sessions: state.sessions.filter((s) => s.id !== tempId), currentSessionId: previousState.currentSessionId, webUICreatedSessions: new Set( Array.from(state.webUICreatedSessions).filter((id) => id !== tempId) ), isLoading: false, error: "Failed to create session", })); return null; } catch (error) { set((state) => ({ sessions: state.sessions.filter((s) => s.id !== tempId), currentSessionId: previousState.currentSessionId, webUICreatedSessions: new Set( Array.from(state.webUICreatedSessions).filter((id) => id !== tempId) ), isLoading: false, error: error instanceof Error ? error.message : "Failed to create session", })); return null; } }, deleteSession: async (id: string, options) => { set({ isLoading: true, error: null }); const metadata = get().worktreeMetadata.get(id); const sessionDirectory = getSessionDirectory(get().sessions, id); const overrideDirectory = metadata?.path ?? sessionDirectory; let archivedMetadata: WorktreeMetadata | null = null; try { if (metadata && options?.archiveWorktree) { await archiveSessionWorktree(metadata, { deleteRemoteBranch: options?.deleteRemoteBranch, remoteName: options?.remoteName, }); archivedMetadata = metadata; } const deleteRequest = () => opencodeClient.deleteSession(id); const success = overrideDirectory ? await opencodeClient.withDirectory(overrideDirectory, deleteRequest) : await deleteRequest(); if (!success) { set((state) => { const update: Partial = { isLoading: false, error: "Failed to delete session", }; if (archivedMetadata) { const nextMetadata = new Map(state.worktreeMetadata); nextMetadata.delete(id); update.worktreeMetadata = nextMetadata; } return update; }); return false; } let nextCurrentId: string | null = null; set((state) => { const filteredSessions = state.sessions.filter((s) => s.id !== id); nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId; const nextMetadata = new Map(state.worktreeMetadata); nextMetadata.delete(id); const nextAvailableWorktrees = options?.archiveWorktree && metadata ? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadata.path)) : state.availableWorktrees; return { sessions: filteredSessions, currentSessionId: nextCurrentId, isLoading: false, worktreeMetadata: nextMetadata, availableWorktrees: nextAvailableWorktrees, }; }); const directoryToStore = overrideDirectory ?? opencodeClient.getDirectory() ?? null; storeSessionForDirectory(directoryToStore, nextCurrentId); return true; } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete session"; if (archivedMetadata) { set((state) => { const nextMetadata = new Map(state.worktreeMetadata); nextMetadata.delete(id); return { worktreeMetadata: nextMetadata, error: message, isLoading: false, }; }); } else { set({ error: message, isLoading: false, }); } return false; } }, deleteSessions: async ( ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean } ) => { const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); if (uniqueIds.length === 0) { return { deletedIds: [], failedIds: [] }; } const silent = options?.silent === true; if (!silent) { set({ isLoading: true, error: null }); } const deletedIds: string[] = []; const failedIds: string[] = []; const archivedIds = new Set(); const removedWorktrees: Array<{ path: string; projectDirectory: string }> = []; const archivedWorktreePaths = new Set(); for (const id of uniqueIds) { try { const metadata = get().worktreeMetadata.get(id); const sessionDirectory = getSessionDirectory(get().sessions, id); const overrideDirectory = metadata?.path ?? sessionDirectory; if (metadata && options?.archiveWorktree && !archivedWorktreePaths.has(metadata.path)) { await archiveSessionWorktree(metadata, { deleteRemoteBranch: options?.deleteRemoteBranch, remoteName: options?.remoteName, }); archivedIds.add(id); removedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); archivedWorktreePaths.add(metadata.path); } const deleteRequest = () => opencodeClient.deleteSession(id); const success = overrideDirectory ? await opencodeClient.withDirectory(overrideDirectory, deleteRequest) : await deleteRequest(); if (success) { deletedIds.push(id); if (metadata?.path && !removedWorktrees.some((entry) => entry.path === metadata.path)) { removedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); } } else { failedIds.push(id); } } catch { failedIds.push(id); } } const directoryStore = useDirectoryStore.getState(); removedWorktrees.forEach(({ path, projectDirectory }) => { if (directoryStore.currentDirectory === path) { directoryStore.setDirectory(projectDirectory, { showOverlay: false }); } }); const deletedSet = new Set(deletedIds); const errorMessage = failedIds.length > 0 ? (failedIds.length === uniqueIds.length ? "Failed to delete sessions" : "Failed to delete some sessions") : null; let nextCurrentId: string | null = null; set((state) => { const filteredSessions = state.sessions.filter((session) => !deletedSet.has(session.id)); if (state.currentSessionId && deletedSet.has(state.currentSessionId)) { nextCurrentId = null; } else { nextCurrentId = state.currentSessionId; } const nextMetadata = new Map(state.worktreeMetadata); for (const removedId of deletedSet) { nextMetadata.delete(removedId); } for (const archivedId of archivedIds) { nextMetadata.delete(archivedId); } const removedPaths = new Set( removedWorktrees .map((entry) => normalizePath(entry.path)) .filter((p): p is string => Boolean(p)) ); const nextAvailableWorktrees = removedPaths.size > 0 ? state.availableWorktrees.filter( (entry) => !removedPaths.has(normalizePath(entry.path) ?? entry.path) ) : state.availableWorktrees; return { sessions: filteredSessions, currentSessionId: nextCurrentId, ...(silent ? {} : { isLoading: false, error: errorMessage }), worktreeMetadata: nextMetadata, availableWorktrees: nextAvailableWorktrees, }; }); const directory = opencodeClient.getDirectory() ?? null; storeSessionForDirectory(directory, nextCurrentId); return { deletedIds, failedIds }; }, updateSessionTitle: async (id: string, title: string) => { try { const sessionDirectory = getSessionDirectory(get().sessions, id); const metadata = get().worktreeMetadata.get(id); const updateRequest = () => opencodeClient.updateSession(id, title); const overrideDirectory = metadata?.path ?? sessionDirectory; const updatedSession = overrideDirectory ? await opencodeClient.withDirectory(overrideDirectory, updateRequest) : await updateRequest(); set((state) => ({ sessions: state.sessions.map((s) => (s.id === id ? updatedSession : s)), })); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to update session title", }); } }, shareSession: async (id: string) => { try { const sessionDirectory = getSessionDirectory(get().sessions, id); const apiClient = opencodeClient.getApiClient(); const metadata = get().worktreeMetadata.get(id); const overrideDirectory = metadata?.path ?? sessionDirectory; const shareRequest = async () => { const directory = sessionDirectory ?? opencodeClient.getDirectory(); return apiClient.session.share({ path: { id }, query: directory ? { directory } : undefined, }); }; const response = overrideDirectory ? await opencodeClient.withDirectory(overrideDirectory, shareRequest) : await shareRequest(); if (response.data) { set((state) => ({ sessions: state.sessions.map((s) => (s.id === id ? response.data : s)), })); return response.data; } return null; } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to share session", }); return null; } }, unshareSession: async (id: string) => { try { const sessionDirectory = getSessionDirectory(get().sessions, id); const apiClient = opencodeClient.getApiClient(); const metadata = get().worktreeMetadata.get(id); const overrideDirectory = metadata?.path ?? sessionDirectory; const unshareRequest = async () => { const directory = sessionDirectory ?? opencodeClient.getDirectory(); return apiClient.session.unshare({ path: { id }, query: directory ? { directory } : undefined, }); }; const response = overrideDirectory ? await opencodeClient.withDirectory(overrideDirectory, unshareRequest) : await unshareRequest(); if (response.data) { set((state) => ({ sessions: state.sessions.map((s) => (s.id === id ? response.data : s)), })); return response.data; } return null; } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to unshare session", }); return null; } }, setCurrentSession: (id: string | null) => { set({ currentSessionId: id, error: null }); const directory = opencodeClient.getDirectory() ?? null; storeSessionForDirectory(directory, id); }, clearError: () => { set({ error: null }); }, getSessionsByDirectory: () => { const { sessions } = get(); return sessions; }, applySessionMetadata: (sessionId, metadata) => { if (!sessionId || !metadata) { return; } set((state) => { const index = state.sessions.findIndex((session) => session.id === sessionId); if (index === -1) { return state; } const existingSession = state.sessions[index]; const mergedTime = metadata.time ? { ...existingSession.time, ...metadata.time } : existingSession.time; const mergedSummary = metadata.summary === undefined ? existingSession.summary : metadata.summary || undefined; const mergedShare = metadata.share === undefined ? existingSession.share : metadata.share || undefined; const mergedSession: Session = { ...existingSession, ...metadata, time: mergedTime, summary: mergedSummary, share: mergedShare, }; const hasChanged = mergedSession.title !== existingSession.title || mergedSession.parentID !== existingSession.parentID || mergedSession.directory !== existingSession.directory || mergedSession.version !== existingSession.version || mergedSession.projectID !== existingSession.projectID || JSON.stringify(mergedSession.time) !== JSON.stringify(existingSession.time) || JSON.stringify(mergedSession.summary ?? null) !== JSON.stringify(existingSession.summary ?? null) || JSON.stringify(mergedSession.share ?? null) !== JSON.stringify(existingSession.share ?? null); const sessions = [...state.sessions]; sessions[index] = hasChanged ? mergedSession : existingSession; return hasChanged ? ({ sessions } as Partial) : state; }); }, isOpenChamberCreatedSession: (sessionId: string) => { const { webUICreatedSessions } = get(); return webUICreatedSessions.has(sessionId); }, markSessionAsOpenChamberCreated: (sessionId: string) => { set((state) => { const newOpenChamberCreatedSessions = new Set(state.webUICreatedSessions); newOpenChamberCreatedSessions.add(sessionId); return { webUICreatedSessions: newOpenChamberCreatedSessions, }; }); }, initializeNewOpenChamberSession: (sessionId: string) => { const { markSessionAsOpenChamberCreated } = get(); markSessionAsOpenChamberCreated(sessionId); }, setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { if (!sessionId) { return; } set((state) => { const next = new Map(state.worktreeMetadata); if (metadata) { next.set(sessionId, metadata); } else { next.delete(sessionId); } return { worktreeMetadata: next }; }); }, getWorktreeMetadata: (sessionId: string) => { if (!sessionId) { return undefined; } return get().worktreeMetadata.get(sessionId); }, setSessionDirectory: (sessionId: string, directory: string | null) => { if (!sessionId) { return; } const currentSessions = get().sessions; const targetIndex = currentSessions.findIndex((session) => session.id === sessionId); if (targetIndex === -1) { return; } const existingSession = currentSessions[targetIndex]; const previousDirectory = existingSession.directory ?? null; const normalizedDirectory = directory ?? undefined; if (previousDirectory === (normalizedDirectory ?? null)) { return; } set((state) => { const sessions = [...state.sessions]; const updatedSession = { ...sessions[targetIndex] } as Record; if (normalizedDirectory !== undefined) { updatedSession.directory = normalizedDirectory; } else { delete updatedSession.directory; } sessions[targetIndex] = updatedSession as Session; return { sessions }; }); if (previousDirectory) { storeSessionForDirectory(previousDirectory, null); } if (directory) { storeSessionForDirectory(directory, sessionId); } }, updateSession: (session: Session) => { set((state) => ({ sessions: state.sessions.map((s) => (s.id === session.id ? session : s)), })); }, }), { name: "session-store", storage: createJSONStorage(() => getSafeStorage()), partialize: (state) => ({ currentSessionId: state.currentSessionId, sessions: state.sessions, lastLoadedDirectory: state.lastLoadedDirectory, webUICreatedSessions: Array.from(state.webUICreatedSessions), worktreeMetadata: Array.from(state.worktreeMetadata.entries()), availableWorktrees: state.availableWorktrees, }), merge: (persistedState, currentState) => { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; if (!isRecord(persistedState)) { return currentState; } const persistedSessions = Array.isArray(persistedState.sessions) ? (persistedState.sessions as Session[]) : currentState.sessions; const persistedCurrentSessionId = typeof persistedState.currentSessionId === "string" || persistedState.currentSessionId === null ? (persistedState.currentSessionId as string | null) : currentState.currentSessionId; const webUiSessionsArray = Array.isArray(persistedState.webUICreatedSessions) ? (persistedState.webUICreatedSessions as string[]) : []; const persistedWorktreeEntries = Array.isArray(persistedState.worktreeMetadata) ? (persistedState.worktreeMetadata as Array<[string, WorktreeMetadata]>) : []; const persistedAvailableWorktrees = Array.isArray(persistedState.availableWorktrees) ? (persistedState.availableWorktrees as WorktreeMetadata[]) : currentState.availableWorktrees; const lastLoadedDirectory = typeof persistedState.lastLoadedDirectory === "string" ? persistedState.lastLoadedDirectory : currentState.lastLoadedDirectory ?? null; return { ...currentState, ...persistedState, sessions: persistedSessions, currentSessionId: persistedCurrentSessionId, webUICreatedSessions: new Set(webUiSessionsArray), worktreeMetadata: new Map(persistedWorktreeEntries), availableWorktrees: persistedAvailableWorktrees, lastLoadedDirectory, }; }, } ), { name: "session-store", } ) );