Files
openchamber/packages/ui/src/stores/sessionStore.ts
T
Bohdan TriapitsynandIuliia Ivashko 321cc7252a Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary
Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements.

## Key Changes

**Sidebar & Navigation Redesign**
- Redesigned sessions sidebar layout with unified button primitives
- Added activity sections with project grouping and improved session organization
- Refined sidebar corners, spacing, and visual hierarchy
- Removed NavRail component in favor of streamlined sidebar
- Stabilized sessions bar toggle position in fullscreen mode

**Performance Optimizations**
- Reduced chat streaming CPU usage and storage churn
- Optimized task tool polling and live timers with debouncing
- Prevented chat state races and reduced background request load
- Debounced draft writes and coalesced session reloads
- Optimized message store updates and turn tracking

**Theme & Visual System**
- Added theme-aware window corners (desktop) and border radius tokens
- Introduced glassmorphism effects on desktop sidebar
- Added backdrop blur to UI elements

**Chat Experience**
- Added session-based permission auto-accept toggle in chat input
- Polished permission shield UX with improved icon sizing and spacing
- Fixed chat scroll-to-bottom behavior and timeline tracking
- Enhanced tool output display with better path label detection
- Removed duplicate draft context details in chat header
- Added text selection menu to chat messages

**Git Improvements**
- Refreshed git history visual design with cleaner dividers
- Added remote removal action in sync selector
- Stabilized git polling to prevent excessive requests
- Improved tool output rendering for git operations

**Settings & Panels**
- Fixed mobile scrolling on settings pages
- Made outside-click settings close instantly
- Reduced settings load churn and CPU spikes
- Improved services dropdown layout and spacing
- Softened panel resize handles

**Desktop Integration**
- Synced macOS window theme with app theme
- Restored window dragging in sidebar header zones
- Fixed system window corners on macOS
- Improved header session metadata and action controls

**Button & Component Standardization**
- Unified button primitives across all components
- Standardized destructive action patterns
- Removed unused button variants (button-large, button-small)
- Aligned context tab close hit areas

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
2026-03-20 01:01:03 +02:00

1748 lines
83 KiB
TypeScript

