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