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>