From 5fc4feee42fab18b917e5a69dfa18226b7520f31 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: Mon, 23 Feb 2026 03:56:38 +0700 Subject: [PATCH] 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. --- .../components/session/SessionFolderItem.tsx | 69 +- .../src/components/session/SessionSidebar.tsx | 661 +++++++++++++----- packages/ui/src/stores/types/sessionTypes.ts | 3 +- .../ui/src/stores/useSessionFoldersStore.ts | 46 +- packages/ui/src/stores/useSessionStore.ts | 24 +- 5 files changed, 618 insertions(+), 185 deletions(-) diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx index 0d9ca201..24b201fe 100644 --- a/packages/ui/src/components/session/SessionFolderItem.tsx +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -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 { 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 { 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 = ({ folder, sessions, + subFolderItems, isCollapsed, onToggle, onRename, @@ -51,6 +66,11 @@ const SessionFolderItemBase = ({ onRenameDraftChange, onRenameSave, onRenameCancel, + droppableRef, + isDropTarget = false, + onNewSession, + onNewSubFolder, + depth = 0, }: SessionFolderItemProps) => { const [localRenaming, setLocalRenaming] = React.useState(false); const [localDraft, setLocalDraft] = React.useState(''); @@ -95,7 +115,7 @@ const SessionFolderItemBase = ({ [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 = ({ }, [isRenaming]); const FolderIcon = isCollapsed ? RiFolderLine : RiFolderOpenLine; + const isSubFolder = depth > 0; return ( -
- {/* Folder header */} +
+ {/* Folder header – also acts as a drop zone when droppableRef is provided */}
({ aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`} >
- + {renaming ? (
({
) : (
- + {folder.name} @@ -213,6 +236,35 @@ const SessionFolderItemBase = ({ mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100', )} > + {onNewSession ? ( + + ) : null} + {/* Only allow sub-folders at depth 0 (one level deep max) */} + {onNewSubFolder && depth === 0 ? ( + + ) : null}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 394e976c..00be8531 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -11,7 +11,10 @@ import { PointerSensor, useSensor, useSensors, + useDraggable, + useDroppable, type Modifier, + type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, @@ -33,6 +36,14 @@ import { } from '@/components/ui/dropdown-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { GridLoader } from '@/components/ui/grid-loader'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -126,6 +137,25 @@ const formatDateLabel = (value: string | number) => { return formatted.replace(',', ''); }; +/** Returns relative time if updated today, otherwise falls back to formatDateLabel using updated time. */ +const formatSessionDateLabel = (updatedMs: number): string => { + const today = new Date(); + const updatedDate = new Date(updatedMs); + const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + + if (isSameDay(updatedDate, today)) { + const diff = Date.now() - updatedMs; + if (diff < 60_000) return 'Just now'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`; + return `${Math.floor(diff / 3_600_000)}h ago`; + } + + return formatDateLabel(updatedMs); +}; + const normalizePath = (value?: string | null) => { if (!value) { return null; @@ -164,11 +194,26 @@ const getSessionCreatedAt = (session: Session): number => { return toFiniteNumber(session.time?.created) ?? 0; }; -const getSessionUpdatedAt = (session: Session): number => { - return toFiniteNumber(session.time?.updated) ?? 0; +const getSessionUpdatedAt = (session: Session, attentionStates?: Map): number => { + const baseUpdated = toFiniteNumber(session.time?.updated) ?? 0; + if (!attentionStates) return baseUpdated; + + const attention = attentionStates.get(session.id); + if (!attention) return baseUpdated; + + return Math.max( + baseUpdated, + attention.lastUserMessageAt ?? 0, + attention.lastStatusChangeAt ?? 0 + ); }; -const compareSessionsByPinnedAndTime = (a: Session, b: Session, pinnedSessionIds: Set): number => { +const compareSessionsByPinnedAndTime = ( + a: Session, + b: Session, + pinnedSessionIds: Set, + attentionStates?: Map +): number => { const aPinned = pinnedSessionIds.has(a.id); const bPinned = pinnedSessionIds.has(b.id); if (aPinned !== bPinned) { @@ -179,7 +224,7 @@ const compareSessionsByPinnedAndTime = (a: Session, b: Session, pinnedSessionIds return getSessionCreatedAt(b) - getSessionCreatedAt(a); } - return getSessionUpdatedAt(b) - getSessionUpdatedAt(a); + return getSessionUpdatedAt(b, attentionStates) - getSessionUpdatedAt(a, attentionStates); }; const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => { @@ -219,6 +264,148 @@ type SessionGroup = { sessions: SessionNode[]; }; +// --- Session Folder DnD helpers --- + +/** + * Wraps a session row so the entire row is draggable onto folder drop zones. + * Stops pointer propagation so the outer group-reorder DndContext does not + * capture the drag (otherwise dragging a session moves the whole workspace group). + */ +const DraggableSessionRow: React.FC<{ + sessionId: string; + sessionDirectory: string | null; + sessionTitle: string; + children: React.ReactNode; +}> = ({ sessionId, sessionDirectory, sessionTitle, children }) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `session-drag:${sessionId}`, + data: { type: 'session', sessionId, sessionDirectory, sessionTitle }, + }); + + const handlePointerDown = React.useCallback( + (e: React.PointerEvent) => { + // Stop event from bubbling to the outer group-reorder DndContext + e.stopPropagation(); + if (listeners?.onPointerDown) { + (listeners.onPointerDown as (event: React.PointerEvent) => void)(e); + } + }, + [listeners], + ); + + return ( +
+ {children} +
+ ); +}; + +/** + * Wraps a and makes it a droppable target. + * Uses a render-prop pattern so the ref/isOver state can be passed + * down as props (avoids hooks-in-callbacks restrictions). + */ +const DroppableFolderWrapper: React.FC<{ + folderId: string; + children: ( + droppableRef: (node: HTMLElement | null) => void, + isOver: boolean, + ) => React.ReactNode; +}> = ({ folderId, children }) => { + const { setNodeRef, isOver } = useDroppable({ + id: `folder-drop:${folderId}`, + data: { type: 'folder', folderId }, + }); + return <>{children(setNodeRef, isOver)}; +}; + +/** + * Provides an inner DndContext scoped to one group, allowing sessions to be + * dragged onto folder headers within that group. + */ +const SessionFolderDndScope: React.FC<{ + scopeKey: string | null; + hasFolders: boolean; + onSessionDroppedOnFolder: (sessionId: string, folderId: string) => void; + children: React.ReactNode; +}> = ({ scopeKey, hasFolders, onSessionDroppedOnFolder, children }) => { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + const [activeDragId, setActiveDragId] = React.useState(null); + const [activeDragTitle, setActiveDragTitle] = React.useState('Session'); + const [activeDragWidth, setActiveDragWidth] = React.useState(null); + const [activeDragHeight, setActiveDragHeight] = React.useState(null); + + // Always need DndContext when scopeKey exists (DraggableSessionRow requires it). + // When there are no folders the drag just has nowhere to land – that's fine. + if (!scopeKey) { + return <>{children}; + } + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragId(null); + setActiveDragWidth(null); + setActiveDragHeight(null); + const { active, over } = event; + if (!over) return; + const activeData = active.data.current as { type?: string; sessionId?: string } | undefined; + const overData = over.data.current as { type?: string; folderId?: string } | undefined; + if (activeData?.type === 'session' && activeData.sessionId && overData?.type === 'folder' && overData.folderId) { + onSessionDroppedOnFolder(activeData.sessionId, overData.folderId); + } + }; + + return ( + { + const data = event.active.data.current as { type?: string; sessionId?: string; sessionTitle?: string } | undefined; + if (data?.type === 'session' && data.sessionId) { + setActiveDragId(data.sessionId); + setActiveDragTitle(data.sessionTitle ?? 'Session'); + const width = event.active.rect.current.initial?.width; + const height = event.active.rect.current.initial?.height; + setActiveDragWidth(typeof width === 'number' ? width : null); + setActiveDragHeight(typeof height === 'number' ? height : null); + } + }} + onDragCancel={() => { + setActiveDragId(null); + setActiveDragWidth(null); + setActiveDragHeight(null); + }} + onDragEnd={handleDragEnd} + > + {children} + + {activeDragId && hasFolders ? ( +
+ +
+ {activeDragTitle} +
+
+ ) : null} +
+
+ ); +}; + +// --- End Session Folder DnD helpers --- + interface SortableProjectItemProps { id: string; projectLabel: string; @@ -598,6 +785,17 @@ export const SessionSidebar: React.FC = ({ const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); const [renamingFolderId, setRenamingFolderId] = React.useState(null); const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); + const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<{ + session: Session; + descendantCount: number; + } | null>(null); + const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<{ + scopeKey: string; + folderId: string; + folderName: string; + subFolderCount: number; + sessionCount: number; + } | null>(null); const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { try { const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY); @@ -688,6 +886,8 @@ export const SessionSidebar: React.FC = ({ const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); // Session Folders store + // Subscribe to foldersMap so renderSessionNode/renderGroupSessions re-run when any folder changes. + // getFoldersForScope is a stable function selector and does not trigger re-renders on its own. const foldersMap = useSessionFoldersStore((state) => state.foldersMap); const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds); const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); @@ -826,8 +1026,8 @@ export const SessionSidebar: React.FC = ({ }, []); const sortedSessions = React.useMemo(() => { - return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); - }, [sessions, pinnedSessionIds]); + return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)); + }, [sessions, pinnedSessionIds, sessionAttentionStates]); React.useEffect(() => { let cancelled = false; @@ -881,9 +1081,9 @@ export const SessionSidebar: React.FC = ({ collection.push(session); map.set(parentID, collection); }); - map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))); + map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates))); return map; - }, [sortedSessions, pinnedSessionIds]); + }, [sortedSessions, pinnedSessionIds, sessionAttentionStates]); React.useEffect(() => { const directories = new Set(); @@ -1115,42 +1315,44 @@ export const SessionSidebar: React.FC = ({ const deleteSessions = useSessionStore((state) => state.deleteSessions); const handleDeleteSession = React.useCallback( - async (session: Session) => { + (session: Session) => { const descendants = collectDescendants(session.id); - - if (descendants.length === 0) { - - const success = await deleteSession(session.id); - if (success) { - toast.success('Session deleted', { - action: { - label: 'OK', - onClick: () => { }, - }, - }); - } else { - toast.error('Failed to delete session'); - } - } else { - - const ids = [session.id, ...descendants.map((s) => s.id)]; - const { deletedIds, failedIds } = await deleteSessions(ids); - if (deletedIds.length > 0) { - toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, { - action: { - label: 'OK', - onClick: () => { }, - }, - }); - } - if (failedIds.length > 0) { - toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); - } - } + setDeleteSessionConfirm({ session, descendantCount: descendants.length }); }, - [collectDescendants, deleteSession, deleteSessions], + [collectDescendants], ); + const confirmDeleteSession = React.useCallback(async () => { + if (!deleteSessionConfirm) return; + const { session } = deleteSessionConfirm; + setDeleteSessionConfirm(null); + const descendants = collectDescendants(session.id); + if (descendants.length === 0) { + const success = await deleteSession(session.id); + if (success) { + toast.success('Session deleted'); + } else { + toast.error('Failed to delete session'); + } + } else { + const ids = [session.id, ...descendants.map((s) => s.id)]; + const { deletedIds, failedIds } = await deleteSessions(ids); + if (deletedIds.length > 0) { + toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`); + } + if (failedIds.length > 0) { + toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); + } + } + }, [deleteSessionConfirm, collectDescendants, deleteSession, deleteSessions]); + + const confirmDeleteFolder = React.useCallback(() => { + if (!deleteFolderConfirm) return; + const { scopeKey, folderId } = deleteFolderConfirm; + setDeleteFolderConfirm(null); + deleteFolder(scopeKey, folderId); + }, [deleteFolderConfirm, deleteFolder]); + const handleOpenDirectoryDialog = React.useCallback(() => { if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) { sessionEvents.requestDirectoryDialog(); @@ -1216,7 +1418,7 @@ export const SessionSidebar: React.FC = ({ projectIsRepo: boolean, ) => { const normalizedProjectRoot = normalizePath(projectRoot ?? null); - const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); + const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)); const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session])); const childrenMap = new Map(); @@ -1229,7 +1431,7 @@ export const SessionSidebar: React.FC = ({ collection.push(session); childrenMap.set(parentID, collection); }); - childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))); + childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates))); // Build worktree lookup map const worktreeByPath = new Map(); @@ -1362,7 +1564,7 @@ export const SessionSidebar: React.FC = ({ return groups; }, - [homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories] + [homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories, sessionAttentionStates] ); const toggleGroupSessionLimit = React.useCallback((groupId: string) => { @@ -1477,8 +1679,12 @@ export const SessionSidebar: React.FC = ({ }; }, [normalizedProjects, projectGitBranchesKey]); - // Session Folders: cleanup stale session IDs when sessions are removed + // Session Folders: cleanup stale session IDs when sessions are removed. + // Guard: skip cleanup while sessions are still loading to avoid wiping folder + // assignments before the server has returned its full session list. + const isSessionsLoading = useSessionStore((state) => state.isLoading); React.useEffect(() => { + if (isSessionsLoading) return; const idsByScope = new Map>(); sessions.forEach((session) => { const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); @@ -1491,11 +1697,12 @@ export const SessionSidebar: React.FC = ({ idsByScope.set(directory, new Set([session.id])); }); - const allScopeKeys = new Set([...Object.keys(foldersMap), ...idsByScope.keys()]); + const currentFoldersMap = useSessionFoldersStore.getState().foldersMap; + const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]); allScopeKeys.forEach((scopeKey) => { cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set()); }); - }, [sessions, foldersMap, cleanupSessions]); + }, [sessions, isSessionsLoading, cleanupSessions]); // removed foldersMap from deps to prevent cascade re-renders const getSessionsForProject = React.useCallback( (project: { normalizedPath: string }) => { @@ -1861,19 +2068,10 @@ export const SessionSidebar: React.FC = ({ | { additions?: number | string | null; deletions?: number | string | null; + files?: number | null; diffs?: Array<{ additions?: number | string | null; deletions?: number | string | null }>; } | undefined; - const diffTotals = sessionSummary?.diffs?.reduce<{ additions: number; deletions: number }>( - (acc, diff) => ({ - additions: acc.additions + (toFiniteNumber(diff?.additions) ?? 0), - deletions: acc.deletions + (toFiniteNumber(diff?.deletions) ?? 0), - }), - { additions: 0, deletions: 0 }, - ); - const additions = toFiniteNumber(sessionSummary?.additions) ?? diffTotals?.additions; - const deletions = toFiniteNumber(sessionSummary?.deletions) ?? diffTotals?.deletions; - const hasSummary = typeof additions === 'number' || typeof deletions === 'number'; if (editingId === session.id) { return ( @@ -1935,15 +2133,13 @@ export const SessionSidebar: React.FC = ({ )} ) : null} - {formatDateLabel(session.time?.created || Date.now())} + {formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} {session.share ? ( ) : null} - {hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? ( - - +{Math.max(0, additions ?? 0)} - / - -{Math.max(0, deletions ?? 0)} + {(sessionSummary?.files ?? 0) > 0 ? ( + + · {sessionSummary!.files} {sessionSummary!.files === 1 ? 'file' : 'files'} changed ) : null} {hasChildren ? ( @@ -1973,6 +2169,7 @@ export const SessionSidebar: React.FC = ({ return ( +
= ({ )} ) : null} - {formatDateLabel(session.time?.created || Date.now())} + {formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} {session.share ? ( ) : null} - {hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? ( - - +{Math.max(0, additions ?? 0)} - / - -{Math.max(0, deletions ?? 0)} + {(sessionSummary?.files ?? 0) > 0 ? ( + + · {sessionSummary!.files} {sessionSummary!.files === 1 ? 'file' : 'files'} changed ) : null} {hasChildren ? ( @@ -2210,12 +2405,10 @@ export const SessionSidebar: React.FC = ({ )} { - const newFolder = createFolder(sessionDirectory, 'New folder'); - addSessionToFolder(sessionDirectory, newFolder.id, session.id); - setRenamingFolderId(newFolder.id); - setRenameFolderDraft('New folder'); - }} + onClick={() => { + const newFolder = createFolder(sessionDirectory, 'New folder'); + addSessionToFolder(sessionDirectory, newFolder.id, session.id); + }} > New folder... @@ -2249,6 +2442,7 @@ export const SessionSidebar: React.FC = ({
+ {hasChildren && isExpanded ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId), @@ -2288,6 +2482,7 @@ export const SessionSidebar: React.FC = ({ removeSessionFromFolder, createFolder, notifyOnSubtasks, + foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector) ], ); @@ -2303,15 +2498,18 @@ export const SessionSidebar: React.FC = ({ 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); + // ALL folders for this scope – including empty ones (so newly created folders show up) + // Build enriched list: { folder, nodes } for every folder in scope + const allFoldersForGroup = scopeFolders.map((folder) => { + const nodes = folder.sessionIds + .map((sid) => group.sessions.find((node) => node.session.id === sid)) + .filter((n): n is SessionNode => Boolean(n)) + .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds, sessionAttentionStates)); + return { folder, nodes }; + }); + + // Root-level folders (no parentId) — sub-folders are rendered inside their parent + const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId); const totalSessions = ungroupedSessions.length; const visibleSessions = isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible); @@ -2341,52 +2539,102 @@ export const SessionSidebar: React.FC = ({ && normalizedGroupDirectory === currentSessionDirectory, ); + // Helper: render a single folder item (root or sub) wrapped in DroppableFolderWrapper + const renderOneFolderItem = (folder: (typeof allFoldersForGroup)[number]['folder'], nodes: SessionNode[], depth: number) => { + // Find direct sub-folders of this folder + const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id); + const subFolderItems = directSubFolders.length > 0 ? ( + <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))} + ) : undefined; + + return ( + + {(droppableRef, isDropTarget) => ( + toggleFolderCollapse(folder.id)} + onRename={(name) => { + if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); + }} + onDelete={() => { + if (!folderScopeKey) return; + // Count affected sub-folders and sessions for the confirm dialog + const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; + const sessionCount = nodes.length; + setDeleteFolderConfirm({ + scopeKey: folderScopeKey, + folderId: folder.id, + folderName: folder.name, + subFolderCount, + sessionCount, + }); + }} + 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(''); + }} + droppableRef={droppableRef} + isDropTarget={isDropTarget} + depth={depth} + onNewSession={() => { + if (projectId && projectId !== activeProjectId) { + setActiveProject(projectId); + } + setActiveMainTab('chat'); + if (mobileVariant) { + setSessionSwitcherOpen(false); + } + openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id }); + }} + onNewSubFolder={depth === 0 ? () => { + if (!folderScopeKey) return; + createFolder(folderScopeKey, 'New folder', folder.id); + } : undefined} + /> + )} + + ); + }; + // 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) + // Shared folder rendering helper (used in both branches). + // Uses DroppableFolderWrapper so each folder header becomes a DnD drop zone. 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(''); - }} - /> - )); + rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0)); if (hideGroupLabel) { return (
+ 0} + onSessionDroppedOnFolder={(sessionId, folderId) => { + if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); + }} + > {renderFolderItems()} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 && foldersWithSessions.length === 0 ? ( + {totalSessions === 0 && scopeFolders.length === 0 ? (
No sessions in this workspace yet.
@@ -2409,6 +2657,7 @@ export const SessionSidebar: React.FC = ({ Show fewer sessions ) : null} +
); @@ -2501,12 +2750,27 @@ export const SessionSidebar: React.FC = ({ ) : null} - - - + + + +

New session or folder

+
+
+ + { if (projectId && projectId !== activeProjectId) { setActiveProject(projectId); } @@ -2516,46 +2780,60 @@ export const SessionSidebar: React.FC = ({ } openNewSessionDraft({ directoryOverride: group.directory }); }} - 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 ${group.label}`} > - - - - -

New session

-
- + + New session +
+ {folderScopeKey ? ( + { + createFolder(folderScopeKey, 'New folder'); + }} + > + + New folder + + ) : null} +
+
) : null}
{!isCollapsed ? (
- {renderFolderItems()} - {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 && foldersWithSessions.length === 0 ? ( -
- No sessions in this workspace yet. -
- ) : null} - {remainingCount > 0 && !isExpanded ? ( - - ) : null} - {isExpanded && totalSessions > maxVisible ? ( - - ) : null} + 0} + onSessionDroppedOnFolder={(sessionId, folderId) => { + if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); + }} + > + {renderFolderItems()} + {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} + {totalSessions === 0 && scopeFolders.length === 0 ? ( +
+ No sessions in this workspace yet. +
+ ) : null} + {remainingCount > 0 && !isExpanded ? ( + + ) : null} + {isExpanded && totalSessions > maxVisible ? ( + + ) : null} +
) : null} @@ -2578,10 +2856,15 @@ export const SessionSidebar: React.FC = ({ getFoldersForScope, collapsedFolderIds, toggleFolderCollapse, + createFolder, renameFolder, deleteFolder, + addSessionToFolder, renamingFolderId, renameFolderDraft, + pinnedSessionIds, + sessionAttentionStates, + foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector) ] ); @@ -3101,6 +3384,66 @@ export const SessionSidebar: React.FC = ({ /> ) : null} + + {/* Confirm delete session dialog */} + { if (!open) setDeleteSessionConfirm(null); }}> + + + Delete session? + + {deleteSessionConfirm && deleteSessionConfirm.descendantCount > 0 + ? `"${deleteSessionConfirm.session.title || 'Untitled Session'}" and its ${deleteSessionConfirm.descendantCount} sub-task${deleteSessionConfirm.descendantCount === 1 ? '' : 's'} will be permanently deleted.` + : `"${deleteSessionConfirm?.session.title || 'Untitled Session'}" will be permanently deleted.`} + + + + + + + + + + {/* Confirm delete folder dialog */} + { if (!open) setDeleteFolderConfirm(null); }}> + + + Delete folder? + + {deleteFolderConfirm && (deleteFolderConfirm.subFolderCount > 0 || deleteFolderConfirm.sessionCount > 0) + ? `"${deleteFolderConfirm.folderName}" will be deleted${deleteFolderConfirm.subFolderCount > 0 ? ` along with ${deleteFolderConfirm.subFolderCount} sub-folder${deleteFolderConfirm.subFolderCount === 1 ? '' : 's'}` : ''}. Sessions inside will not be deleted.` + : `"${deleteFolderConfirm?.folderName}" will be permanently deleted.`} + + + + + + + + ); }; diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 90727118..315e5100 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -119,6 +119,7 @@ export type NewSessionDraftState = { initialPrompt?: string; /** Synthetic context parts to include with the initial message */ syntheticParts?: SyntheticContextPart[]; + targetFolderId?: string; }; // Voice state types @@ -209,7 +210,7 @@ export interface SessionStore { setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void; loadSessions: () => Promise; - openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[] }) => void; + openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void; closeNewSessionDraft: () => void; createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index bd883962..6728f0b8 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -9,6 +9,8 @@ export interface SessionFolder { name: string; sessionIds: string[]; createdAt: number; + /** If set, this folder is a sub-folder of the parent folder with this id */ + parentId?: string | null; } type SessionFoldersMap = Record; @@ -20,7 +22,7 @@ interface SessionFoldersState { interface SessionFoldersActions { getFoldersForScope: (scopeKey: string) => SessionFolder[]; - createFolder: (scopeKey: string, name: string) => SessionFolder; + createFolder: (scopeKey: string, name: string, parentId?: string | null) => SessionFolder; renameFolder: (scopeKey: string, folderId: string, name: string) => void; deleteFolder: (scopeKey: string, folderId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; @@ -65,7 +67,8 @@ const readPersistedFolders = (): SessionFoldersMap => { 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 }); + const parentId = typeof candidate.parentId === 'string' ? candidate.parentId : null; + folders.push({ id, name, sessionIds, createdAt, parentId }); } if (folders.length > 0) { result[scopeKey] = folders; @@ -149,13 +152,14 @@ export const useSessionFoldersStore = create()( return get().foldersMap[scopeKey] ?? []; }, - createFolder: (scopeKey: string, name: string): SessionFolder => { + createFolder: (scopeKey: string, name: string, parentId?: string | null): SessionFolder => { const trimmed = name.trim() || 'New folder'; const folder: SessionFolder = { id: createFolderId(), name: trimmed, sessionIds: [], createdAt: Date.now(), + parentId: parentId ?? null, }; const current = get().foldersMap; const scopeFolders = current[scopeKey] ?? []; @@ -187,16 +191,29 @@ export const useSessionFoldersStore = create()( const current = get().foldersMap; const scopeFolders = current[scopeKey]; if (!scopeFolders) return; - const nextFolders = scopeFolders.filter((folder) => folder.id !== folderId); + // Also delete all sub-folders of this folder + const idsToDelete = new Set([folderId]); + let changed = true; + while (changed) { + changed = false; + for (const f of scopeFolders) { + if (f.parentId && idsToDelete.has(f.parentId) && !idsToDelete.has(f.id)) { + idsToDelete.add(f.id); + changed = true; + } + } + } + const nextFolders = scopeFolders.filter((folder) => !idsToDelete.has(folder.id)); const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; set({ foldersMap: nextMap }); persistFolders(nextMap); - // Clean up collapsed state + // Clean up collapsed state for all deleted folders const collapsed = get().collapsedFolderIds; - if (collapsed.has(folderId)) { + const hasStale = Array.from(idsToDelete).some((id) => collapsed.has(id)); + if (hasStale) { const nextCollapsed = new Set(collapsed); - nextCollapsed.delete(folderId); + idsToDelete.forEach((id) => nextCollapsed.delete(id)); set({ collapsedFolderIds: nextCollapsed }); persistCollapsed(nextCollapsed); } @@ -220,9 +237,8 @@ export const useSessionFoldersStore = create()( return folder; }); - const filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0); - const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders }; - const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, get().collapsedFolderIds); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds); set(nextCollapsed ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } @@ -250,9 +266,8 @@ export const useSessionFoldersStore = create()( }); 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); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds); set(nextCollapsed ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } @@ -292,9 +307,8 @@ export const useSessionFoldersStore = create()( }); 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); + const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; + const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds); set(nextCollapsed ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 781e9d07..ca30555b 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -17,6 +17,7 @@ import { opencodeClient } from "@/lib/opencode/client"; import { useDirectoryStore } from "./useDirectoryStore"; import { useConfigStore } from "./useConfigStore"; import { useProjectsStore } from "./useProjectsStore"; +import { useSessionFoldersStore } from "./useSessionFoldersStore"; import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"; import { flattenAssistantTextParts } from "@/lib/messages/messageText"; @@ -150,6 +151,7 @@ export const useSessionStore = create()( title: options?.title, initialPrompt: options?.initialPrompt, syntheticParts: options?.syntheticParts, + targetFolderId: options?.targetFolderId, }, currentSessionId: null, error: null, @@ -186,18 +188,24 @@ export const useSessionStore = create()( closeNewSessionDraft: () => { const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId; set({ - newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined }, + newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined }, currentSessionId: realCurrentSessionId, }); }, createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => { + const draft = get().newSessionDraft; + const targetFolderId = draft.targetFolderId; get().closeNewSessionDraft(); const result = await useSessionManagementStore.getState().createSession(title, directoryOverride, parentID); if (result?.id) { await get().setCurrentSession(result.id); + const finalScopeKey = directoryOverride || get().lastLoadedDirectory || result.directory; + if (targetFolderId && finalScopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(finalScopeKey, targetFolderId, result.id); + } } return result; }, @@ -348,9 +356,12 @@ export const useSessionStore = create()( }; if (draft?.open) { + const draftTargetFolderId = draft.targetFolderId; + const draftDirectoryOverride = draft.directoryOverride ?? null; + const created = await useSessionManagementStore .getState() - .createSession(draft.title, draft.directoryOverride ?? null, draft.parentID ?? null); + .createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null); if (!created?.id) { throw new Error('Failed to create session'); @@ -410,6 +421,15 @@ export const useSessionStore = create()( const draftSyntheticParts = draft.syntheticParts; get().closeNewSessionDraft(); + + // Assign to target folder if session was created from folder's + button + if (draftTargetFolderId) { + const scopeKey = draftDirectoryOverride || created.directory || null; + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id); + } + } + setStatus(created.id, 'busy'); // Merge draft synthetic parts with any additional parts passed to sendMessage