import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { Session } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client";
import { getSafeStorage } from "./utils/safeStorage";
import type { WorktreeMetadata } from "@/types/worktree";
import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus";
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
import { useDirectoryStore } from "./useDirectoryStore";
import { useProjectsStore } from "./useProjectsStore";
import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus";
import type { ProjectEntry } from "@/lib/api/types";
import { checkIsGitRepository } from "@/lib/gitApi";
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
import { isMissingGlobalSessionsEndpointError, readNextCursor, type GlobalSessionRecord } from "./globalSessions";
interface SessionState {
sessions: Session[];
archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
currentSessionId: string | null;
lastLoadedDirectory: string | null;
isLoading: boolean;
error: string | null;
webUICreatedSessions: Set<string>;
worktreeMetadata: Map<string, WorktreeMetadata>;
availableWorktrees: WorktreeMetadata[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
}
interface SessionActions {
loadSessions: () => Promise<void>;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
archiveSession: (id: string) => Promise<boolean>;
archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
updateSessionTitle: (id: string, title: string) => Promise<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void;
clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[];
getDirectoryForSession: (sessionId: string) => string | null;
applySessionMetadata: (sessionId: string, metadata: Partial<Session>) => void;
isOpenChamberCreatedSession: (sessionId: string) => boolean;
markSessionAsOpenChamberCreated: (sessionId: string) => void;
initializeNewOpenChamberSession: (sessionId: string, agents: Record<string, unknown>[]) => void;
setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void;
getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined;
setSessionDirectory: (sessionId: string, directory: string | null) => void;
updateSession: (session: Session) => void;
removeSessionFromStore: (sessionId: string) => void;
}
type SessionStore = SessionState & SessionActions;
const safeStorage = getSafeStorage();
const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory";
type SessionSelectionMap = Record<string, string>;
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<string, unknown>).reduce<SessionSelectionMap>((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;
let loadSessionsRequestSeq = 0;
let loadSessionsInFlight: Promise<void> | null = null;
let loadSessionsQueued = false;
let persistSelectionTimer: ReturnType<typeof setTimeout> | undefined;
let pendingSelectionMap: SessionSelectionMap | null = null;
type ProjectSessionResult = {
projectId: string;
projectPath: string | null;
sessions: Session[];
discoveredWorktrees: WorktreeMetadata[];
validPaths: Set<string>;
};
type ProjectSessionCacheEntry = {
cachedAt: number;
result: ProjectSessionResult;
};
type ProjectRepoCacheEntry = {
cachedAt: number;
isGitRepo: boolean;
};
const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000;
const projectSessionCache = new Map<string, ProjectSessionCacheEntry>();
const projectRepoStatusCache = new Map<string, ProjectRepoCacheEntry>();
const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => {
const key = normalizePath(projectPath) ?? projectPath;
projectSessionCache.set(key, { cachedAt: Date.now(), result });
};
const pruneProjectCaches = (validProjectPaths: Iterable<string>) => {
const valid = new Set<string>();
for (const path of validProjectPaths) {
const normalized = normalizePath(path) ?? path;
if (normalized) {
valid.add(normalized);
}
}
for (const key of projectSessionCache.keys()) {
if (!valid.has(key)) {
projectSessionCache.delete(key);
}
}
for (const key of projectRepoStatusCache.keys()) {
if (!valid.has(key)) {
projectRepoStatusCache.delete(key);
}
}
};
const getProjectRepoStatus = async (projectPath: string): Promise<boolean> => {
const key = normalizePath(projectPath) ?? projectPath;
const cached = projectRepoStatusCache.get(key);
if (cached && Date.now() - cached.cachedAt <= PROJECT_REPO_STATUS_CACHE_TTL_MS) {
return cached.isGitRepo;
}
const isGitRepo = await checkIsGitRepository(key).catch(() => false);
projectRepoStatusCache.set(key, { cachedAt: Date.now(), isGitRepo });
return isGitRepo;
};
const getSessionSelectionMap = (): SessionSelectionMap => {
if (!sessionSelectionCache) {
sessionSelectionCache = readSessionSelectionMap();
}
return sessionSelectionCache;
};
const persistSessionSelectionMap = (map: SessionSelectionMap) => {
sessionSelectionCache = map;
pendingSelectionMap = map;
clearTimeout(persistSelectionTimer);
persistSelectionTimer = setTimeout(() => {
try {
safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(map));
pendingSelectionMap = null;
} catch { /* ignored */ }
}, 300);
};
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
if (pendingSelectionMap !== null) {
clearTimeout(persistSelectionTimer);
try {
safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(pendingSelectionMap));
} catch { /* ignored */ }
pendingSelectionMap = null;
}
});
}
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<string>) => {
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; deleteLocalBranch?: boolean; remoteName?: string }
) => {
const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined));
const projects = useProjectsStore.getState().projects;
const normalizedProject = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory;
const projectEntry = projects.find((project) => normalizePath(project.path) === normalizedProject);
const projectRef = {
id: projectEntry?.id ?? `path:${normalizedProject}`,
path: normalizedProject,
};
await removeProjectWorktree(
projectRef,
status ? ({ ...metadata, status } as WorktreeMetadata) : metadata,
{
deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName,
}
);
};
const deleteSessionOnServer = async (sessionId: string, directory?: string | null): Promise<boolean> => {
const apiClient = opencodeClient.getApiClient();
const normalizedDirectory = normalizePath(directory ?? null);
const response = await apiClient.session.delete({
sessionID: sessionId,
...(normalizedDirectory ? { directory: normalizedDirectory } : {}),
});
return Boolean(response.data);
};
const setSessionArchivedOnServer = async (
sessionId: string,
archivedAt: number,
directory?: string | null,
): Promise<Session | null> => {
const apiClient = opencodeClient.getApiClient();
const normalizedDirectory = normalizePath(directory ?? null);
const response = await apiClient.session.update({
sessionID: sessionId,
...(normalizedDirectory ? { directory: normalizedDirectory } : {}),
time: { archived: archivedAt },
});
return response.data ?? null;
};
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 readVSCodeWorkspaceDirectory = (): string | null => {
if (typeof window === "undefined") {
return null;
}
const config = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__;
const workspaceFolder = typeof config?.workspaceFolder === "string" ? config.workspaceFolder : null;
return normalizePath(workspaceFolder);
};
const isVSCodeRuntime = (): boolean => {
if (typeof window === "undefined") return false;
const runtime = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
.__OPENCHAMBER_RUNTIME_APIS__?.runtime;
return Boolean(runtime?.isVSCode);
};
const vscodeDebugLog = (...args: unknown[]) => {
if (!streamDebugEnabled()) return;
if (!isVSCodeRuntime()) return;
console.log("[OpenChamber][VSCode][sessions]", ...args);
};
const dedupeSessionsById = (sessions: Session[]): Session[] => {
const map = new Map<string, Session>();
sessions.forEach((session) => {
if (!session || typeof session.id !== "string" || session.id.length === 0) {
return;
}
const existing = map.get(session.id);
if (!existing) {
map.set(session.id, session);
return;
}
const existingUpdated = (existing as { time?: { updated?: number | null } }).time?.updated ?? 0;
const candidateUpdated = (session as { time?: { updated?: number | null } }).time?.updated ?? 0;
if (candidateUpdated > existingUpdated) {
map.set(session.id, session);
}
});
return Array.from(map.values());
};
const buildSessionsByDirectory = (sessions: Session[]): Map<string, Session[]> => {
const map = new Map<string, Session[]>();
sessions.forEach((session) => {
const directory = normalizePath((session as { directory?: string | null }).directory ?? null);
if (!directory) {
return;
}
const existing = map.get(directory);
if (existing) {
existing.push(session);
} else {
map.set(directory, [session]);
}
});
for (const [key, value] of map.entries()) {
map.set(key, dedupeSessionsById(value));
}
return map;
};
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<string, WorktreeMetadata>,
preloadedWorktrees?: WorktreeMetadata[]
): Promise<Map<string, WorktreeMetadata> | 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: WorktreeMetadata[];
if (Array.isArray(preloadedWorktrees)) {
worktreeEntries = preloadedWorktrees;
} else {
try {
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
} catch (error) {
console.debug("Failed to hydrate worktree metadata from 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<string, WorktreeMetadata>();
worktreeEntries.forEach((metadata) => {
const normalizedPath = normalizePath(metadata.path) ?? metadata.path;
if (normalizedPath === normalizedProject) {
return;
}
worktreeMapByPath.set(normalizedPath, metadata);
});
let mutated = false;
const next = new Map(existingMetadata);
const mergeHydratedMetadata = (
hydrated: WorktreeMetadata,
previous?: WorktreeMetadata
): WorktreeMetadata => {
if (!previous) {
return hydrated;
}
return {
...previous,
...hydrated,
branch: hydrated.branch || previous.branch,
label: hydrated.label || previous.label,
name: hydrated.name || previous.name,
projectDirectory: hydrated.projectDirectory || previous.projectDirectory,
createdFromBranch: hydrated.createdFromBranch || previous.createdFromBranch,
kind: hydrated.kind || previous.kind,
status: hydrated.status || previous.status,
};
};
sessionsWithDirectory.forEach(({ id, directory }) => {
const metadata = worktreeMapByPath.get(directory);
if (!metadata) {
if (next.delete(id)) {
mutated = true;
}
return;
}
const previous = next.get(id);
const merged = mergeHydratedMetadata(metadata, previous);
if (
!previous ||
previous.path !== merged.path ||
previous.branch !== merged.branch ||
previous.label !== merged.label ||
previous.name !== merged.name ||
previous.projectDirectory !== merged.projectDirectory ||
previous.createdFromBranch !== merged.createdFromBranch ||
previous.kind !== merged.kind ||
previous.source !== merged.source
) {
next.set(id, merged);
mutated = true;
}
});
return mutated ? next : null;
};
export const useSessionStore = create<SessionStore>()(
devtools(
persist(
(set, get) => ({
sessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null,
lastLoadedDirectory: null,
isLoading: false,
error: null,
webUICreatedSessions: new Set(),
worktreeMetadata: new Map(),
availableWorktrees: [],
availableWorktreesByProject: new Map(),
loadSessions: async () => {
if (loadSessionsInFlight) {
loadSessionsQueued = true;
return loadSessionsInFlight;
}
const task = (async () => {
const requestSeq = ++loadSessionsRequestSeq;
const isLatestRequest = () => requestSeq === loadSessionsRequestSeq;
set({ isLoading: true, error: null });
try {
const directoryStore = useDirectoryStore.getState();
const projectsStore = useProjectsStore.getState();
const apiClient = opencodeClient.getApiClient();
const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory();
const includeDescendants = Boolean(vscodeWorkspaceDirectory);
vscodeDebugLog("loadSessions:start", {
workspace: vscodeWorkspaceDirectory,
currentDirectory: directoryStore.currentDirectory,
clientDirectory: opencodeClient.getDirectory(),
projectsCount: projectsStore.projects.length,
activeProjectId: projectsStore.activeProjectId,
});
const normalizedFallback = normalizePath(directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null);
const activeProject = projectsStore.projects.find((project) => project.id === projectsStore.activeProjectId) ?? null;
const activeProjectRoot = normalizePath(activeProject?.path ?? null);
const legacyRoot = activeProjectRoot ?? normalizedFallback ?? null;
const projectEntries: Array<Pick<ProjectEntry, 'id' | 'path'>> = projectsStore.projects.length > 0
? projectsStore.projects
: (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []);
const resolveSessionDirectory = (session: Session): string | null => {
const direct = normalizePath((session as { directory?: string | null }).directory ?? null);
if (direct) {
return direct;
}
const projectWorktree = normalizePath((session as GlobalSessionRecord).project?.worktree ?? null);
return projectWorktree;
};
const matchesProjectDirectory = (sessionDirectory: string | null, projectDirectory: string): boolean => {
if (!sessionDirectory) {
return false;
}
if (sessionDirectory === projectDirectory) {
return true;
}
return includeDescendants && sessionDirectory.startsWith(`${projectDirectory}/`);
};
const applyProjectResults = async (projectResults: ProjectSessionResult[], archivedSessions: Session[]) => {
const sessionsByDirectory = new Map<string, Session[]>();
projectResults.forEach((result) => {
if (!result.projectPath) {
return;
}
result.validPaths.forEach((directory) => {
const directoryKey = normalizePath(directory) ?? directory;
const directorySessions = result.sessions.filter((session) => {
const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey;
return dir === directoryKey;
});
sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions));
});
});
const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat());
const stateSnapshot = get();
let nextWorktreeMetadata = stateSnapshot.worktreeMetadata;
for (const result of projectResults) {
if (!result.projectPath) {
continue;
}
try {
const hydratedMetadata = await hydrateSessionWorktreeMetadata(
result.sessions,
result.projectPath,
nextWorktreeMetadata,
result.discoveredWorktrees
);
if (hydratedMetadata) {
nextWorktreeMetadata = hydratedMetadata;
}
} catch (metadataError) {
console.debug("Failed to refresh worktree metadata during session load:", metadataError);
}
}
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
projectResults.forEach((result) => {
if (result.projectPath) {
worktreesByProject.set(result.projectPath, result.discoveredWorktrees);
}
});
const allValidPaths = new Set<string>();
projectResults.forEach((result) => {
result.validPaths.forEach((value) => {
const key = normalizePath(value) ?? value;
if (key) {
allValidPaths.add(key);
}
});
});
const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null;
const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate)
? activeDirectoryCandidate
: (activeProjectRoot ?? activeDirectoryCandidate);
const activeDirectorySessions = activeDirectory
? sessionsByDirectory.get(activeDirectory) ?? []
: mergedSessions;
const validSessionIds = new Set(mergedSessions.map((session) => session.id));
// Keep directory-scoped stored selections tidy.
for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) {
clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id));
}
const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null);
let nextCurrentId = stateSnapshot.currentSessionId;
const currentSessionInActiveDirectory = Boolean(
nextCurrentId && activeDirectorySessions.some((session) => session.id === nextCurrentId)
);
if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || (directoryChanged && !currentSessionInActiveDirectory)) {
nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null;
}
if (activeDirectory) {
const storedSelection = getStoredSessionForDirectory(activeDirectory);
if (storedSelection && validSessionIds.has(storedSelection)) {
nextCurrentId = storedSelection;
}
}
const resolvedDirectoryForCurrent = (() => {
if (!nextCurrentId) {
return activeDirectory ?? null;
}
const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path;
if (metadataPath) {
return normalizePath(metadataPath) ?? metadataPath;
}
const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId);
if (sessionDir) {
return sessionDir;
}
return activeDirectory ?? null;
})();
if (!isLatestRequest()) {
return;
}
try {
opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined);
} catch (error) {
console.warn("Failed to sync OpenCode directory after session load:", error);
}
const activeWorktrees = activeProjectRoot
? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? []
: [];
set({
sessions: mergedSessions,
archivedSessions,
sessionsByDirectory,
currentSessionId: nextCurrentId,
lastLoadedDirectory: activeDirectory ?? null,
isLoading: false,
worktreeMetadata: nextWorktreeMetadata,
availableWorktrees: activeWorktrees,
availableWorktreesByProject: worktreesByProject,
});
if (activeDirectory) {
storeSessionForDirectory(activeDirectory, nextCurrentId);
}
if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) {
storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId);
}
};
if (projectEntries.length === 0) {
if (!isLatestRequest()) {
return;
}
set({
sessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null,
lastLoadedDirectory: null,
isLoading: false,
worktreeMetadata: new Map(),
availableWorktrees: [],
availableWorktreesByProject: new Map(),
});
return;
}
pruneProjectCaches(projectEntries.map((entry) => entry.path));
const buildProjectResults = async (sourceSessions: Session[]): Promise<ProjectSessionResult[]> => {
return Promise.all(
projectEntries.map(async (project: Pick<ProjectEntry, 'id' | 'path'>) => {
const normalizedProject = normalizePath(project.path);
if (!normalizedProject) {
return {
projectId: project.id,
projectPath: null,
sessions: [],
discoveredWorktrees: [],
validPaths: new Set<string>(),
};
}
const isGitRepo = await getProjectRepoStatus(normalizedProject);
let discoveredWorktrees: WorktreeMetadata[] = [];
const validPaths = new Set<string>([normalizedProject]);
if (isGitRepo) {
discoveredWorktrees = await listProjectWorktrees({
id: project.id,
path: normalizedProject,
}).catch(() => []);
discoveredWorktrees.forEach((meta) => {
if (meta?.path) {
validPaths.add(normalizePath(meta.path) ?? meta.path);
}
});
}
const mergedSessions = dedupeSessionsById(
sourceSessions.filter((session) => {
const sessionDirectory = resolveSessionDirectory(session);
if (!sessionDirectory) {
return false;
}
for (const projectPath of validPaths) {
if (matchesProjectDirectory(sessionDirectory, projectPath)) {
return true;
}
}
return false;
}),
);
const result: ProjectSessionResult = {
projectId: project.id,
projectPath: normalizedProject,
sessions: mergedSessions,
discoveredWorktrees,
validPaths,
};
setProjectSessionCache(normalizedProject, result);
return result;
}),
);
};
try {
const pageSize = 500;
const previousArchivedSessions = dedupeSessionsById(get().archivedSessions);
const firstPage = await apiClient.experimental.session.list({ limit: pageSize, archived: false });
let liveSessions = dedupeSessionsById(Array.isArray(firstPage.data) ? firstPage.data as Session[] : []);
let archivedSessions: Session[] = [];
let hasLoadedArchivedSessions = false;
const apply = async () => {
if (!isLatestRequest()) {
return;
}
const projectResults = await buildProjectResults(liveSessions);
const archivedForRender = hasLoadedArchivedSessions
? dedupeSessionsById(archivedSessions)
: previousArchivedSessions;
await applyProjectResults(projectResults, archivedForRender);
};
await apply();
const backgroundLoad = async () => {
let cursor = readNextCursor(firstPage) ?? undefined;
while (cursor && isLatestRequest()) {
const response = await apiClient.experimental.session.list({
limit: pageSize,
cursor,
archived: false,
});
const page = Array.isArray(response.data) ? response.data as Session[] : [];
if (page.length === 0) {
break;
}
liveSessions = dedupeSessionsById([...liveSessions, ...page]);
await apply();
cursor = readNextCursor(response) ?? undefined;
}
let archivedCursor: number | undefined;
while (isLatestRequest()) {
const response = await apiClient.experimental.session.list({
limit: pageSize,
archived: true,
...(archivedCursor ? { cursor: archivedCursor } : {}),
});
const page = Array.isArray(response.data)
? (response.data as Session[]).filter((session) => Boolean(session.time?.archived))
: [];
if (page.length > 0) {
hasLoadedArchivedSessions = true;
archivedSessions = dedupeSessionsById([...archivedSessions, ...page]);
await apply();
}
const next = readNextCursor(response);
if (!next) {
break;
}
archivedCursor = next;
}
if (!hasLoadedArchivedSessions && isLatestRequest()) {
hasLoadedArchivedSessions = true;
archivedSessions = [];
await apply();
}
};
void backgroundLoad().catch((error) => {
console.debug("Failed to load additional global sessions:", error);
});
return;
} catch (error) {
if (!isMissingGlobalSessionsEndpointError(error)) {
throw error;
}
console.debug("Global session endpoint unavailable, using legacy loader");
}
const fallbackResponse = await apiClient.session.list(undefined);
const fallbackSessions = dedupeSessionsById(Array.isArray(fallbackResponse.data) ? fallbackResponse.data : []);
const fallbackProjectResults = await buildProjectResults(fallbackSessions);
await applyProjectResults(fallbackProjectResults, []);
} catch (error) {
if (!isLatestRequest()) {
return;
}
set({
error: error instanceof Error ? error.message : "Failed to load sessions",
isLoading: false,
});
}
})();
loadSessionsInFlight = task;
try {
await task;
} finally {
if (loadSessionsInFlight === task) {
loadSessionsInFlight = null;
}
if (loadSessionsQueued) {
loadSessionsQueued = false;
void get().loadSessions();
}
}
},
createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => {
set({ error: null });
const directoryStore = useDirectoryStore.getState();
const fallbackDirectory = normalizePath(directoryStore.currentDirectory);
const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory();
const targetDirectory = vscodeWorkspaceDirectory ?? normalizePath(directoryOverride ?? opencodeClient.getDirectory() ?? fallbackDirectory);
vscodeDebugLog("createSession:start", { title, parentID, targetDirectory, vscodeWorkspaceDirectory });
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) => {
const nextSessions = [optimisticSession, ...state.sessions];
const nextByDirectory = new Map(state.sessionsByDirectory);
if (targetDirectory) {
const existing = nextByDirectory.get(targetDirectory) ?? [];
nextByDirectory.set(targetDirectory, dedupeSessionsById([optimisticSession, ...existing]));
}
return {
sessions: nextSessions,
sessionsByDirectory: nextByDirectory,
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) => {
const normalizedTarget = targetDirectory ?? null;
const normalizedReal: Session = (normalizedTarget
? ({ ...real, directory: normalizedTarget } as Session)
: real);
set((state) => {
const updatedSessions = state.sessions.map((item) => (item.id === tempId ? normalizedReal : item));
const nextByDirectory = new Map(state.sessionsByDirectory);
if (targetDirectory) {
const existing = nextByDirectory.get(targetDirectory) ?? [];
const replaced = existing.map((item) => (item.id === tempId ? normalizedReal : item));
nextByDirectory.set(targetDirectory, dedupeSessionsById(replaced));
}
return {
sessions: updatedSessions,
sessionsByDirectory: buildSessionsByDirectory(updatedSessions),
currentSessionId: normalizedReal.id,
webUICreatedSessions: new Set([
...Array.from(state.webUICreatedSessions).filter((id) => id !== tempId),
normalizedReal.id,
]),
};
});
storeSessionForDirectory(targetDirectory ?? null, normalizedReal.id);
};
const pollForSession = async (): Promise<Session | null> => {
const apiClient = opencodeClient.getApiClient();
const attempts = 20;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await apiClient.session.list(
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 metadataPath = typeof metadata?.path === 'string' ? metadata.path : null;
const metadataProjectDirectory = typeof metadata?.projectDirectory === 'string' ? metadata.projectDirectory : null;
const snapshot = get();
const sessionDirectory = getSessionDirectory([...snapshot.sessions, ...snapshot.archivedSessions], id);
const requestDirectory = normalizePath(metadataProjectDirectory)
?? normalizePath(sessionDirectory)
?? normalizePath(opencodeClient.getDirectory() ?? null)
?? null;
let archiveSucceeded = false;
try {
const success = await deleteSessionOnServer(id, requestDirectory);
if (!success) {
set({
isLoading: false,
error: "Failed to delete session",
});
return false;
}
if (metadata && options?.archiveWorktree) {
try {
await archiveSessionWorktree(metadata, {
deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName,
});
archiveSucceeded = true;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete worktree";
set({ error: message });
}
}
let nextCurrentId: string | null = null;
set((state) => {
const filteredSessions = state.sessions.filter((s) => s.id !== id);
const filteredArchivedSessions = state.archivedSessions.filter((s) => s.id !== id);
nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId;
const nextMetadata = new Map(state.worktreeMetadata);
nextMetadata.delete(id);
const shouldRemoveWorktreeFromLists = Boolean(metadataPath && options?.archiveWorktree && archiveSucceeded);
const nextAvailableWorktrees = shouldRemoveWorktreeFromLists
? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath))
: state.availableWorktrees;
const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject);
if (shouldRemoveWorktreeFromLists && metadataProjectDirectory) {
const projectKey = normalizePath(metadataProjectDirectory) ?? metadataProjectDirectory;
const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? [];
nextAvailableWorktreesByProject.set(
projectKey,
projectWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath))
);
}
return {
sessions: filteredSessions,
archivedSessions: filteredArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
currentSessionId: nextCurrentId,
isLoading: false,
worktreeMetadata: nextMetadata,
availableWorktrees: nextAvailableWorktrees,
availableWorktreesByProject: nextAvailableWorktreesByProject,
};
});
const directoryToStore = normalizePath(sessionDirectory)
?? normalizePath(opencodeClient.getDirectory() ?? null)
?? null;
storeSessionForDirectory(directoryToStore, nextCurrentId);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete session";
set({
error: message,
isLoading: false,
});
return false;
}
},
deleteSessions: async (
ids: string[],
options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: 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 worktreesToArchive = new Map<string, WorktreeMetadata>();
const archivedWorktreePaths = new Set<string>();
for (const id of uniqueIds) {
try {
const metadata = get().worktreeMetadata.get(id);
const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id);
const requestDirectory = normalizePath(metadata?.projectDirectory ?? null)
?? normalizePath(sessionDirectory)
?? normalizePath(opencodeClient.getDirectory() ?? null)
?? null;
if (metadata && options?.archiveWorktree) {
const key = normalizePath(metadata.path) ?? metadata.path;
if (!archivedWorktreePaths.has(key)) {
archivedWorktreePaths.add(key);
worktreesToArchive.set(key, metadata);
}
}
const success = await deleteSessionOnServer(id, requestDirectory);
if (success) {
deletedIds.push(id);
} else {
failedIds.push(id);
}
} catch {
failedIds.push(id);
}
}
const archivedWorktrees: Array<{ path: string; projectDirectory: string }> = [];
const archiveFailures: string[] = [];
if (options?.archiveWorktree && worktreesToArchive.size > 0) {
for (const metadata of worktreesToArchive.values()) {
try {
await archiveSessionWorktree(metadata, {
deleteRemoteBranch: options?.deleteRemoteBranch,
deleteLocalBranch: options?.deleteLocalBranch,
remoteName: options?.remoteName,
});
archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete worktree";
archiveFailures.push(message);
}
}
}
if (archiveFailures.length > 0) {
set({ error: archiveFailures[0] });
}
const directoryStore = useDirectoryStore.getState();
archivedWorktrees.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));
const filteredArchivedSessions = state.archivedSessions.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);
}
const removedPaths = new Set(
archivedWorktrees
.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;
const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject);
if (archivedWorktrees.length > 0) {
const removedPathsByProject = archivedWorktrees.reduce<Map<string, Set<string>>>((accumulator, entry) => {
const projectKey = normalizePath(entry.projectDirectory) ?? entry.projectDirectory;
const pathKey = normalizePath(entry.path) ?? entry.path;
if (!accumulator.has(projectKey)) {
accumulator.set(projectKey, new Set());
}
accumulator.get(projectKey)?.add(pathKey);
return accumulator;
}, new Map());
removedPathsByProject.forEach((paths, projectKey) => {
const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? [];
const filtered = projectWorktrees.filter(
(entry) => !paths.has(normalizePath(entry.path) ?? entry.path)
);
nextAvailableWorktreesByProject.set(projectKey, filtered);
});
}
return {
sessions: filteredSessions,
archivedSessions: filteredArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
currentSessionId: nextCurrentId,
...(silent ? {} : { isLoading: false, error: errorMessage }),
worktreeMetadata: nextMetadata,
availableWorktrees: nextAvailableWorktrees,
availableWorktreesByProject: nextAvailableWorktreesByProject,
};
});
const directory = opencodeClient.getDirectory() ?? null;
storeSessionForDirectory(directory, nextCurrentId);
return { deletedIds, failedIds };
},
archiveSession: async (id: string) => {
const { archivedIds, failedIds } = await get().archiveSessions([id]);
return archivedIds.length === 1 && failedIds.length === 0;
},
archiveSessions: async (ids: string[], options?: { 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 { archivedIds: [], failedIds: [] };
}
const silent = options?.silent === true;
if (!silent) {
set({ isLoading: true, error: null });
}
const archivedIds: string[] = [];
const failedIds: string[] = [];
for (const id of uniqueIds) {
try {
const metadata = get().worktreeMetadata.get(id);
const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id);
const requestDirectory = normalizePath(metadata?.projectDirectory ?? null)
?? normalizePath(sessionDirectory)
?? normalizePath(opencodeClient.getDirectory() ?? null)
?? null;
const archived = await setSessionArchivedOnServer(id, Date.now(), requestDirectory);
if (!archived) {
failedIds.push(id);
continue;
}
archivedIds.push(id);
} catch {
failedIds.push(id);
}
}
const archivedSet = new Set(archivedIds);
let nextCurrentId: string | null = null;
const errorMessage = failedIds.length > 0
? (failedIds.length === uniqueIds.length ? "Failed to archive sessions" : "Failed to archive some sessions")
: null;
set((state) => {
if (archivedSet.size === 0) {
return silent ? state : { ...state, isLoading: false, error: errorMessage };
}
const archivedRows = state.sessions.filter((session) => archivedSet.has(session.id)).map((session) => ({
...session,
time: {
...session.time,
archived: Date.now(),
},
} as Session));
const remaining = state.sessions.filter((session) => !archivedSet.has(session.id));
const nextArchivedSessions = dedupeSessionsById([...archivedRows, ...state.archivedSessions]);
if (state.currentSessionId && archivedSet.has(state.currentSessionId)) {
nextCurrentId = remaining[0]?.id ?? null;
} else {
nextCurrentId = state.currentSessionId;
}
return {
sessions: remaining,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(remaining),
currentSessionId: nextCurrentId,
...(silent ? {} : { isLoading: false, error: errorMessage }),
};
});
if (!silent && archivedSet.size === 0) {
set({ isLoading: false, error: errorMessage });
}
return { archivedIds, 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) => {
const sessions = state.sessions.map((s) => (s.id === id ? updatedSession : s));
return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) };
});
} 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({
sessionID: id,
...(directory ? { directory } : {})
});
};
const response = overrideDirectory
? await opencodeClient.withDirectory(overrideDirectory, shareRequest)
: await shareRequest();
if (response.data) {
set((state) => {
const sessions = state.sessions.map((s) => (s.id === id ? response.data : s));
return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) };
});
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({
sessionID: id,
...(directory ? { directory } : {})
});
};
const response = overrideDirectory
? await opencodeClient.withDirectory(overrideDirectory, unshareRequest)
: await unshareRequest();
if (response.data) {
set((state) => {
const sessions = state.sessions.map((s) => (s.id === id ? response.data : s));
return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) };
});
return response.data;
}
return null;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to unshare session",
});
return null;
}
},
setCurrentSession: (id: string | null) => {
const prevSessionId = get().currentSessionId;
set({ currentSessionId: id, error: null });
// Notify server of view state changes
// This enables server-side needs_attention tracking
if (prevSessionId && prevSessionId !== id) {
// Leaving previous session
fetch(`/api/sessions/${prevSessionId}/unview`, { method: 'POST' })
.catch(() => { /* ignore */ });
}
if (id) {
// Entering new session
fetch(`/api/sessions/${id}/view`, { method: 'POST' })
.catch(() => { /* ignore */ });
}
// Trigger immediate poll to get latest attention states
// This prevents stale state when switching sessions
triggerSessionStatusPoll();
const directory = opencodeClient.getDirectory() ?? null;
storeSessionForDirectory(directory, id);
},
clearError: () => {
set({ error: null });
},
getSessionsByDirectory: (directory: string) => {
const normalized = normalizePath(directory) ?? directory;
const { sessionsByDirectory, sessions } = get();
const direct = sessionsByDirectory.get(normalized);
if (direct) {
return direct;
}
return sessions.filter((session) => {
const dir = normalizePath((session as { directory?: string | null }).directory ?? null);
return (dir ?? normalized) === normalized;
});
},
getDirectoryForSession: (sessionId: string) => {
if (!sessionId) {
return null;
}
const metadata = get().worktreeMetadata.get(sessionId);
if (metadata?.path) {
return normalizePath(metadata.path) ?? metadata.path;
}
const entry = get().sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined;
const directory = normalizePath(entry?.directory ?? null);
return directory;
},
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 ||
(mergedTime !== existingSession.time && JSON.stringify(mergedTime) !== JSON.stringify(existingSession.time)) ||
(mergedSummary !== existingSession.summary && JSON.stringify(mergedSummary ?? null) !== JSON.stringify(existingSession.summary ?? null)) ||
(mergedShare !== existingSession.share && JSON.stringify(mergedShare ?? null) !== JSON.stringify(existingSession.share ?? null));
const sessions = [...state.sessions];
sessions[index] = hasChanged ? mergedSession : existingSession;
return {
sessions,
sessionsByDirectory: buildSessionsByDirectory(sessions),
};
});
},
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<string, unknown>;
if (normalizedDirectory !== undefined) {
updatedSession.directory = normalizedDirectory;
} else {
delete updatedSession.directory;
}
sessions[targetIndex] = updatedSession as Session;
return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) };
});
if (previousDirectory) {
storeSessionForDirectory(previousDirectory, null);
}
if (directory) {
storeSessionForDirectory(directory, sessionId);
}
},
updateSession: (session: Session) => {
set((state) => {
const index = state.sessions.findIndex((s) => s.id === session.id);
const archivedIndex = state.archivedSessions.findIndex((s) => s.id === session.id);
const isArchived = Boolean(session.time?.archived);
const nextSessions = index === -1
? (isArchived ? state.sessions : [session, ...state.sessions])
: state.sessions.map((s) => (s.id === session.id ? session : s));
const nextArchivedSessions = archivedIndex === -1
? (isArchived ? [session, ...state.archivedSessions] : state.archivedSessions)
: state.archivedSessions.map((s) => (s.id === session.id ? session : s));
const deduped = dedupeSessionsById(nextSessions.filter((item) => !item.time?.archived));
const dedupedArchived = dedupeSessionsById(nextArchivedSessions.filter((item) => Boolean(item.time?.archived)));
return {
sessions: deduped,
archivedSessions: dedupedArchived,
sessionsByDirectory: buildSessionsByDirectory(deduped),
};
});
},
removeSessionFromStore: (sessionId: string) => {
if (!sessionId) {
return;
}
set((state) => {
const target = [...state.sessions, ...state.archivedSessions]
.find((session) => session.id === sessionId) as { directory?: string | null } | undefined;
const directory = normalizePath(target?.directory ?? null);
const filteredSessions = state.sessions.filter((session) => session.id !== sessionId);
const filteredArchivedSessions = state.archivedSessions.filter((session) => session.id !== sessionId);
if (filteredSessions.length === state.sessions.length && filteredArchivedSessions.length === state.archivedSessions.length) {
return state;
}
const nextMetadata = new Map(state.worktreeMetadata);
nextMetadata.delete(sessionId);
const nextCurrentId = state.currentSessionId === sessionId ? null : state.currentSessionId;
if (directory) {
storeSessionForDirectory(directory, null);
}
return {
sessions: filteredSessions,
archivedSessions: filteredArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
currentSessionId: nextCurrentId,
worktreeMetadata: nextMetadata,
};
});
},
}),
{
name: "session-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
currentSessionId: state.currentSessionId,
sessions: state.sessions,
archivedSessions: state.archivedSessions,
lastLoadedDirectory: state.lastLoadedDirectory,
webUICreatedSessions: Array.from(state.webUICreatedSessions),
worktreeMetadata: Array.from(state.worktreeMetadata.entries()),
availableWorktrees: state.availableWorktrees,
availableWorktreesByProject: Array.from(state.availableWorktreesByProject.entries()),
}),
merge: (persistedState, currentState) => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
if (!isRecord(persistedState)) {
return currentState;
}
const persistedSessions = Array.isArray(persistedState.sessions)
? (persistedState.sessions as Session[])
: currentState.sessions;
const persistedArchivedSessions = Array.isArray(persistedState.archivedSessions)
? (persistedState.archivedSessions as Session[])
: currentState.archivedSessions;
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 persistedWorktreesByProjectEntries = Array.isArray(persistedState.availableWorktreesByProject)
? (persistedState.availableWorktreesByProject as Array<[string, WorktreeMetadata[]]>)
: [];
const persistedWorktreesByProject = new Map(persistedWorktreesByProjectEntries);
const lastLoadedDirectory =
typeof persistedState.lastLoadedDirectory === "string"
? persistedState.lastLoadedDirectory
: currentState.lastLoadedDirectory ?? null;
const mergedSessions = dedupeSessionsById(persistedSessions);
const mergedArchivedSessions = dedupeSessionsById(persistedArchivedSessions);
const mergedResult = {
...currentState,
...persistedState,
sessions: mergedSessions,
archivedSessions: mergedArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(mergedSessions),
currentSessionId: persistedCurrentSessionId,
webUICreatedSessions: new Set(webUiSessionsArray),
worktreeMetadata: new Map(persistedWorktreeEntries),
availableWorktrees: persistedAvailableWorktrees,
availableWorktreesByProject: persistedWorktreesByProject.size > 0
? persistedWorktreesByProject
: currentState.availableWorktreesByProject,
lastLoadedDirectory,
};
return mergedResult;
},
}
),
{
name: "session-store",
}
)
);