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 <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-02-21 01:09:55 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 62e5c3edfe
commit 6e63f23bb8
4 changed files with 783 additions and 14 deletions
@@ -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<TSessionNode> {
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 = <TSessionNode,>({
folder,
sessions,
isCollapsed,
onToggle,
onRename,
onDelete,
renderSessionNode,
groupDirectory,
projectId,
mobileVariant = false,
isRenaming = false,
renameDraft = '',
onRenameDraftChange,
onRenameSave,
onRenameCancel,
}: SessionFolderItemProps<TSessionNode>) => {
const [localRenaming, setLocalRenaming] = React.useState(false);
const [localDraft, setLocalDraft] = React.useState('');
const inputRef = React.useRef<HTMLInputElement | null>(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 (
<div className="oc-folder">
{/* Folder header */}
<div
className={cn(
'group/folder flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-sm',
'hover:bg-interactive-hover/50 cursor-pointer',
)}
onClick={renaming ? undefined : onToggle}
role={renaming ? undefined : 'button'}
tabIndex={renaming ? undefined : 0}
onKeyDown={
renaming
? undefined
: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle();
}
}
}
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
>
<div className="min-w-0 flex items-center gap-1.5 pl-1.5 flex-1">
<FolderIcon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{renaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-1"
data-keyboard-avoid="true"
onSubmit={(event) => {
event.preventDefault();
handleSaveRename();
}}
>
<input
ref={inputRef}
value={draft}
onChange={(event) => 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();
}
}}
/>
<button
type="submit"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(event) => event.stopPropagation()}
>
<RiCheckLine className="size-4" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleCancelRename();
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="size-4" />
</button>
</form>
) : (
<div className="min-w-0 flex items-center gap-1.5 flex-1">
<span className="typography-ui-label font-semibold text-muted-foreground truncate">
{folder.name}
</span>
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
{sessions.length}
</span>
{isCollapsed ? (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
</div>
)}
</div>
{/* Action buttons */}
{!renaming ? (
<div className="flex items-center gap-0.5 px-0.5">
<div
className={cn(
'flex items-center gap-0.5 transition-opacity',
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
)}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleStartRename();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Rename folder ${folder.name}`}
>
<RiPencilAiLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Delete folder ${folder.name}`}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : null}
</div>
{/* Folder body */}
{!isCollapsed ? (
<div className="pb-1 pl-2">
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null),
)
) : (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
Empty folder
</div>
)}
</div>
) : null}
</div>
);
};
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
props: SessionFolderItemProps<TSessionNode>,
) => React.ReactElement;
@@ -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<SessionSidebarProps> = ({
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null);
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
@@ -678,6 +687,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
};
}, [normalizedProjects, projectGitBranchesKey]);
// Session Folders: cleanup stale session IDs when sessions are removed
React.useEffect(() => {
const idsByScope = new Map<string, Set<string>>();
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<string>());
});
}, [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<SessionSidebarProps> = ({
<RiMore2Line className={mobileVariant ? 'h-4 w-4' : 'h-3.5 w-3.5'} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuContent
align="end"
className="min-w-[180px]"
onCloseAutoFocus={(event) => {
if (renamingFolderId) {
event.preventDefault();
}
}}
>
<DropdownMenuItem
onClick={() => {
setEditingId(session.id);
@@ -2122,6 +2172,71 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</DropdownMenuItem>
</>
)}
{/* Move to folder submenu */}
{sessionDirectory ? (() => {
const scopeFolders = getFoldersForScope(sessionDirectory);
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg]:mr-1">
<RiFolderLine className="h-4 w-4" />
Move to folder
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{scopeFolders.length === 0 ? (
<DropdownMenuItem disabled className="text-muted-foreground">
No folders yet
</DropdownMenuItem>
) : (
scopeFolders.map((folder) => (
<DropdownMenuItem
key={folder.id}
onClick={() => {
if (currentFolderId === folder.id) {
removeSessionFromFolder(sessionDirectory, session.id);
} else {
addSessionToFolder(sessionDirectory, folder.id, session.id);
}
}}
>
<span className="flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? (
<RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" />
) : null}
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
const newFolder = createFolder(sessionDirectory, 'New folder');
addSessionToFolder(sessionDirectory, newFolder.id, session.id);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft('New folder');
}}
>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
</DropdownMenuItem>
{currentFolderId ? (
<DropdownMenuItem
onClick={() => {
removeSessionFromFolder(sessionDirectory, session.id);
}}
className="text-destructive focus:text-destructive"
>
<RiCloseLine className="mr-1 h-4 w-4" />
Remove from folder
</DropdownMenuItem>
) : null}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
);
})() : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive [&>svg]:mr-1"
onClick={() => handleDeleteSession(session)}
@@ -2166,6 +2281,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
copiedSessionId,
mobileVariant,
openMenuSessionId,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
addSessionToFolder,
removeSessionFromFolder,
createFolder,
notifyOnSubtasks,
],
);
@@ -2175,8 +2296,25 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
// 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 }) => (
<SessionFolderItem
key={folder.id}
folder={folder}
sessions={nodes}
isCollapsed={collapsedFolderIds.has(folder.id)}
onToggle={() => 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 (
<div className="oc-group">
<div className="oc-group-body pb-3">
{renderFolderItems()}
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
{totalSessions === 0 ? (
{totalSessions === 0 && foldersWithSessions.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
No sessions in this workspace yet.
</div>
@@ -2355,8 +2531,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div>
{!isCollapsed ? (
<div className="oc-group-body pb-3">
{renderFolderItems()}
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
{totalSessions === 0 ? (
{totalSessions === 0 && foldersWithSessions.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
No sessions in this workspace yet.
</div>
@@ -2398,6 +2575,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
mobileVariant,
setSessionSwitcherOpen,
openNewSessionDraft,
getFoldersForScope,
collapsedFolderIds,
toggleFolderCollapse,
renameFolder,
deleteFolder,
renamingFolderId,
renameFolderDraft,
]
);
@@ -209,15 +209,15 @@ function DropdownMenuSubTrigger({
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover flex cursor-default items-center rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
{children}
<RiArrowRightSLine className="ml-auto size-3.5" />
</DropdownMenuPrimitive.SubTrigger>
@@ -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<string, SessionFolder[]>;
interface SessionFoldersState {
foldersMap: SessionFoldersMap;
collapsedFolderIds: Set<string>;
}
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<string>) => 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<string, unknown>;
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<string, unknown>;
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<string> => {
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<string>): 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<string>,
): Set<string> | null => {
const nextFolderIds = new Set(nextFolders.map((folder) => folder.id));
let nextCollapsed: Set<string> | 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<SessionFoldersStore>()(
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<string>): 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' },
),
);