feat(session-folders): folder organization, sub-folders, delete confirmations, and UX improvements (#469)

* feat(session-folders): drag-to-folder DnD, sort by activity, and UX improvements

- Add DraggableSessionRow wrapping each session row so the whole row is
  draggable; stopPropagation prevents outer group-reorder DnD from firing
- Add DroppableFolderWrapper + SessionFolderDndScope (inner DndContext
  scoped per group) with closestCenter collision detection
- DragOverlay matches exact width/height of dragged row so cursor stays
  aligned
- Folder header highlights (ring + primary colour) when a session hovers
  over it during drag
- + button on folder header opens a dropdown: 'New session' / 'New folder'
- + button on each folder row creates a session scoped to that folder
- Empty folders are no longer auto-deleted (removed .filter(sessionIds.length>0)
  from addSessionToFolder / removeSessionFromFolder / cleanupSessions)
- Sessions inside a folder are sorted by most-recent activity (same
  compareSessionsByPinnedAndTime logic used everywhere else)
- Sort comparator now takes sessionAttentionStates so lastUserMessageAt /
  lastStatusChangeAt is used when newer than session.time.updated; all
  sort call-sites and their useMemo/useCallback deps updated accordingly
- Remove foldersMap from cleanup effect deps to prevent cascade re-renders
  when folders change; read current value via getState() instead

* fix(session-folders): new session is placed into the correct folder

sendMessage() was calling useSessionManagementStore.createSession()
directly, bypassing the targetFolderId logic in useSessionStore.createSession.

Fix: read targetFolderId from draft at the top of the draft branch in
sendMessage, then call addSessionToFolder immediately after the session
is created and before the draft is closed. Also propagate targetFolderId
through openNewSessionDraft options and NewSessionDraftState type.

* feat(session-folders): add sub-folder support (one level deep)

- SessionFolder gains optional parentId field for hierarchy
- createFolder accepts parentId to create sub-folders
- deleteFolder cascades to remove all child sub-folders
- SessionFolderItem renders sub-folders before sessions in body;
  new sub-folder button (RiFolderAddLine) visible at depth 0 only
- renderOneFolderItem in SessionSidebar builds the tree recursively;
  sub-folders are indented via depth prop (ml-3 on root's children)
- Persist/hydrate parentId correctly from localStorage

* feat(session): add delete confirm dialogs and improve subtitle UX

- Add confirmation dialogs before deleting sessions or folders
- Show relative time (e.g., '2h ago', '35min ago') for recent sessions
- Replace +/- diff numbers with file change count (e.g., '3 files changed')
- New folders use default name without forcing rename
- Cleaner, less cluttered session list UI

* fix(session-folders): skip folder cleanup while sessions are loading

Prevents race condition on reload where cleanupSessions() runs before
the server returns the full session list, causing folder-session
assignments to be incorrectly wiped from localStorage.
This commit is contained in:
Nguyễn Ngô Thượng
2026-02-22 22:56:38 +02:00
committed by GitHub
parent 107e19a0dd
commit 5fc4feee42
5 changed files with 618 additions and 185 deletions
@@ -8,6 +8,8 @@ import {
RiDeleteBinLine,
RiCheckLine,
RiCloseLine,
RiAddLine,
RiFolderAddLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
@@ -15,6 +17,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
sessions: TSessionNode[];
/** Sub-folders that belong directly to this folder */
subFolderItems?: React.ReactNode;
isCollapsed: boolean;
onToggle: () => void;
onRename: (name: string) => void;
@@ -33,11 +37,22 @@ interface SessionFolderItemProps<TSessionNode> {
onRenameDraftChange?: (value: string) => void;
onRenameSave?: () => void;
onRenameCancel?: () => void;
/** Ref callback from useDroppable – attach to folder header to make it a drop zone */
droppableRef?: (node: HTMLElement | null) => void;
/** Whether a draggable session is currently hovering over this folder */
isDropTarget?: boolean;
/** Create a new session scoped to this folder */
onNewSession?: () => void;
/** Create a new sub-folder inside this folder */
onNewSubFolder?: () => void;
/** Visual indent depth (0 = root folder, 1 = sub-folder) */
depth?: number;
}
const SessionFolderItemBase = <TSessionNode,>({
folder,
sessions,
subFolderItems,
isCollapsed,
onToggle,
onRename,
@@ -51,6 +66,11 @@ const SessionFolderItemBase = <TSessionNode,>({
onRenameDraftChange,
onRenameSave,
onRenameCancel,
droppableRef,
isDropTarget = false,
onNewSession,
onNewSubFolder,
depth = 0,
}: SessionFolderItemProps<TSessionNode>) => {
const [localRenaming, setLocalRenaming] = React.useState(false);
const [localDraft, setLocalDraft] = React.useState('');
@@ -95,7 +115,7 @@ const SessionFolderItemBase = <TSessionNode,>({
[isRenaming, onRenameDraftChange],
);
// Auto-focus rename when externally triggered (e.g. from session menu)
// Auto-focus rename when externally triggered
React.useEffect(() => {
if (!isRenaming) return;
const focusInput = () => {
@@ -113,14 +133,17 @@ const SessionFolderItemBase = <TSessionNode,>({
}, [isRenaming]);
const FolderIcon = isCollapsed ? RiFolderLine : RiFolderOpenLine;
const isSubFolder = depth > 0;
return (
<div className="oc-folder">
{/* Folder header */}
<div className={cn('oc-folder', isSubFolder && 'ml-3')}>
{/* Folder header – also acts as a drop zone when droppableRef is provided */}
<div
ref={droppableRef}
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',
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
)}
onClick={renaming ? undefined : onToggle}
role={renaming ? undefined : 'button'}
@@ -138,7 +161,7 @@ const SessionFolderItemBase = <TSessionNode,>({
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" />
<FolderIcon className={cn('h-3.5 w-3.5 flex-shrink-0', isDropTarget ? 'text-primary' : 'text-muted-foreground')} />
{renaming ? (
<form
@@ -188,7 +211,7 @@ const SessionFolderItemBase = <TSessionNode,>({
</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">
<span className={cn('typography-ui-label font-semibold truncate', isDropTarget ? 'text-primary' : 'text-muted-foreground')}>
{folder.name}
</span>
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
@@ -213,6 +236,35 @@ const SessionFolderItemBase = <TSessionNode,>({
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
)}
>
{onNewSession ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNewSession();
}}
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={`New session in ${folder.name}`}
title="New session"
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
) : null}
{/* Only allow sub-folders at depth 0 (one level deep max) */}
{onNewSubFolder && depth === 0 ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNewSubFolder();
}}
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={`New sub-folder in ${folder.name}`}
title="New sub-folder"
>
<RiFolderAddLine className="h-3.5 w-3.5" />
</button>
) : null}
<button
type="button"
onClick={(event) => {
@@ -243,15 +295,18 @@ const SessionFolderItemBase = <TSessionNode,>({
{/* Folder body */}
{!isCollapsed ? (
<div className="pb-1 pl-2">
{/* Sub-folders first */}
{subFolderItems}
{/* Then sessions */}
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null),
)
) : (
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
Empty folder
</div>
)}
) : null}
</div>
) : null}
</div>