From 6e63f23bb878e591085f458966a6976972c16c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ng=C3=B4=20Th=C6=B0=E1=BB=A3ng?= <83950837+nguyenngothuong@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:09:55 +0700 Subject: [PATCH] feat(sessions): add custom folder grouping for sessions (#460) * feat(sessions): add custom folder grouping for sessions Allow users to create custom folders to organize chat sessions. Folders are stored in localStorage, scoped per project. Features: - Create/rename/delete folders from sidebar header - Move sessions to folders via context menu - Remove sessions from folders - Collapse/expand folder state persisted - Automatic cleanup when sessions are deleted Implementation: - useSessionFoldersStore: Zustand store with CRUD + localStorage persistence - SessionFolderItem: Component for folder header (name, count, actions) + body - SessionSidebar: Integration with context menu, folder rendering in session list * fix(sessions): remove sidebar header folder action * fix: remove folder when they becomes empty * refactor(sessions): switch folder scope to session directory and clean folder interactions * fix: move folder actions outside rename mode --------- Co-authored-by: Bohdan Triapitsyn --- .../components/session/SessionFolderItem.tsx | 263 ++++++++++++++ .../src/components/session/SessionSidebar.tsx | 194 ++++++++++- .../ui/src/components/ui/dropdown-menu.tsx | 18 +- .../ui/src/stores/useSessionFoldersStore.ts | 322 ++++++++++++++++++ 4 files changed, 783 insertions(+), 14 deletions(-) create mode 100644 packages/ui/src/components/session/SessionFolderItem.tsx create mode 100644 packages/ui/src/stores/useSessionFoldersStore.ts diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx new file mode 100644 index 00000000..0d9ca201 --- /dev/null +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -0,0 +1,263 @@ +import React from 'react'; +import { + RiFolderLine, + RiFolderOpenLine, + RiArrowRightSLine, + RiArrowDownSLine, + RiPencilAiLine, + RiDeleteBinLine, + RiCheckLine, + RiCloseLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import type { SessionFolder } from '@/stores/useSessionFoldersStore'; + +interface SessionFolderItemProps { + folder: SessionFolder; + sessions: TSessionNode[]; + isCollapsed: boolean; + onToggle: () => void; + onRename: (name: string) => void; + onDelete: () => void; + renderSessionNode: ( + node: TSessionNode, + depth?: number, + groupDir?: string | null, + projectId?: string | null, + ) => React.ReactNode; + groupDirectory?: string | null; + projectId?: string | null; + mobileVariant?: boolean; + isRenaming?: boolean; + renameDraft?: string; + onRenameDraftChange?: (value: string) => void; + onRenameSave?: () => void; + onRenameCancel?: () => void; +} + +const SessionFolderItemBase = ({ + folder, + sessions, + isCollapsed, + onToggle, + onRename, + onDelete, + renderSessionNode, + groupDirectory, + projectId, + mobileVariant = false, + isRenaming = false, + renameDraft = '', + onRenameDraftChange, + onRenameSave, + onRenameCancel, +}: SessionFolderItemProps) => { + const [localRenaming, setLocalRenaming] = React.useState(false); + const [localDraft, setLocalDraft] = React.useState(''); + const inputRef = React.useRef(null); + + const renaming = isRenaming || localRenaming; + const draft = isRenaming ? renameDraft : localDraft; + + const handleStartRename = React.useCallback(() => { + setLocalDraft(folder.name); + setLocalRenaming(true); + }, [folder.name]); + + const handleSaveRename = React.useCallback(() => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== folder.name) { + onRename(trimmed); + } + if (isRenaming && onRenameSave) { + onRenameSave(); + } + setLocalRenaming(false); + setLocalDraft(''); + }, [draft, folder.name, isRenaming, onRename, onRenameSave]); + + const handleCancelRename = React.useCallback(() => { + if (isRenaming && onRenameCancel) { + onRenameCancel(); + } + setLocalRenaming(false); + setLocalDraft(''); + }, [isRenaming, onRenameCancel]); + + const handleDraftChange = React.useCallback( + (value: string) => { + if (isRenaming && onRenameDraftChange) { + onRenameDraftChange(value); + } else { + setLocalDraft(value); + } + }, + [isRenaming, onRenameDraftChange], + ); + + // Auto-focus rename when externally triggered (e.g. from session menu) + React.useEffect(() => { + if (!isRenaming) return; + const focusInput = () => { + const input = inputRef.current; + if (!input) return; + input.focus(); + input.select(); + }; + const frameId = requestAnimationFrame(focusInput); + const timeoutId = window.setTimeout(focusInput, 0); + return () => { + cancelAnimationFrame(frameId); + window.clearTimeout(timeoutId); + }; + }, [isRenaming]); + + const FolderIcon = isCollapsed ? RiFolderLine : RiFolderOpenLine; + + return ( +
+ {/* Folder header */} +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggle(); + } + } + } + aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`} + > +
+ + + {renaming ? ( +
{ + event.preventDefault(); + handleSaveRename(); + }} + > + handleDraftChange(event.target.value)} + className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" + autoFocus + placeholder="Folder name" + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + handleCancelRename(); + return; + } + if (event.key === ' ' || event.key === 'Enter') { + event.stopPropagation(); + } + }} + /> + + +
+ ) : ( +
+ + {folder.name} + + + • {sessions.length} + + {isCollapsed ? ( + + ) : ( + + )} +
+ )} + +
+ + {/* Action buttons */} + {!renaming ? ( +
+
+ + +
+
+ ) : null} +
+ + {/* Folder body */} + {!isCollapsed ? ( +
+ {sessions.length > 0 ? ( + sessions.map((node) => + renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null), + ) + ) : ( +
+ Empty folder +
+ )} +
+ ) : null} +
+ ); +}; + +export const SessionFolderItem = React.memo(SessionFolderItemBase) as ( + props: SessionFolderItemProps, +) => React.ReactElement; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index c5325fc6..394e976c 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -26,6 +26,10 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -42,6 +46,7 @@ import { RiErrorWarningLine, RiFileCopyLine, RiFolderAddLine, + RiFolderLine, RiGitBranchLine, RiGitPullRequestLine, RiGitRepositoryLine, @@ -80,6 +85,8 @@ import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel'; import { BranchPickerDialog } from './BranchPickerDialog'; +import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; +import { SessionFolderItem } from './SessionFolderItem'; const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); @@ -589,6 +596,8 @@ export const SessionSidebar: React.FC = ({ const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); + const [renamingFolderId, setRenamingFolderId] = React.useState(null); + const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { try { const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY); @@ -678,6 +687,19 @@ export const SessionSidebar: React.FC = ({ const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); + // Session Folders store + const foldersMap = useSessionFoldersStore((state) => state.foldersMap); + const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds); + const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); + const createFolder = useSessionFoldersStore((state) => state.createFolder); + const renameFolder = useSessionFoldersStore((state) => state.renameFolder); + const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder); + const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder); + const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder); + const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse); + const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions); + const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); + const gitDirectories = useGitStore((state) => state.directories); const sessions = useSessionStore((state) => state.sessions); @@ -1455,6 +1477,26 @@ export const SessionSidebar: React.FC = ({ }; }, [normalizedProjects, projectGitBranchesKey]); + // Session Folders: cleanup stale session IDs when sessions are removed + React.useEffect(() => { + const idsByScope = new Map>(); + sessions.forEach((session) => { + const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (!directory) return; + const existing = idsByScope.get(directory); + if (existing) { + existing.add(session.id); + return; + } + idsByScope.set(directory, new Set([session.id])); + }); + + const allScopeKeys = new Set([...Object.keys(foldersMap), ...idsByScope.keys()]); + allScopeKeys.forEach((scopeKey) => { + cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set()); + }); + }, [sessions, foldersMap, cleanupSessions]); + const getSessionsForProject = React.useCallback( (project: { normalizedPath: string }) => { // In VS Code, only show sessions from the main project directory (skip worktrees) @@ -2070,7 +2112,15 @@ export const SessionSidebar: React.FC = ({ - + { + if (renamingFolderId) { + event.preventDefault(); + } + }} + > { setEditingId(session.id); @@ -2122,6 +2172,71 @@ export const SessionSidebar: React.FC = ({ )} + {/* Move to folder submenu */} + {sessionDirectory ? (() => { + const scopeFolders = getFoldersForScope(sessionDirectory); + const currentFolderId = getSessionFolderId(sessionDirectory, session.id); + return ( + <> + + + + + Move to folder + + + {scopeFolders.length === 0 ? ( + + No folders yet + + ) : ( + scopeFolders.map((folder) => ( + { + if (currentFolderId === folder.id) { + removeSessionFromFolder(sessionDirectory, session.id); + } else { + addSessionToFolder(sessionDirectory, folder.id, session.id); + } + }} + > + {folder.name} + {currentFolderId === folder.id ? ( + + ) : null} + + )) + )} + + { + const newFolder = createFolder(sessionDirectory, 'New folder'); + addSessionToFolder(sessionDirectory, newFolder.id, session.id); + setRenamingFolderId(newFolder.id); + setRenameFolderDraft('New folder'); + }} + > + + New folder... + + {currentFolderId ? ( + { + removeSessionFromFolder(sessionDirectory, session.id); + }} + className="text-destructive focus:text-destructive" + > + + Remove from folder + + ) : null} + + + + ); + })() : null} + handleDeleteSession(session)} @@ -2166,6 +2281,12 @@ export const SessionSidebar: React.FC = ({ copiedSessionId, mobileVariant, openMenuSessionId, + renamingFolderId, + getFoldersForScope, + getSessionFolderId, + addSessionToFolder, + removeSessionFromFolder, + createFolder, notifyOnSubtasks, ], ); @@ -2175,8 +2296,25 @@ export const SessionSidebar: React.FC = ({ const isExpanded = expandedSessionGroups.has(groupKey); const isCollapsed = collapsedGroups.has(groupKey); const maxVisible = hideDirectoryControls ? 10 : 5; - const totalSessions = group.sessions.length; - const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); + + // --- Session Folders: split into foldered vs ungrouped --- + const folderScopeKey = normalizePath(group.directory ?? null); + const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; + const sessionIdsInFolders = new Set(scopeFolders.flatMap((f) => f.sessionIds)); + const ungroupedSessions = group.sessions.filter((node) => !sessionIdsInFolders.has(node.session.id)); + + // Folders that have sessions in THIS group + const foldersWithSessions = scopeFolders + .map((folder) => { + const nodes = folder.sessionIds + .map((sid) => group.sessions.find((node) => node.session.id === sid)) + .filter((n): n is SessionNode => Boolean(n)); + return { folder, nodes }; + }) + .filter(({ nodes }) => nodes.length > 0); + + const totalSessions = ungroupedSessions.length; + const visibleSessions = isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible); const remainingCount = totalSessions - visibleSessions.length; const collectGroupSessions = (nodes: SessionNode[]): Session[] => { const collected: Session[] = []; @@ -2205,12 +2343,50 @@ export const SessionSidebar: React.FC = ({ // VS Code sessions list uses a separate header (Agent Manager / New Session). // When the caller requests a flat list (hideGroupLabel), omit the per-group header entirely. + // Shared folder rendering helper (used in both branches) + const renderFolderItems = () => + foldersWithSessions.map(({ folder, nodes }) => ( + toggleFolderCollapse(folder.id)} + onRename={(name) => { + if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); + }} + onDelete={() => { + if (folderScopeKey) deleteFolder(folderScopeKey, folder.id); + }} + renderSessionNode={renderSessionNode} + groupDirectory={group.directory} + projectId={projectId} + mobileVariant={mobileVariant} + isRenaming={renamingFolderId === folder.id} + renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined} + onRenameDraftChange={(value) => setRenameFolderDraft(value)} + onRenameSave={() => { + const trimmed = renameFolderDraft.trim(); + if (trimmed && folderScopeKey) { + renameFolder(folderScopeKey, folder.id, trimmed); + } + setRenamingFolderId(null); + setRenameFolderDraft(''); + }} + onRenameCancel={() => { + setRenamingFolderId(null); + setRenameFolderDraft(''); + }} + /> + )); + if (hideGroupLabel) { return (
+ {renderFolderItems()} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 ? ( + {totalSessions === 0 && foldersWithSessions.length === 0 ? (
No sessions in this workspace yet.
@@ -2355,8 +2531,9 @@ export const SessionSidebar: React.FC = ({
{!isCollapsed ? (
+ {renderFolderItems()} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 ? ( + {totalSessions === 0 && foldersWithSessions.length === 0 ? (
No sessions in this workspace yet.
@@ -2398,6 +2575,13 @@ export const SessionSidebar: React.FC = ({ mobileVariant, setSessionSwitcherOpen, openNewSessionDraft, + getFoldersForScope, + collapsedFolderIds, + toggleFolderCollapse, + renameFolder, + deleteFolder, + renamingFolderId, + renameFolderDraft, ] ); diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 6a6512a2..b224bcac 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -209,15 +209,15 @@ function DropdownMenuSubTrigger({ inset?: boolean }) { return ( - + {children} diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts new file mode 100644 index 00000000..bd883962 --- /dev/null +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -0,0 +1,322 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { getSafeStorage } from './utils/safeStorage'; + +// --- Types --- + +export interface SessionFolder { + id: string; + name: string; + sessionIds: string[]; + createdAt: number; +} + +type SessionFoldersMap = Record; + +interface SessionFoldersState { + foldersMap: SessionFoldersMap; + collapsedFolderIds: Set; +} + +interface SessionFoldersActions { + getFoldersForScope: (scopeKey: string) => SessionFolder[]; + createFolder: (scopeKey: string, name: string) => SessionFolder; + renameFolder: (scopeKey: string, folderId: string, name: string) => void; + deleteFolder: (scopeKey: string, folderId: string) => void; + addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; + removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; + toggleFolderCollapse: (folderId: string) => void; + cleanupSessions: (scopeKey: string, existingSessionIds: Set) => void; + getSessionFolderId: (scopeKey: string, sessionId: string) => string | null; +} + +type SessionFoldersStore = SessionFoldersState & SessionFoldersActions; + +// --- Storage --- + +const FOLDERS_STORAGE_KEY = 'oc.sessions.folders'; +const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse'; + +const safeStorage = getSafeStorage(); + +const readPersistedFolders = (): SessionFoldersMap => { + try { + const raw = safeStorage.getItem(FOLDERS_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + const result: SessionFoldersMap = {}; + for (const [scopeKey, value] of Object.entries(parsed)) { + if (!Array.isArray(value)) { + continue; + } + const folders: SessionFolder[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry as Record; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; + const createdAt = typeof candidate.createdAt === 'number' ? candidate.createdAt : 0; + if (!id || !name) continue; + const sessionIds = Array.isArray(candidate.sessionIds) + ? (candidate.sessionIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + : []; + folders.push({ id, name, sessionIds, createdAt }); + } + if (folders.length > 0) { + result[scopeKey] = folders; + } + } + return result; + } catch { + return {}; + } +}; + +const readPersistedCollapsed = (): Set => { + try { + const raw = safeStorage.getItem(COLLAPSED_STORAGE_KEY); + if (!raw) { + return new Set(); + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((v): v is string => typeof v === 'string')); + } catch { + return new Set(); + } +}; + +const persistFolders = (foldersMap: SessionFoldersMap): void => { + try { + safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(foldersMap)); + } catch { + // ignored + } +}; + +const persistCollapsed = (collapsedFolderIds: Set): void => { + try { + safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(collapsedFolderIds))); + } catch { + // ignored + } +}; + +const createFolderId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}; + +const syncCollapsedAfterFolderCleanup = ( + prevFolders: SessionFolder[], + nextFolders: SessionFolder[], + collapsedFolderIds: Set, +): Set | null => { + const nextFolderIds = new Set(nextFolders.map((folder) => folder.id)); + let nextCollapsed: Set | null = null; + + for (const folder of prevFolders) { + if (!nextFolderIds.has(folder.id) && collapsedFolderIds.has(folder.id)) { + if (!nextCollapsed) { + nextCollapsed = new Set(collapsedFolderIds); + } + nextCollapsed.delete(folder.id); + } + } + + return nextCollapsed; +}; + +// --- Store --- + +export const useSessionFoldersStore = create()( + devtools( + (set, get) => ({ + foldersMap: readPersistedFolders(), + collapsedFolderIds: readPersistedCollapsed(), + + getFoldersForScope: (scopeKey: string): SessionFolder[] => { + if (!scopeKey) return []; + return get().foldersMap[scopeKey] ?? []; + }, + + createFolder: (scopeKey: string, name: string): SessionFolder => { + const trimmed = name.trim() || 'New folder'; + const folder: SessionFolder = { + id: createFolderId(), + name: trimmed, + sessionIds: [], + createdAt: Date.now(), + }; + const current = get().foldersMap; + const scopeFolders = current[scopeKey] ?? []; + const nextMap: SessionFoldersMap = { + ...current, + [scopeKey]: [...scopeFolders, folder], + }; + set({ foldersMap: nextMap }); + persistFolders(nextMap); + return folder; + }, + + renameFolder: (scopeKey: string, folderId: string, name: string): void => { + const trimmed = name.trim(); + if (!trimmed || !scopeKey) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders) return; + const nextFolders = scopeFolders.map((folder) => + folder.id === folderId ? { ...folder, name: trimmed } : folder, + ); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + set({ foldersMap: nextMap }); + persistFolders(nextMap); + }, + + deleteFolder: (scopeKey: string, folderId: string): void => { + if (!scopeKey) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders) return; + const nextFolders = scopeFolders.filter((folder) => folder.id !== folderId); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + set({ foldersMap: nextMap }); + persistFolders(nextMap); + + // Clean up collapsed state + const collapsed = get().collapsedFolderIds; + if (collapsed.has(folderId)) { + const nextCollapsed = new Set(collapsed); + nextCollapsed.delete(folderId); + set({ collapsedFolderIds: nextCollapsed }); + persistCollapsed(nextCollapsed); + } + }, + + addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string): void => { + if (!scopeKey || !folderId || !sessionId) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders) return; + + // Remove session from any existing folder first, then add to target + const nextFolders = scopeFolders.map((folder) => { + const withoutSession = folder.sessionIds.filter((id) => id !== sessionId); + if (folder.id === folderId) { + return { ...folder, sessionIds: [...withoutSession, sessionId] }; + } + if (withoutSession.length !== folder.sessionIds.length) { + return { ...folder, sessionIds: withoutSession }; + } + return folder; + }); + + const filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, get().collapsedFolderIds); + + set(nextCollapsed + ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } + : { foldersMap: nextMap }); + persistFolders(nextMap); + if (nextCollapsed) { + persistCollapsed(nextCollapsed); + } + }, + + removeSessionFromFolder: (scopeKey: string, sessionId: string): void => { + if (!scopeKey || !sessionId) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders) return; + + let changed = false; + const nextFolders = scopeFolders.map((folder) => { + const filtered = folder.sessionIds.filter((id) => id !== sessionId); + if (filtered.length !== folder.sessionIds.length) { + changed = true; + return { ...folder, sessionIds: filtered }; + } + return folder; + }); + + if (!changed) return; + const filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, get().collapsedFolderIds); + + set(nextCollapsed + ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } + : { foldersMap: nextMap }); + persistFolders(nextMap); + if (nextCollapsed) { + persistCollapsed(nextCollapsed); + } + }, + + toggleFolderCollapse: (folderId: string): void => { + const collapsed = get().collapsedFolderIds; + const next = new Set(collapsed); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + set({ collapsedFolderIds: next }); + persistCollapsed(next); + }, + + cleanupSessions: (scopeKey: string, existingSessionIds: Set): void => { + if (!scopeKey) return; + const current = get().foldersMap; + const scopeFolders = current[scopeKey]; + if (!scopeFolders || scopeFolders.length === 0) return; + + let changed = false; + const nextFolders = scopeFolders.map((folder) => { + const filtered = folder.sessionIds.filter((id) => existingSessionIds.has(id)); + if (filtered.length !== folder.sessionIds.length) { + changed = true; + return { ...folder, sessionIds: filtered }; + } + return folder; + }); + + if (!changed) return; + const filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, get().collapsedFolderIds); + + set(nextCollapsed + ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } + : { foldersMap: nextMap }); + persistFolders(nextMap); + if (nextCollapsed) { + persistCollapsed(nextCollapsed); + } + }, + + getSessionFolderId: (scopeKey: string, sessionId: string): string | null => { + if (!scopeKey || !sessionId) return null; + const scopeFolders = get().foldersMap[scopeKey]; + if (!scopeFolders) return null; + for (const folder of scopeFolders) { + if (folder.sessionIds.includes(sessionId)) { + return folder.id; + } + } + return null; + }, + }), + { name: 'session-folders-store' }, + ), +);