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
+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();