refactor: modularize session sidebar and add GitHub PR tracking (#610)

* feat: switch sessions sidebar to global paginated loading with archived flow

Load sessions via global endpoint with progressive 500-item pagination and legacy fallback
Add dedicated archived sidebar section for archived and unassigned sessions
Change remove behavior to archive outside archived and hard-delete inside archived

* feat: improve archived sessions UX and folder persistence

Archive sessions on worktree removal while keeping worktree deletion
Streamline archived sidebar actions, icons, metadata, and tooltips
Persist session folders to ~/.config/openchamber/sessions-directories.json with startup hydration

* fix: align archived session actions and clean empty archived folders

Apply archived dropdown behavior consistently for folder-contained sessions
Remove archived-only folder actions while keeping standard folder behavior elsewhere
Auto-prune empty archived folders during session cleanup and persistence sync

* refactor: modularize session sidebar and stabilize behavior

Split monolithic sidebar logic into focused hooks and components
Kept session, archive, folder, and project interactions working with cleaner state persistence
Added sidebar DOCUMENTATION.md summarizing file roles and refactor outcomes

* fix: improve fork PR detection and smart remote tracking

Added centralized PR status store for shared polling and refresh
Auto-selects the remote that has an existing PR when current remote has none
Stops periodic polling for closed or merged PRs to reduce unnecessary requests

* fix: make chat and toast corners follow active theme radius

Toast corners now use theme radius tokens instead of hardcoded rounding
User message bubble now uses theme-configured max radius with preserved tail corner
Square-corner themes now consistently affect both toasts and chat bubbles

* feat: show live PR status across git view and session sidebar

Added a shared GitHub PR status store with adaptive background polling and terminal-state pause
Improved fork remote detection and auto-selection so existing PRs are found more reliably
Updated session group headers to show clickable PR number with branch and state-colored branch icon

* feat: centralize GitHub PR tracking and enrich session sidebar PR details

Moved PR status polling to a single global pipeline keyed by directory and branch
Improved fork-aware PR resolution and reduced duplicate GitHub status fetches across views
Added richer session sidebar PR display with clickable number, state-aware styling, and structured tooltip details

* fix: adjust PR indicator icon vertical alignment

Fine-tuned PR indicator icon vertical alignment in session sidebar
Reduced icon translate-y from 2px to 0.5px for better visual balance

* feat: improve session sidebar status display

* feat: enhance session display logic for minimal mode and improve dropdown menu accessibility

* feat: refactor session row to include tooltip for minimal display mode
This commit is contained in:
Bohdan Triapitsyn
2026-03-06 17:22:59 +02:00
committed by GitHub
parent d54e1199df
commit a7f11121e8
47 changed files with 6538 additions and 3772 deletions
+96
View File
@@ -0,0 +1,96 @@
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
export type GlobalSessionRecord = Session & {
project?: {
id: string;
name?: string;
worktree?: string;
} | null;
};
const toNumber = (value: string | null): number | null => {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const readResponseHeader = (response: unknown, header: string): string | null => {
if (!response || typeof response !== "object") {
return null;
}
const container = response as { headers?: unknown };
const headers = container.headers;
if (!headers || typeof headers !== "object") {
return null;
}
const maybeGet = headers as { get?: (name: string) => string | null };
if (typeof maybeGet.get === "function") {
return maybeGet.get(header);
}
const maybeRecord = headers as Record<string, unknown>;
const direct = maybeRecord[header] ?? maybeRecord[header.toLowerCase()];
return typeof direct === "string" ? direct : null;
};
export const readNextCursor = (response: unknown): number | null => {
return toNumber(readResponseHeader(response, "x-next-cursor"));
};
export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean => {
if (!error || typeof error !== "object") {
return false;
}
const value = error as {
status?: number;
response?: { status?: number };
cause?: { status?: number; response?: { status?: number } };
};
const status = value.status
?? value.response?.status
?? value.cause?.status
?? value.cause?.response?.status;
return status === 404;
};
export async function listGlobalSessionPages(
apiClient: OpencodeClient,
options: {
archived: boolean;
pageSize: number;
onPage?: (sessions: GlobalSessionRecord[]) => void;
},
): Promise<GlobalSessionRecord[]> {
const all: GlobalSessionRecord[] = [];
let cursor: number | undefined;
while (true) {
const response = await apiClient.experimental.session.list({
archived: options.archived,
limit: options.pageSize,
...(cursor ? { cursor } : {}),
});
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
if (payload.length === 0) {
break;
}
all.push(...payload);
options.onPage?.(payload);
const nextCursor = toNumber(readResponseHeader(response, "x-next-cursor"));
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
return all;
}
+269 -258
View File
@@ -12,9 +12,11 @@ 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;
@@ -31,6 +33,8 @@ interface SessionActions {
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>;
@@ -97,24 +101,10 @@ type ProjectRepoCacheEntry = {
isGitRepo: boolean;
};
const PROJECT_SESSION_CACHE_TTL_MS = 30_000;
const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000;
const projectSessionCache = new Map<string, ProjectSessionCacheEntry>();
const projectRepoStatusCache = new Map<string, ProjectRepoCacheEntry>();
const getFreshProjectSessionCache = (projectPath: string): ProjectSessionResult | null => {
const key = normalizePath(projectPath) ?? projectPath;
const cached = projectSessionCache.get(key);
if (!cached) {
return null;
}
if (Date.now() - cached.cachedAt > PROJECT_SESSION_CACHE_TTL_MS) {
projectSessionCache.delete(key);
return null;
}
return cached.result;
};
const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => {
const key = normalizePath(projectPath) ?? projectPath;
projectSessionCache.set(key, { cachedAt: Date.now(), result });
@@ -241,6 +231,21 @@ const deleteSessionOnServer = async (sessionId: string, directory?: string | nul
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;
@@ -446,6 +451,7 @@ export const useSessionStore = create<SessionStore>()(
(set, get) => ({
sessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null,
lastLoadedDirectory: null,
@@ -475,151 +481,6 @@ export const useSessionStore = create<SessionStore>()(
activeProjectId: projectsStore.activeProjectId,
});
const canonicalDirectoryCache = new Map<string, string>();
const resolveCanonicalDirectory = async (directory: string): Promise<string> => {
const normalizedRequested = normalizePath(directory) ?? directory;
const cacheKey = normalizedRequested;
const cached = canonicalDirectoryCache.get(cacheKey);
if (cached) {
return cached;
}
try {
const info = await apiClient.path.get({ directory });
const canonical = normalizePath((info.data as { directory?: string | null } | null)?.directory ?? null);
const resolved = canonical ?? normalizedRequested;
canonicalDirectoryCache.set(cacheKey, resolved);
return resolved;
} catch {
canonicalDirectoryCache.set(cacheKey, normalizedRequested);
return normalizedRequested;
}
};
const filterSessionsToDirectory = (
sessions: Session[],
directory: string,
options?: { includeDescendants?: boolean; includeMissingDirectory?: boolean }
): Session[] => {
const normalized = normalizePath(directory);
if (!normalized) {
return sessions;
}
const includeDescendants = options?.includeDescendants === true;
const prefix = includeDescendants ? `${normalized}/` : null;
const includeMissingDirectory = options?.includeMissingDirectory === true;
return sessions.filter((session) => {
const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null);
if (!sessionDir) return includeMissingDirectory;
if (sessionDir === normalized) return true;
if (prefix && sessionDir.startsWith(prefix)) return true;
return false;
});
};
const assignRequestedDirectory = (
sessions: Session[],
requestedDirectory: string,
canonicalDirectory?: string | null
): Session[] => {
const normalizedRequested = normalizePath(requestedDirectory);
if (!normalizedRequested) {
return sessions;
}
const normalizedCanonical = normalizePath(canonicalDirectory ?? null);
if (!normalizedCanonical || normalizedCanonical === normalizedRequested) {
return sessions.map((session) => {
const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null);
if (sessionDir) {
return session;
}
return ({ ...session, directory: normalizedRequested } as Session);
});
}
const canonicalPrefix = normalizedCanonical === "/" ? "/" : `${normalizedCanonical}/`;
const requestedPrefix = normalizedRequested === "/" ? "/" : `${normalizedRequested}/`;
return sessions.map((session) => {
const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null);
if (!sessionDir) {
return ({ ...session, directory: normalizedRequested } as Session);
}
if (sessionDir === normalizedCanonical) {
return ({ ...session, directory: normalizedRequested } as Session);
}
if (canonicalPrefix !== "/" && sessionDir.startsWith(canonicalPrefix)) {
const suffix = sessionDir.slice(canonicalPrefix.length);
return ({ ...session, directory: `${requestedPrefix}${suffix}` } as Session);
}
return session;
});
};
const fetchSessionsForDirectory = async (directoryParam?: string | null): Promise<Session[]> => {
const requestedDirectory = normalizePath(directoryParam);
if (!requestedDirectory) {
try {
const response = await apiClient.session.list(undefined);
return Array.isArray(response.data) ? response.data : [];
} catch (error) {
console.debug("Failed to list sessions (global):", error);
throw error;
}
}
const canonicalDirectory = await resolveCanonicalDirectory(requestedDirectory);
const listFromDirectoryScopedCall = async (): Promise<Session[]> => {
const response = await apiClient.session.list({ directory: requestedDirectory });
return Array.isArray(response.data) ? response.data : [];
};
let sessions: Session[] = [];
let listError: unknown = null;
let usedGlobalFallback = false;
try {
sessions = await listFromDirectoryScopedCall();
} catch (error) {
console.debug("Failed to list sessions for directory:", requestedDirectory, error);
listError = error;
sessions = [];
}
// Some runtimes canonicalize directory paths (e.g. realpath). If the scoped call returns no results,
// fall back to the global list and map canonical paths back to the requested directory.
if (sessions.length === 0) {
usedGlobalFallback = true;
try {
const globalResponse = await apiClient.session.list(undefined);
const globalList = Array.isArray(globalResponse.data) ? globalResponse.data : [];
sessions = filterSessionsToDirectory(globalList, canonicalDirectory, {
includeDescendants,
includeMissingDirectory: false,
});
} catch (error) {
console.debug("Failed to list sessions (global fallback):", error);
if (listError) {
throw listError;
}
throw error;
}
}
const filtered = filterSessionsToDirectory(sessions, canonicalDirectory, {
includeDescendants,
includeMissingDirectory: !usedGlobalFallback,
});
vscodeDebugLog("fetchSessionsForDirectory", {
requestedDirectory,
canonicalDirectory,
fetched: sessions.length,
filtered: filtered.length,
});
return assignRequestedDirectory(filtered, requestedDirectory, canonicalDirectory);
};
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);
@@ -630,7 +491,26 @@ export const useSessionStore = create<SessionStore>()(
? projectsStore.projects
: (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []);
const applyProjectResults = async (projectResults: ProjectSessionResult[]) => {
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) {
@@ -751,6 +631,7 @@ export const useSessionStore = create<SessionStore>()(
set({
sessions: mergedSessions,
archivedSessions,
sessionsByDirectory,
currentSessionId: nextCurrentId,
lastLoadedDirectory: activeDirectory ?? null,
@@ -774,6 +655,7 @@ export const useSessionStore = create<SessionStore>()(
}
set({
sessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null,
lastLoadedDirectory: null,
@@ -787,110 +669,134 @@ export const useSessionStore = create<SessionStore>()(
pruneProjectCaches(projectEntries.map((entry) => entry.path));
const activeProjectId = projectsStore.activeProjectId;
const cachedProjectResults: ProjectSessionResult[] = [];
projectEntries.forEach((project) => {
const normalizedProject = normalizePath(project.path);
if (!normalizedProject) {
return;
}
const cached = getFreshProjectSessionCache(normalizedProject);
if (cached) {
cachedProjectResults.push(cached);
}
});
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 hasCachedActiveProject = cachedProjectResults.some(
(result) => result.projectId === activeProjectId
);
if (hasCachedActiveProject && isLatestRequest()) {
await applyProjectResults(cachedProjectResults);
}
const projectResults: ProjectSessionResult[] = await 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 cached = getFreshProjectSessionCache(normalizedProject);
const isActiveProject = project.id === activeProjectId;
if (cached && !isActiveProject) {
return cached;
}
const isGitRepo = await getProjectRepoStatus(normalizedProject);
const parentSessions = await fetchSessionsForDirectory(normalizedProject || null);
vscodeDebugLog("projectSessions", {
projectId: project.id,
projectPath: normalizedProject,
isGitRepo,
parentSessions: parentSessions.length,
});
const subdirectorySessions: Session[] = [];
let discoveredWorktrees: WorktreeMetadata[] = [];
const validPaths = new Set<string>();
validPaths.add(normalizedProject);
if (isGitRepo) {
try {
const candidates = new Set<string>();
const managedWorktrees = await listProjectWorktrees({
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 = managedWorktrees;
managedWorktrees.forEach((meta) => {
discoveredWorktrees.forEach((meta) => {
if (meta?.path) {
candidates.add(normalizePath(meta.path) ?? meta.path);
validPaths.add(normalizePath(meta.path) ?? meta.path);
}
});
candidates.forEach((candidate) => {
const normalizedCandidate = normalizePath(candidate) ?? candidate;
validPaths.add(normalizedCandidate);
});
if (candidates.size > 0) {
const results = await Promise.allSettled(
Array.from(candidates).map((path) => fetchSessionsForDirectory(path))
);
results.forEach((result) => {
if (result.status === "fulfilled" && Array.isArray(result.value)) {
subdirectorySessions.push(...result.value);
}
});
}
} catch {
discoveredWorktrees = [];
}
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 firstPage = await apiClient.experimental.session.list({ limit: pageSize, archived: false });
let liveSessions = dedupeSessionsById(Array.isArray(firstPage.data) ? firstPage.data as Session[] : []);
let archivedSessions: Session[] = [];
const apply = async () => {
if (!isLatestRequest()) {
return;
}
const projectResults = await buildProjectResults(liveSessions);
await applyProjectResults(projectResults, dedupeSessionsById(archivedSessions));
};
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;
}
const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]);
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) {
archivedSessions = dedupeSessionsById([...archivedSessions, ...page]);
await apply();
}
const next = readNextCursor(response);
if (!next) {
break;
}
archivedCursor = next;
}
};
const result: ProjectSessionResult = {
projectId: project.id,
projectPath: normalizedProject,
sessions: mergedSessions,
discoveredWorktrees,
validPaths,
};
void backgroundLoad().catch((error) => {
console.debug("Failed to load additional global sessions:", error);
});
setProjectSessionCache(normalizedProject, result);
return result;
})
);
await applyProjectResults(projectResults);
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;
@@ -1057,7 +963,8 @@ export const useSessionStore = create<SessionStore>()(
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 sessionDirectory = getSessionDirectory(get().sessions, id);
const snapshot = get();
const sessionDirectory = getSessionDirectory([...snapshot.sessions, ...snapshot.archivedSessions], id);
const requestDirectory = normalizePath(metadataProjectDirectory)
?? normalizePath(sessionDirectory)
?? normalizePath(opencodeClient.getDirectory() ?? null)
@@ -1091,6 +998,7 @@ export const useSessionStore = create<SessionStore>()(
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);
@@ -1109,6 +1017,7 @@ export const useSessionStore = create<SessionStore>()(
}
return {
sessions: filteredSessions,
archivedSessions: filteredArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
currentSessionId: nextCurrentId,
isLoading: false,
@@ -1155,7 +1064,7 @@ export const useSessionStore = create<SessionStore>()(
for (const id of uniqueIds) {
try {
const metadata = get().worktreeMetadata.get(id);
const sessionDirectory = getSessionDirectory(get().sessions, id);
const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id);
const requestDirectory = normalizePath(metadata?.projectDirectory ?? null)
?? normalizePath(sessionDirectory)
?? normalizePath(opencodeClient.getDirectory() ?? null)
@@ -1218,6 +1127,7 @@ export const useSessionStore = create<SessionStore>()(
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 {
@@ -1261,6 +1171,7 @@ export const useSessionStore = create<SessionStore>()(
return {
sessions: filteredSessions,
archivedSessions: filteredArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
currentSessionId: nextCurrentId,
...(silent ? {} : { isLoading: false, error: errorMessage }),
@@ -1276,6 +1187,88 @@ export const useSessionStore = create<SessionStore>()(
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);
@@ -1560,14 +1553,23 @@ export const useSessionStore = create<SessionStore>()(
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
? [session, ...state.sessions]
? (isArchived ? state.sessions : [session, ...state.sessions])
: state.sessions.map((s) => (s.id === session.id ? session : s));
const deduped = dedupeSessionsById(nextSessions);
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),
};
});
@@ -1579,11 +1581,13 @@ export const useSessionStore = create<SessionStore>()(
}
set((state) => {
const target = state.sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined;
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);
if (filteredSessions.length === state.sessions.length) {
const filteredArchivedSessions = state.archivedSessions.filter((session) => session.id !== sessionId);
if (filteredSessions.length === state.sessions.length && filteredArchivedSessions.length === state.archivedSessions.length) {
return state;
}
@@ -1598,6 +1602,7 @@ export const useSessionStore = create<SessionStore>()(
return {
sessions: filteredSessions,
archivedSessions: filteredArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
currentSessionId: nextCurrentId,
worktreeMetadata: nextMetadata,
@@ -1611,6 +1616,7 @@ export const useSessionStore = create<SessionStore>()(
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()),
@@ -1628,6 +1634,9 @@ export const useSessionStore = create<SessionStore>()(
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
@@ -1657,11 +1666,13 @@ export const useSessionStore = create<SessionStore>()(
: 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),
@@ -137,6 +137,7 @@ export interface VoiceState {
export interface SessionStore {
sessions: Session[];
archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
currentSessionId: string | null;
lastLoadedDirectory: string | null;
@@ -221,6 +222,8 @@ export interface SessionStore {
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>;
@@ -0,0 +1,562 @@
import { create } from 'zustand';
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
const PR_REVALIDATE_TTL_MS = 90_000;
const PR_REVALIDATE_INTERVAL_MS = 15_000;
const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000;
const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const;
const PR_OPEN_BUSY_INTERVAL_MS = 60_000;
const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000;
const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged';
const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => {
const checks = status?.checks;
if (!checks) {
return false;
}
return checks.state === 'pending' || checks.pending > 0;
};
export const getGitHubPrStatusKey = (directory: string, branch: string, remoteName?: string | null): string => {
void remoteName;
return `${directory}::${branch}`;
};
type RefreshOptions = {
force?: boolean;
onlyExistingPr?: boolean;
silent?: boolean;
markInitialResolved?: boolean;
};
type PrTrackingTarget = {
directory: string;
branch: string;
remoteName?: string | null;
};
type PrRuntimeParams = {
directory: string;
branch: string;
remoteName: string | null;
canShow: boolean;
github?: RuntimeAPIs['github'];
githubAuthChecked: boolean;
githubConnected: boolean | null;
};
type PrStatusEntry = {
status: GitHubPullRequestStatus | null;
isLoading: boolean;
error: string | null;
isInitialStatusResolved: boolean;
lastRefreshAt: number;
lastDiscoveryPollAt: number;
watchers: number;
params: PrRuntimeParams | null;
};
type GitHubPrStatusStore = {
entries: Record<string, PrStatusEntry>;
activeRequestCount: number;
totalRequestCount: number;
ensureEntry: (key: string) => void;
setParams: (key: string, params: PrRuntimeParams) => void;
startWatching: (key: string) => void;
stopWatching: (key: string) => void;
refresh: (key: string, options?: RefreshOptions) => Promise<void>;
updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void;
syncBackgroundTargets: (args: {
targets: PrTrackingTarget[];
github?: RuntimeAPIs['github'];
githubAuthChecked: boolean;
githubConnected: boolean | null;
}) => void;
};
const timers = new Map<string, number>();
const bootstrapTimers = new Map<string, number[]>();
const inFlightBySignature = new Set<string>();
const lastRefreshBySignature = new Map<string, number>();
const backgroundWatchingKeys = new Set<string>();
const getSignatureFromParams = (params: PrRuntimeParams | null | undefined): string | null => {
if (!params?.directory || !params.branch) {
return null;
}
return `${params.directory}::${params.branch}`;
};
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
return Object.entries(entries)
.filter(([, entry]) => getSignatureFromParams(entry.params) === signature)
.map(([key]) => key);
};
const pickFetchParamsForSignature = (
entries: Record<string, PrStatusEntry>,
signature: string,
preferredKey: string,
): PrRuntimeParams | null => {
const keys = getKeysBySignature(entries, signature);
const candidates = keys
.map((key) => entries[key])
.filter((entry): entry is PrStatusEntry => Boolean(entry?.params))
.map((entry) => entry.params)
.filter((params): params is PrRuntimeParams => Boolean(params?.canShow && params.github?.prStatus));
if (candidates.length === 0) {
return null;
}
const preferred = entries[preferredKey]?.params;
if (
preferred
&& getSignatureFromParams(preferred) === signature
&& preferred.canShow
&& preferred.github?.prStatus
) {
return preferred;
}
const withRemote = candidates.find((params) => Boolean(params.remoteName));
if (withRemote) {
return withRemote;
}
return candidates[0] ?? null;
};
const createEntry = (): PrStatusEntry => ({
status: null,
isLoading: false,
error: null,
isInitialStatusResolved: false,
lastRefreshAt: 0,
lastDiscoveryPollAt: 0,
watchers: 0,
params: null,
});
const mergeParams = (current: PrRuntimeParams | null, next: PrRuntimeParams): PrRuntimeParams => {
if (!current) {
return next;
}
return {
...current,
...next,
remoteName: next.remoteName ?? current.remoteName ?? null,
};
};
export const useGitHubPrStatusStore = create<GitHubPrStatusStore>((set, get) => ({
entries: {},
activeRequestCount: 0,
totalRequestCount: 0,
ensureEntry: (key) => {
set((state) => {
if (state.entries[key]) {
return state;
}
return {
entries: {
...state.entries,
[key]: createEntry(),
},
};
});
},
setParams: (key, params) => {
set((state) => {
const current = state.entries[key] ?? createEntry();
return {
entries: {
...state.entries,
[key]: {
...current,
params: mergeParams(current.params, params),
},
},
};
});
},
startWatching: (key) => {
set((state) => {
const current = state.entries[key] ?? createEntry();
return {
entries: {
...state.entries,
[key]: {
...current,
watchers: current.watchers + 1,
},
},
};
});
if (timers.has(key)) {
return;
}
const runBootstrapRefresh = (delayMs: number) => {
const timerId = window.setTimeout(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return;
}
const entry = get().entries[key];
if (!entry || entry.watchers <= 0) {
return;
}
if (entry.status?.pr) {
return;
}
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
}, delayMs);
const existing = bootstrapTimers.get(key) ?? [];
existing.push(timerId);
bootstrapTimers.set(key, existing);
};
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay));
const timerId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return;
}
const entry = get().entries[key];
if (!entry || entry.watchers <= 0) {
return;
}
const hasPr = Boolean(entry.status?.pr);
if (!hasPr) {
const now = Date.now();
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
return;
}
set((state) => {
const current = state.entries[key];
if (!current) {
return state;
}
return {
entries: {
...state.entries,
[key]: {
...current,
lastDiscoveryPollAt: now,
},
},
};
});
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
return;
}
if (isTerminalPrState(entry.status?.pr?.state)) {
return;
}
const elapsed = Date.now() - entry.lastRefreshAt;
const nextInterval = isPendingChecks(entry.status)
? PR_OPEN_BUSY_INTERVAL_MS
: (entry.status?.checks && entry.status.checks.state !== 'pending'
? PR_OPEN_STABLE_INTERVAL_MS
: PR_OPEN_DEFAULT_INTERVAL_MS);
if (elapsed < nextInterval) {
return;
}
void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true });
}, PR_REVALIDATE_INTERVAL_MS);
timers.set(key, timerId);
},
stopWatching: (key) => {
set((state) => {
const current = state.entries[key];
if (!current) {
return state;
}
const watchers = Math.max(0, current.watchers - 1);
return {
entries: {
...state.entries,
[key]: {
...current,
watchers,
},
},
};
});
const entry = get().entries[key];
if (entry && entry.watchers > 0) {
return;
}
const timerId = timers.get(key);
if (typeof timerId === 'number') {
window.clearInterval(timerId);
}
timers.delete(key);
const pendingBootstrapTimers = bootstrapTimers.get(key);
if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) {
pendingBootstrapTimers.forEach((id) => {
window.clearTimeout(id);
});
}
bootstrapTimers.delete(key);
},
refresh: async (key, options) => {
const state = get();
const entry = state.entries[key];
const signature = getSignatureFromParams(entry?.params);
if (!entry || !signature) {
return;
}
const signatureKeys = getKeysBySignature(state.entries, signature);
const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr));
if (options?.onlyExistingPr && !hasExistingPr) {
return;
}
const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0;
if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) {
return;
}
if (inFlightBySignature.has(signature)) {
return;
}
const params = pickFetchParamsForSignature(state.entries, signature, key);
if (!params) {
return;
}
inFlightBySignature.add(signature);
lastRefreshBySignature.set(signature, Date.now());
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
lastRefreshAt: Date.now(),
isLoading: options?.silent ? current.isLoading : true,
error: null,
};
});
return {
entries: nextEntries,
};
});
if (params.githubAuthChecked && params.githubConnected === false) {
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
status: { connected: false },
error: null,
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
inFlightBySignature.delete(signature);
return;
}
if (!params.github?.prStatus) {
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
status: null,
error: 'GitHub runtime API unavailable',
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
inFlightBySignature.delete(signature);
return;
}
try {
set((prev) => ({
...prev,
activeRequestCount: prev.activeRequestCount + 1,
totalRequestCount: prev.totalRequestCount + 1,
}));
const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined);
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
const prevPr = current.status?.pr;
const nextPr = next.pr;
const shouldCarryBody = Boolean(
nextPr
&& prevPr
&& nextPr.number === prevPr.number
&& (!nextPr.body || !nextPr.body.trim())
&& typeof prevPr.body === 'string'
&& prevPr.body.trim().length > 0,
);
const status = shouldCarryBody && nextPr && prevPr?.body
? {
...next,
pr: {
...nextPr,
body: prevPr.body,
},
}
: next;
nextEntries[signatureKey] = {
...current,
status,
error: null,
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
const current = nextEntries[signatureKey];
if (!current) {
return;
}
nextEntries[signatureKey] = {
...current,
error: message || 'Failed to load PR status',
isLoading: options?.silent ? current.isLoading : false,
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
};
});
return {
entries: nextEntries,
};
});
} finally {
inFlightBySignature.delete(signature);
set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) }));
}
},
updateStatus: (key, updater) => {
set((state) => {
const current = state.entries[key] ?? createEntry();
return {
entries: {
...state.entries,
[key]: {
...current,
status: updater(current.status),
},
},
};
});
},
syncBackgroundTargets: ({ targets, github, githubAuthChecked, githubConnected }) => {
if (!github || targets.length === 0) {
Array.from(backgroundWatchingKeys).forEach((key) => {
get().stopWatching(key);
backgroundWatchingKeys.delete(key);
});
return;
}
const uniqueTargets = new Map<string, PrTrackingTarget>();
targets.forEach((target) => {
const directory = target.directory.trim();
const branch = target.branch.trim();
if (!directory || !branch) {
return;
}
const key = getGitHubPrStatusKey(directory, branch, target.remoteName ?? null);
if (!uniqueTargets.has(key)) {
uniqueTargets.set(key, {
directory,
branch,
remoteName: target.remoteName ?? null,
});
}
});
const nextKeys = new Set(uniqueTargets.keys());
Array.from(backgroundWatchingKeys).forEach((key) => {
if (nextKeys.has(key)) {
return;
}
get().stopWatching(key);
backgroundWatchingKeys.delete(key);
});
uniqueTargets.forEach((target, key) => {
get().ensureEntry(key);
get().setParams(key, {
directory: target.directory,
branch: target.branch,
remoteName: target.remoteName ?? null,
canShow: true,
github,
githubAuthChecked,
githubConnected,
});
if (!backgroundWatchingKeys.has(key)) {
get().startWatching(key);
backgroundWatchingKeys.add(key);
}
});
},
}));
@@ -0,0 +1,21 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type SessionDisplayMode = 'default' | 'minimal';
type SessionDisplayStore = {
displayMode: SessionDisplayMode;
setDisplayMode: (mode: SessionDisplayMode) => void;
};
export const useSessionDisplayStore = create<SessionDisplayStore>()(
persist(
(set) => ({
displayMode: 'default',
setDisplayMode: (mode) => set({ displayMode: mode }),
}),
{
name: 'session-display-mode',
},
),
);
+180 -18
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useDirectoryStore } from './useDirectoryStore';
// --- Types ---
@@ -38,8 +40,77 @@ type SessionFoldersStore = SessionFoldersState & SessionFoldersActions;
const FOLDERS_STORAGE_KEY = 'oc.sessions.folders';
const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse';
const SESSIONS_DIRECTORIES_PATH_SUFFIX = '.config/openchamber/sessions-directories.json';
const DISK_WRITE_DEBOUNCE_MS = 250;
const ARCHIVED_SCOPE_PREFIX = '__archived__:';
const safeStorage = getSafeStorage();
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
let diskHydrated = false;
let diskHydrationInFlight = false;
const getSessionsDirectoriesPath = (): string | null => {
const directoryState = useDirectoryStore.getState();
const homeDirectory = typeof directoryState.homeDirectory === 'string' && directoryState.homeDirectory.length > 0
? directoryState.homeDirectory
: (safeStorage.getItem('homeDirectory') || '');
if (!homeDirectory) {
return null;
}
return `${homeDirectory.replace(/\/$/, '')}/${SESSIONS_DIRECTORIES_PATH_SUFFIX}`;
};
const getParentDirectory = (path: string): string | null => {
const index = path.lastIndexOf('/');
if (index <= 0) {
return null;
}
return path.slice(0, index);
};
const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
if (typeof window === 'undefined') {
return;
}
if (diskWriteTimer) {
clearTimeout(diskWriteTimer);
}
const foldersSnapshot = JSON.parse(JSON.stringify(foldersMap)) as SessionFoldersMap;
const collapsedSnapshot = Array.from(collapsedFolderIds);
diskWriteTimer = setTimeout(() => {
diskWriteTimer = null;
void (async () => {
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
if (!runtimeFiles?.writeFile) {
return;
}
const path = getSessionsDirectoriesPath();
if (!path) {
return;
}
const parentDirectory = getParentDirectory(path);
if (parentDirectory) {
await runtimeFiles.createDirectory(parentDirectory).catch(() => undefined);
}
const payload = {
version: 1,
foldersMap: foldersSnapshot,
collapsedFolderIds: collapsedSnapshot,
updatedAt: Date.now(),
};
await runtimeFiles.writeFile(path, JSON.stringify(payload, null, 2)).catch(() => undefined);
})();
}, DISK_WRITE_DEBOUNCE_MS);
};
const readPersistedFolders = (): SessionFoldersMap => {
try {
@@ -112,6 +183,12 @@ const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
}
};
const persistState = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
persistFolders(foldersMap);
persistCollapsed(collapsedFolderIds);
schedulePersistToDisk(foldersMap, collapsedFolderIds);
};
const createFolderId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -139,6 +216,14 @@ const syncCollapsedAfterFolderCleanup = (
return nextCollapsed;
};
const pruneEmptyArchivedFolders = (scopeKey: string, folders: SessionFolder[]): SessionFolder[] => {
if (!scopeKey.startsWith(ARCHIVED_SCOPE_PREFIX)) {
return folders;
}
return folders.filter((folder) => folder.sessionIds.length > 0);
};
// --- Store ---
export const useSessionFoldersStore = create<SessionFoldersStore>()(
@@ -168,7 +253,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
[scopeKey]: [...scopeFolders, folder],
};
set({ foldersMap: nextMap });
persistFolders(nextMap);
persistState(nextMap, get().collapsedFolderIds);
return folder;
},
@@ -183,7 +268,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
);
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
set({ foldersMap: nextMap });
persistFolders(nextMap);
persistState(nextMap, get().collapsedFolderIds);
},
deleteFolder: (scopeKey: string, folderId: string): void => {
@@ -206,7 +291,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
const nextFolders = scopeFolders.filter((folder) => !idsToDelete.has(folder.id));
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
set({ foldersMap: nextMap });
persistFolders(nextMap);
persistState(nextMap, get().collapsedFolderIds);
// Clean up collapsed state for all deleted folders
const collapsed = get().collapsedFolderIds;
@@ -215,7 +300,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
const nextCollapsed = new Set(collapsed);
idsToDelete.forEach((id) => nextCollapsed.delete(id));
set({ collapsedFolderIds: nextCollapsed });
persistCollapsed(nextCollapsed);
persistState(nextMap, nextCollapsed);
}
},
@@ -243,10 +328,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
set(nextCollapsed
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
: { foldersMap: nextMap });
persistFolders(nextMap);
if (nextCollapsed) {
persistCollapsed(nextCollapsed);
}
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
},
removeSessionFromFolder: (scopeKey: string, sessionId: string): void => {
@@ -272,10 +354,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
set(nextCollapsed
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
: { foldersMap: nextMap });
persistFolders(nextMap);
if (nextCollapsed) {
persistCollapsed(nextCollapsed);
}
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
},
toggleFolderCollapse: (folderId: string): void => {
@@ -287,7 +366,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
next.add(folderId);
}
set({ collapsedFolderIds: next });
persistCollapsed(next);
persistState(get().foldersMap, next);
},
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>): void => {
@@ -297,7 +376,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
if (!scopeFolders || scopeFolders.length === 0) return;
let changed = false;
const nextFolders = scopeFolders.map((folder) => {
const filteredFolders = scopeFolders.map((folder) => {
const filtered = folder.sessionIds.filter((id) => existingSessionIds.has(id));
if (filtered.length !== folder.sessionIds.length) {
changed = true;
@@ -306,6 +385,11 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
return folder;
});
const nextFolders = pruneEmptyArchivedFolders(scopeKey, filteredFolders);
if (nextFolders.length !== filteredFolders.length) {
changed = true;
}
if (!changed) return;
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
@@ -313,10 +397,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
set(nextCollapsed
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
: { foldersMap: nextMap });
persistFolders(nextMap);
if (nextCollapsed) {
persistCollapsed(nextCollapsed);
}
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
},
getSessionFolderId: (scopeKey: string, sessionId: string): string | null => {
@@ -334,3 +415,84 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
{ name: 'session-folders-store' },
),
);
const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
if (diskHydrated || diskHydrationInFlight || typeof window === 'undefined') {
return;
}
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
if (!runtimeFiles?.readFile) {
return;
}
const path = getSessionsDirectoriesPath();
if (!path) {
return;
}
diskHydrationInFlight = true;
const result = await runtimeFiles.readFile(path).catch(() => null);
if (!result?.content) {
diskHydrationInFlight = false;
diskHydrated = true;
return;
}
try {
const parsed = JSON.parse(result.content) as {
foldersMap?: SessionFoldersMap;
collapsedFolderIds?: string[];
};
const diskFolders = parsed?.foldersMap && typeof parsed.foldersMap === 'object'
? parsed.foldersMap
: {};
const diskCollapsed = Array.isArray(parsed?.collapsedFolderIds)
? new Set(parsed.collapsedFolderIds.filter((value): value is string => typeof value === 'string'))
: new Set<string>();
const hasDiskData = Object.keys(diskFolders).length > 0 || diskCollapsed.size > 0;
if (!hasDiskData) {
return;
}
useSessionFoldersStore.setState({
foldersMap: diskFolders,
collapsedFolderIds: diskCollapsed,
});
persistFolders(diskFolders);
persistCollapsed(diskCollapsed);
} catch {
// ignored
} finally {
diskHydrationInFlight = false;
diskHydrated = true;
}
};
const bootstrapSessionFoldersDiskHydration = (): void => {
if (typeof window === 'undefined') {
return;
}
let attempts = 0;
const maxAttempts = 20;
const runAttempt = () => {
attempts += 1;
void hydrateSessionFoldersFromDisk();
if (diskHydrated || attempts >= maxAttempts) {
return;
}
setTimeout(runAttempt, 500);
};
runAttempt();
};
bootstrapSessionFoldersDiskHydration();
@@ -83,6 +83,7 @@ export const useSessionStore = create<SessionStore>()(
(set, get) => ({
sessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
currentSessionId: null,
lastLoadedDirectory: null,
@@ -280,6 +281,8 @@ export const useSessionStore = create<SessionStore>()(
},
deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options),
deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options),
archiveSession: (id: string) => useSessionManagementStore.getState().archiveSession(id),
archiveSessions: (ids: string[], options) => useSessionManagementStore.getState().archiveSessions(ids, options),
updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title),
shareSession: (id: string) => useSessionManagementStore.getState().shareSession(id),
unshareSession: (id: string) => useSessionManagementStore.getState().unshareSession(id),
@@ -876,6 +879,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
if (
state.sessions === prevState.sessions &&
state.archivedSessions === prevState.archivedSessions &&
state.sessionsByDirectory === prevState.sessionsByDirectory &&
state.currentSessionId === prevState.currentSessionId &&
state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
@@ -893,6 +897,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
useSessionStore.setState({
sessions: state.sessions,
archivedSessions: state.archivedSessions,
sessionsByDirectory: state.sessionsByDirectory,
currentSessionId: draftOpen ? null : state.currentSessionId,
lastLoadedDirectory: state.lastLoadedDirectory,