From 9b82e12f71fbbc9178348bb7657fad6f8045dfed Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 8 Aug 2026 17:36:00 -0700 Subject: [PATCH] feat(desktop): nestable sidebar groups with session-scope trees and cycle-safe re-parenting Reintroduces the grouping tree extracted from #6274 (exact revert of the extraction commit, preserving all review-round fixes): sections gain parentSectionId + nullable projectId, depth-capped folders-first nesting in project and Sessions scopes, context-menu re-parenting, recursive hotkey reveal and bulk-select coverage. --- .../DashboardSidebar/DashboardSidebar.tsx | 18 +- .../DashboardSidebarNestedSection.tsx | 171 +++++++++++++++ .../DashboardSidebarNestedSection/index.ts | 1 + ...DashboardSidebarExpandedProjectContent.tsx | 116 ++++++++--- .../DashboardSidebarSectionContextMenu.tsx | 2 + ...DashboardSidebarSectionActionsDropdown.tsx | 2 + .../SectionActionsMenuItems.tsx | 86 +++++++- .../types.ts | 1 + .../DashboardSidebarSessionsSection.tsx | 60 ++++-- .../DashboardSidebarWorkspaceContextMenu.tsx | 39 ++-- ...useDashboardSidebarWorkspaceItemActions.ts | 2 - .../SortableSectionHeader.tsx | 2 + .../buildDashboardSidebarProjects.test.ts | 88 +++++++- .../buildDashboardSidebarProjects.ts | 184 ++++++++++++++--- .../useDashboardSidebarData.ts | 29 ++- .../useDashboardSidebarShortcuts.ts | 91 +++++--- .../hooks/useSidebarDnd/useSidebarDnd.ts | 12 +- .../components/DashboardSidebar/types.ts | 8 +- .../getFlattenedV2WorkspaceIds.ts | 80 ++++++-- .../utils/projectChildren/projectChildren.ts | 14 +- .../hooks/useDashboardSidebarState/index.ts | 6 +- .../useDashboardSidebarState.ts | 194 +++++++++++++++++- .../dashboardSidebarLocal/schema.ts | 9 +- plans/20260808-optional-project-sessions.md | 20 +- 24 files changed, 1075 insertions(+), 160 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarNestedSection/DashboardSidebarNestedSection.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarNestedSection/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index d6ba1dcce54..710f9b70074 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -48,7 +48,7 @@ import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcu import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider"; import { DashboardSidebarPortsProvider } from "./providers/DashboardSidebarPortsProvider"; import { DashboardSidebarSelectionProvider } from "./providers/DashboardSidebarSelectionProvider"; -import type { DashboardSidebarProject } from "./types"; +import type { DashboardSidebarProject, DashboardSidebarSection } from "./types"; import { getProjectChildrenWorkspaces } from "./utils/projectChildren"; interface DashboardSidebarProps { @@ -110,7 +110,7 @@ export function DashboardSidebar({ const { groups, pinnedWorkspaces, - sessionWorkspaces, + sessionsScope, refreshWorkspacePullRequest, toggleProjectCollapsed, } = useDashboardSidebarData(); @@ -157,7 +157,7 @@ export function DashboardSidebar({ const workspaceShortcutLabels = useDashboardSidebarShortcuts( orderedGroups, - sessionWorkspaces, + sessionsScope, ); const selectableWorkspaceIds = useMemo( () => @@ -185,6 +185,10 @@ export function DashboardSidebar({ if (pinned) { return groups.find((project) => project.id === pinned.projectId) ?? null; } + // Recursive: nested groups hold workspaces too. + const sectionContains = (section: DashboardSidebarSection): boolean => + section.workspaces.some((ws) => ws.id === activeV2WorkspaceId) || + section.childSections.some(sectionContains); for (const project of groups) { for (const child of project.children) { if ( @@ -193,10 +197,8 @@ export function DashboardSidebar({ ) { return project; } - if (child.type === "section") { - for (const ws of child.section.workspaces) { - if (ws.id === activeV2WorkspaceId) return project; - } + if (child.type === "section" && sectionContains(child.section)) { + return project; } } } @@ -242,7 +244,7 @@ export function DashboardSidebar({ /> )} boolean; + /** Returns true when the event was consumed as a selection gesture. */ + onWorkspaceSelectionClick: ( + event: Parameters< + NonNullable< + React.ComponentProps< + typeof DashboardSidebarWorkspaceItem + >["onSelectionClick"] + > + >[0], + workspaceId: string, + ) => boolean; +} + +interface DashboardSidebarNestedSectionProps { + section: DashboardSidebarSection; + /** 0 = a root group in the Sessions area; ≥1 = nested inside a group. */ + depth: number; + workspaceShortcutLabels?: Map; + /** Bulk-selection wiring (project scope only). */ + selection?: NestedSectionSelection; + onWorkspaceHover: (workspaceId: string) => void | Promise; +} + +/** + * Recursive group renderer for the parts of the tree the flat DnD lane does + * not manage: groups nested inside a group, and the Sessions area's root + * groups. Children render folders-first (nested groups above the group's own + * workspaces); moves in and out happen through the context menus. + */ +export function DashboardSidebarNestedSection({ + section, + depth, + workspaceShortcutLabels, + selection, + onWorkspaceHover, +}: DashboardSidebarNestedSectionProps) { + const { + deleteSection, + renameSection, + setSectionColor, + toggleSectionCollapsed, + } = useDashboardSidebarState(); + const { clearPendingSectionRename, pendingRenameSectionId } = + useDashboardSidebarSectionRename(); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(section.name); + + const startRename = useCallback(() => { + setRenameValue(section.name); + setIsRenaming(true); + }, [section.name]); + + useEffect(() => { + if (pendingRenameSectionId !== section.id) return; + startRename(); + clearPendingSectionRename(section.id); + }, [ + clearPendingSectionRename, + pendingRenameSectionId, + section.id, + startRename, + ]); + + const handleSubmitRename = () => { + const trimmed = renameValue.trim(); + if (trimmed) renameSection(section.id, trimmed); + setIsRenaming(false); + }; + + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + return ( +
0 ? 10 : 0 }}> +
+ setSectionColor(section.id, color)} + onDelete={() => deleteSection(section.id)} + > + { + setRenameValue(section.name); + setIsRenaming(false); + }} + onToggleCollapse={() => toggleSectionCollapsed(section.id)} + actions={ + setSectionColor(section.id, color)} + onDelete={() => deleteSection(section.id)} + /> + } + /> + +
+ {!section.isCollapsed && ( + <> + {section.childSections.map((child) => ( + + ))} +
0 ? 10 : 0 }}> + {section.workspaces.map((workspace) => { + const canBulkSelect = + selection !== undefined && + workspace.type === "worktree" && + workspace.pendingTransaction?.type !== "insert"; + return ( + + selection.onWorkspaceSelectionClick( + event, + workspace.id, + ) + : undefined + } + onHoverCardOpen={() => onWorkspaceHover(workspace.id)} + /> + ); + })} +
+ + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarNestedSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarNestedSection/index.ts new file mode 100644 index 00000000000..087e6f57e68 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarNestedSection/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarNestedSection } from "./DashboardSidebarNestedSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 20c16db374d..7a1db242689 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -9,7 +9,11 @@ import { createPortal } from "react-dom"; import { useSidebarDnd } from "../../../../hooks/useSidebarDnd"; import { parseId } from "../../../../hooks/useSidebarDnd/useSidebarDnd"; import { useDashboardSidebarSelection } from "../../../../providers/DashboardSidebarSelectionProvider"; -import type { DashboardSidebarProjectChild } from "../../../../types"; +import type { + DashboardSidebarProjectChild, + DashboardSidebarSection, +} from "../../../../types"; +import { DashboardSidebarNestedSection } from "../../../DashboardSidebarNestedSection"; import { WorkspaceBulkMenuScope } from "../../../DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope"; import { SidebarDragOverlay } from "../../../SidebarDragOverlay"; import { SortableSectionHeader } from "../../../SortableSectionHeader"; @@ -55,25 +59,48 @@ export function DashboardSidebarExpandedProjectContent({ const { isWorkspaceSelected, selectWorkspaceFromEvent } = useDashboardSidebarSelection(); - const selectableWorkspaceIds = useMemo( - () => - flatItems.flatMap((id) => { - const parsed = parseId(id); - if (!parsed || parsed.type !== "workspace") return []; - const workspace = workspacesById.get(parsed.realId); - if ( - !workspace || - workspace.type !== "worktree" || - workspace.pendingTransaction?.type === "insert" - ) { - return []; - } - const group = groupInfo.get(parsed.realId); - if (group && collapsedSectionIds.has(group.sectionId)) return []; - return [parsed.realId]; - }), - [flatItems, workspacesById, groupInfo, collapsedSectionIds], - ); + // Range-select order matches the visual order: a section's nested + // subtree renders directly below its header (folders-first), before the + // section's direct workspaces from the flat lane. + const selectableWorkspaceIds = useMemo(() => { + const isSelectable = (workspaceId: string) => { + const workspace = workspacesById.get(workspaceId); + return ( + !!workspace && + workspace.type === "worktree" && + workspace.pendingTransaction?.type !== "insert" + ); + }; + const collectVisibleSubtree = ( + section: DashboardSidebarSection, + out: string[], + ) => { + for (const child of section.childSections) { + if (child.isCollapsed) continue; + collectVisibleSubtree(child, out); + out.push( + ...child.workspaces + .map((workspace) => workspace.id) + .filter(isSelectable), + ); + } + }; + return flatItems.flatMap((id) => { + const parsed = parseId(id); + if (!parsed) return []; + if (parsed.type === "section") { + const section = sectionsById.get(parsed.realId); + if (!section || collapsedSectionIds.has(section.id)) return []; + const nested: string[] = []; + collectVisibleSubtree(section, nested); + return nested; + } + if (!isSelectable(parsed.realId)) return []; + const group = groupInfo.get(parsed.realId); + if (group && collapsedSectionIds.has(group.sectionId)) return []; + return [parsed.realId]; + }); + }, [flatItems, workspacesById, sectionsById, groupInfo, collapsedSectionIds]); return ( @@ -108,15 +135,48 @@ export function DashboardSidebarExpandedProjectContent({ if (parsed.type === "section") { const section = sectionsById.get(parsed.realId); if (!section) return null; + // Nested groups render as a folders-first block + // under the header; the flat DnD lane manages only + // the header and the group's direct workspaces. + const showNested = + !section.isCollapsed && + section.childSections.length > 0 && + activeType !== "section"; return ( - +
+ + {showNested && + section.childSections.map((child) => ( + + selectWorkspaceFromEvent(event, { + workspaceId, + projectId, + orderedWorkspaceIds: + selectableWorkspaceIds, + }), + }} + onWorkspaceHover={onWorkspaceHover} + /> + ))} +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx index ebff7132ef6..7b5eb44b46f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx @@ -12,6 +12,7 @@ interface DashboardSidebarSectionContextMenuProps } export function DashboardSidebarSectionContextMenu({ + sectionId, color, onRename, onSetColor, @@ -27,6 +28,7 @@ export function DashboardSidebarSectionContextMenu({ onPointerDown={(event) => event.stopPropagation()} > event.stopPropagation()} > { + const sections = Array.from(collections.v2SidebarSections.state.values()); + const self = sections.find((row) => row.sectionId === sectionId); + if (!self) return { moveTargets: [], isNested: false }; + return { + isNested: self.parentSectionId !== null, + moveTargets: sections + .filter( + (row) => + row.sectionId !== sectionId && + canMoveSectionToParent(collections, sectionId, row.sectionId), + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((row) => ({ + id: row.sectionId, + name: row.name, + color: row.color, + })), + }; + }, [collections, sectionId]); + const selectedValue = color ?? PROJECT_COLOR_DEFAULT; const colorOptions = [ { name: "Default", value: PROJECT_COLOR_DEFAULT }, @@ -144,6 +184,50 @@ export function SectionActionsMenuItems({ )} + {moveTargets.length > 0 && + (kind === "context" ? ( + + + + Move to group + + + {moveTargets.map((target) => + renderItem({ + key: target.id, + onSelect: () => moveSectionToParent(sectionId, target.id), + children: {target.name}, + }), + )} + + + ) : ( + + + + Move to group + + + {moveTargets.map((target) => + renderItem({ + key: target.id, + onSelect: () => moveSectionToParent(sectionId, target.id), + children: {target.name}, + }), + )} + + + ))} + {isNested && + renderItem({ + onSelect: () => moveSectionToParent(sectionId, null), + children: ( + <> + + Move to top level + + ), + })} {kind === "context" ? ( ) : ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts index ced8619368d..2ed1929860e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts @@ -1,4 +1,5 @@ export interface DashboardSidebarSectionActionsProps { + sectionId: string; color: string | null; onRename: () => void; onSetColor: (color: string | null) => void; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx index 04a0bdad3f7..00711eb6078 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx @@ -1,42 +1,69 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { LuPlus } from "react-icons/lu"; import { useOpenNewSessionModal } from "renderer/stores/new-workspace-modal"; -import type { DashboardSidebarWorkspace } from "../../types"; +import type { DashboardSidebarSessionsScope } from "../../hooks/useDashboardSidebarData/buildDashboardSidebarProjects"; +import { DashboardSidebarNestedSection } from "../DashboardSidebarNestedSection"; import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; interface DashboardSidebarSessionsSectionProps { - sessionWorkspaces: DashboardSidebarWorkspace[]; + sessionsScope: DashboardSidebarSessionsScope; isCollapsed?: boolean; /** The workspaces-list collapse toggle hides rows; the header stays. */ rowsHidden?: boolean; onWorkspaceHover: (workspaceId: string) => void | Promise; } +function collectScopeWorkspaces(scope: DashboardSidebarSessionsScope): Array<{ + workspace: DashboardSidebarSessionsScope["looseWorkspaces"][number]; + isInSection: boolean; +}> { + const rows = scope.looseWorkspaces.map((workspace) => ({ + workspace, + isInSection: false, + })); + const walk = (sections: DashboardSidebarSessionsScope["rootSections"]) => { + for (const section of sections) { + rows.push( + ...section.workspaces.map((workspace) => ({ + workspace, + isInSection: true, + })), + ); + walk(section.childSections); + } + }; + walk(scope.rootSections); + return rows; +} + /** - * Top-level "Sessions" section, rendered above the Projects header. The - * header (and its "+", which opens the create surface with "No project" - * preselected) always renders in expanded mode — like the Projects header — - * so sessions stay discoverable at zero. Collapsed rail renders a plain icon - * stack with a trailing divider, matching the Pinned section. + * Top-level "Sessions" section, rendered above the Projects header: loose + * sessions first, then the null-scope group tree. The header (and its "+", + * which opens the create surface with "No project" preselected) always + * renders in expanded mode — like the Projects header — so sessions stay + * discoverable at zero. Collapsed rail renders a plain icon stack with a + * trailing divider, matching the Pinned section. */ export function DashboardSidebarSessionsSection({ - sessionWorkspaces, + sessionsScope, isCollapsed = false, rowsHidden = false, onWorkspaceHover, }: DashboardSidebarSessionsSectionProps) { const openNewSessionModal = useOpenNewSessionModal(); + const { looseWorkspaces, rootSections } = sessionsScope; if (isCollapsed) { - if (sessionWorkspaces.length === 0) return null; + const rows = collectScopeWorkspaces(sessionsScope); + if (rows.length === 0) return null; return (
- {sessionWorkspaces.map((workspace) => ( + {rows.map(({ workspace, isInSection }) => ( onWorkspaceHover(workspace.id)} /> ))} @@ -66,13 +93,22 @@ export function DashboardSidebarSessionsSection({
{!rowsHidden && - sessionWorkspaces.map((workspace) => ( + looseWorkspaces.map((workspace) => ( onWorkspaceHover(workspace.id)} /> ))} + {!rowsHidden && + rootSections.map((section) => ( + + ))} ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 702d232928f..ae7cebd08c0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -11,6 +11,7 @@ import { } from "@superset/ui/context-menu"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; import { LuArrowRightLeft, LuArrowUp, @@ -96,25 +97,37 @@ export function DashboardSidebarWorkspaceContextMenu({ const deleteHotkeyText = useHotkeyDisplay("CLOSE_WORKSPACE").text; const showDeleteShortcut = showDeleteHotkey && deleteHotkeyText !== "Unassigned"; - const { data: sections = [] } = useLiveQuery( - (q) => - q - .from({ sidebarSections: collections.v2SidebarSections }) - // `?? ""` and not null: TanStack DB's eq(col, null) never - // matches, and no section can have an empty-string projectId, - // so sessions resolve to an empty list without relying on the - // eq(null) quirk. - .where(({ sidebarSections }) => - eq(sidebarSections.projectId, projectId ?? ""), - ) + // Project rows keep the index-backed eq() scope. The sessions scope + // (projectId null) must scan + filter in JS: TanStack DB's eq(col, null) + // never matches. This menu mounts per workspace row, so the common + // project case shouldn't pay for the full-collection scan. + const { data: scopedSections = [] } = useLiveQuery( + (q) => { + const base = q.from({ sidebarSections: collections.v2SidebarSections }); + return ( + projectId === null + ? base + : base.where(({ sidebarSections }) => + eq(sidebarSections.projectId, projectId), + ) + ) .orderBy(({ sidebarSections }) => sidebarSections.tabOrder, "asc") .select(({ sidebarSections }) => ({ id: sidebarSections.sectionId, + projectId: sidebarSections.projectId, name: sidebarSections.name, color: sidebarSections.color, - })), + })); + }, [collections, projectId], ); + const sections = useMemo( + () => + projectId === null + ? scopedSections.filter((section) => section.projectId === null) + : scopedSections, + [scopedSections, projectId], + ); const handleCloseAllPorts = () => { if (isKillingPorts) return; void killPorts(ports); @@ -190,7 +203,7 @@ export function DashboardSidebarWorkspaceContextMenu({ )} {/* Group actions mutate placement (sectionId/tabOrder), which a pinned row doesn't display — the change would only surface on unpin. */} - {!isPinned && !isLocalMainWorkspace && projectId !== null && ( + {!isPinned && !isLocalMainWorkspace && ( <> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index a2061123b10..4810103a683 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -113,8 +113,6 @@ export function useDashboardSidebarWorkspaceItemActions({ }; const handleCreateSection = () => { - // Sessions get groups in the stacked nesting PR. - if (projectId === null) return; const sectionId = createSection(projectId); moveWorkspaceToSection(workspaceId, projectId, sectionId); requestSectionRename(sectionId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx index e090834eb9e..e7785782126 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx @@ -78,6 +78,7 @@ export function SortableSectionHeader({ }} > setSectionColor(section.id, color)} @@ -96,6 +97,7 @@ export function SortableSectionHeader({ onToggleCollapse={() => onToggleCollapse(section.id)} actions={ setSectionColor(section.id, color)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.test.ts index b3afa91755b..495bd0ea94a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "bun:test"; import { buildDashboardSidebarPinnedWorkspaces, buildDashboardSidebarProjects, - buildDashboardSidebarSessionWorkspaces, + buildDashboardSidebarSessionsScope, + buildSectionTree, partitionSidebarWorkspacesByPinned, type SidebarProjectInput, type SidebarSectionInput, @@ -35,6 +36,7 @@ function makeSection( return { id: "section-1", projectId: "project-1", + parentSectionId: null, name: "Section", createdAt: DATE, isCollapsed: false, @@ -233,7 +235,7 @@ describe("sessions (null projectId)", () => { }); it("orders the Sessions section by tabOrder with no repo affordances", () => { - const rows = buildDashboardSidebarSessionWorkspaces({ + const scope = buildDashboardSidebarSessionsScope({ sessionSidebarWorkspaces: [ makeWorkspace({ id: "session-b", @@ -248,16 +250,98 @@ describe("sessions (null projectId)", () => { tabOrder: 1, }), ], + sessionSections: [], machineId: MACHINE_ID, pullRequestsByWorkspaceId: new Map(), }); + const rows = scope.looseWorkspaces; expect(rows.map((row) => row.id)).toEqual(["session-a", "session-b"]); expect(rows[0].projectId).toBeNull(); expect(rows[0].repoUrl).toBeNull(); expect(rows[0].branchExistsOnRemote).toBe(false); }); + it("nests session groups and splices dangling group refs to the loose lane", () => { + const scope = buildDashboardSidebarSessionsScope({ + sessionSidebarWorkspaces: [ + makeWorkspace({ + id: "grouped", + projectId: null, + type: "session", + sectionId: "group-child", + }), + makeWorkspace({ + id: "dangling", + projectId: null, + type: "session", + sectionId: "group-deleted", + }), + ], + sessionSections: [ + makeSection({ id: "group-root", projectId: null, tabOrder: 1 }), + makeSection({ + id: "group-child", + projectId: null, + parentSectionId: "group-root", + tabOrder: 1, + }), + ], + machineId: MACHINE_ID, + pullRequestsByWorkspaceId: new Map(), + }); + + expect(scope.looseWorkspaces.map((row) => row.id)).toEqual(["dangling"]); + expect(scope.rootSections.map((section) => section.id)).toEqual([ + "group-root", + ]); + const child = scope.rootSections[0].childSections[0]; + expect(child.id).toBe("group-child"); + expect(child.workspaces.map((row) => row.id)).toEqual(["grouped"]); + }); +}); + +describe("buildSectionTree", () => { + it("splices cyclic and dangling parents to the root", () => { + const { rootSections, nodesById } = buildSectionTree([ + makeSection({ id: "a", parentSectionId: "b", tabOrder: 1 }), + makeSection({ id: "b", parentSectionId: "a", tabOrder: 2 }), + makeSection({ id: "c", parentSectionId: "missing", tabOrder: 3 }), + makeSection({ id: "d", parentSectionId: "c", tabOrder: 4 }), + ]); + + // The a↔b cycle and c's dangling parent all splice to root; d keeps + // its valid parent c. + expect(rootSections.map((section) => section.id).sort()).toEqual([ + "a", + "b", + "c", + ]); + expect(nodesById.get("c")?.childSections.map((s) => s.id)).toEqual(["d"]); + }); + + it("splices nesting beyond the depth cap to the root", () => { + const chain = ["g1", "g2", "g3", "g4", "g5", "g6"].map((id, index, ids) => + makeSection({ + id, + parentSectionId: index === 0 ? null : ids[index - 1], + tabOrder: index, + }), + ); + const { rootSections } = buildSectionTree(chain); + // Levels past MAX_GROUP_DEPTH re-root instead of disappearing. + expect(rootSections.length).toBeGreaterThan(1); + const ids = new Set(); + const walk = (sections: typeof rootSections) => { + for (const section of sections) { + ids.add(section.id); + walk(section.childSections); + } + }; + walk(rootSections); + expect(ids.size).toBe(6); + }); + it("keeps a pinned session in the Pinned section with null project identity", () => { const rows = buildDashboardSidebarPinnedWorkspaces({ pinnedSidebarWorkspaces: [ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts index adcb5a164b8..665645ce179 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts @@ -26,7 +26,10 @@ export interface SidebarProjectInput { export interface SidebarSectionInput { id: string; - projectId: string; + /** Null scopes the group to the top-level Sessions area. */ + projectId: string | null; + /** Non-null when this group nests inside another group. */ + parentSectionId: string | null; name: string; createdAt: Date; isCollapsed: boolean; @@ -34,6 +37,95 @@ export interface SidebarSectionInput { color: string | null; } +/** Corrupt trees never brick the sidebar: deeper nesting splices to root. */ +const MAX_GROUP_DEPTH = 4; + +/** + * Assembles one scope's groups into a tree of DashboardSidebarSection nodes. + * Nested groups attach to their parent's `childSections` (sorted by tabOrder); + * a group whose parent is missing, cyclic, or beyond MAX_GROUP_DEPTH is + * spliced to the scope root instead of being dropped. + * + * Returns every node keyed by id plus the root-level nodes in input order. + */ +export function buildSectionTree(sections: SidebarSectionInput[]): { + nodesById: Map; + rootSections: DashboardSidebarSection[]; +} { + const inputsById = new Map(sections.map((section) => [section.id, section])); + + // A node keeps its declared parent when that parent exists and the chain + // doesn't loop back to the node itself. A dangling or cyclic ancestor + // deeper up re-roots THAT ancestor, which already unblocks this node's + // chain — so only self-cycles and missing direct parents splice here. + const resolveParent = (section: SidebarSectionInput): string | null => { + if (section.parentSectionId === null) return null; + if (!inputsById.has(section.parentSectionId)) return null; + const visited = new Set([section.id]); + let current: string | null = section.parentSectionId; + while (current !== null) { + if (current === section.id) return null; // self-cycle → splice + if (visited.has(current)) break; // upstream loop — not ours to fix + visited.add(current); + current = inputsById.get(current)?.parentSectionId ?? null; + if (current === section.id) return null; + } + return section.parentSectionId; + }; + + const nodesById = new Map( + sections.map((section) => [ + section.id, + { + ...section, + workspaces: [], + childSections: [], + }, + ]), + ); + + const rootSections: DashboardSidebarSection[] = []; + for (const section of sections) { + const node = nodesById.get(section.id); + if (!node) continue; + const parentId = resolveParent(section); + node.parentSectionId = parentId; + if (parentId === null) { + rootSections.push(node); + } else { + nodesById.get(parentId)?.childSections.push(node); + } + } + + // Depth cap: anything nested deeper than MAX_GROUP_DEPTH re-roots (kept + // visible, never dropped). Applied after attachment so re-rooted + // ancestors don't double-count depth. + const enforceDepth = (node: DashboardSidebarSection, depth: number): void => { + const keep: DashboardSidebarSection[] = []; + for (const child of node.childSections) { + if (depth + 1 >= MAX_GROUP_DEPTH) { + child.parentSectionId = null; + rootSections.push(child); + // The re-rooted subtree may itself exceed the cap. + enforceDepth(child, 0); + } else { + keep.push(child); + enforceDepth(child, depth + 1); + } + } + node.childSections = keep; + }; + for (const root of [...rootSections]) { + enforceDepth(root, 0); + } + + for (const node of nodesById.values()) { + node.childSections.sort((left, right) => left.tabOrder - right.tabOrder); + } + rootSections.sort((left, right) => left.tabOrder - right.tabOrder); + return { nodesById, rootSections }; +} + export interface SidebarWorkspaceInput { id: string; /** Null for project-less "session" workspaces. */ @@ -163,31 +255,53 @@ export function buildDashboardSidebarPinnedWorkspaces({ ); } +export interface DashboardSidebarSessionsScope { + /** Sessions outside any group, ordered by tabOrder. */ + looseWorkspaces: DashboardSidebarWorkspace[]; + /** Root-level session groups (tree), ordered by tabOrder. */ + rootSections: DashboardSidebarSection[]; +} + /** - * Decorates the Sessions section rows (project-less workspaces), ordered by - * tabOrder ascending. Sessions have no repo identity, so every project-derived - * affordance (repoUrl, remote-branch, PRs) is null/off. + * Builds the top-level Sessions area: project-less workspaces plus the + * null-scope group tree. Sessions have no repo identity, so every + * project-derived affordance (repoUrl, remote-branch, PRs) is null/off. + * A session pointing at a dangling group splices to the loose lane. */ -export function buildDashboardSidebarSessionWorkspaces({ +export function buildDashboardSidebarSessionsScope({ sessionSidebarWorkspaces, + sessionSections, machineId, pullRequestsByWorkspaceId, }: { sessionSidebarWorkspaces: SidebarWorkspaceInput[]; + sessionSections: SidebarSectionInput[]; machineId: string; pullRequestsByWorkspaceId: Map; -}): DashboardSidebarWorkspace[] { - return sessionSidebarWorkspaces +}): DashboardSidebarSessionsScope { + const { nodesById, rootSections } = buildSectionTree(sessionSections); + + const looseWorkspaces: DashboardSidebarWorkspace[] = []; + for (const workspace of sessionSidebarWorkspaces .slice() - .sort((left, right) => left.tabOrder - right.tabOrder) - .map((workspace) => - decorateSidebarWorkspace( - workspace, - { githubOwner: null, githubRepoName: null }, - machineId, - pullRequestsByWorkspaceId, - ), + .sort((left, right) => left.tabOrder - right.tabOrder)) { + const decorated = decorateSidebarWorkspace( + workspace, + { githubOwner: null, githubRepoName: null }, + machineId, + pullRequestsByWorkspaceId, ); + const section = workspace.sectionId + ? nodesById.get(workspace.sectionId) + : undefined; + if (section) { + section.workspaces.push({ ...decorated, accentColor: section.color }); + } else { + looseWorkspaces.push(decorated); + } + } + rootSections.sort((left, right) => left.tabOrder - right.tabOrder); + return { looseWorkspaces, rootSections }; } export interface BuildDashboardSidebarProjectsParams { @@ -230,23 +344,33 @@ export function buildDashboardSidebarProjects({ }); } + // Group the section inputs per project, then build each project's tree. + // Every node (root or nested) lands in sectionMap so workspaces can join + // their group by id; only root nodes become top-level child entries. + const sectionsByProject = new Map(); for (const section of sidebarSections) { - const project = projectsById.get(section.projectId); + if (section.projectId === null) continue; // sessions scope, built separately + if (!projectsById.has(section.projectId)) continue; + const list = sectionsByProject.get(section.projectId) ?? []; + list.push(section); + sectionsByProject.set(section.projectId, list); + } + for (const [projectId, projectSections] of sectionsByProject) { + const project = projectsById.get(projectId); if (!project) continue; - - const sidebarSection: DashboardSidebarSection = { - ...section, - workspaces: [], - }; - - project.sectionMap.set(section.id, sidebarSection); - project.childEntries.push({ - tabOrder: section.tabOrder, - child: { - type: "section", - section: sidebarSection, - }, - }); + const { nodesById, rootSections } = buildSectionTree(projectSections); + for (const [sectionId, node] of nodesById) { + project.sectionMap.set(sectionId, node); + } + for (const section of rootSections) { + project.childEntries.push({ + tabOrder: section.tabOrder, + child: { + type: "section", + section, + }, + }); + } } for (const workspace of visibleSidebarWorkspaces) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index f56b7ccf985..08a38e14c80 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -23,7 +23,8 @@ import type { import { buildDashboardSidebarPinnedWorkspaces, buildDashboardSidebarProjects, - buildDashboardSidebarSessionWorkspaces, + buildDashboardSidebarSessionsScope, + type DashboardSidebarSessionsScope, partitionSidebarWorkspacesByPinned, } from "./buildDashboardSidebarProjects"; import { @@ -237,6 +238,7 @@ export function useDashboardSidebarData() { .select(({ sidebarSections }) => ({ id: sidebarSections.sectionId, projectId: sidebarSections.projectId, + parentSectionId: sidebarSections.parentSectionId, name: sidebarSections.name, createdAt: sidebarSections.createdAt, isCollapsed: sidebarSections.isCollapsed, @@ -245,6 +247,16 @@ export function useDashboardSidebarData() { })), [collections], ); + // TanStack DB's eq(col, null) never matches, so scope-splitting the + // sections happens in JS rather than in the query. + const { projectSections, sessionSections } = useMemo(() => { + const project: typeof sidebarSections = []; + const sessions: typeof sidebarSections = []; + for (const section of sidebarSections) { + (section.projectId === null ? sessions : project).push(section); + } + return { projectSections: project, sessionSections: sessions }; + }, [sidebarSections]); const { workspaces: hostWorkspaces } = useHostWorkspaces(); const hostWorkspacesById = useMemo( @@ -482,7 +494,7 @@ export function useDashboardSidebarData() { () => buildDashboardSidebarProjects({ sidebarProjects, - sidebarSections, + sidebarSections: projectSections, visibleSidebarWorkspaces: projectRows, machineId, pullRequestsByWorkspaceId, @@ -491,22 +503,23 @@ export function useDashboardSidebarData() { machineId, pullRequestsByWorkspaceId, sidebarProjects, - sidebarSections, + projectSections, projectRows, ], ); const groups = useStableDashboardSidebarProjects(computedGroups); - const computedSessionWorkspaces = useMemo( + const computedSessionsScope = useMemo( () => - buildDashboardSidebarSessionWorkspaces({ + buildDashboardSidebarSessionsScope({ sessionSidebarWorkspaces: sessionRows, + sessionSections, machineId, pullRequestsByWorkspaceId, }), - [machineId, pullRequestsByWorkspaceId, sessionRows], + [machineId, pullRequestsByWorkspaceId, sessionRows, sessionSections], ); - const sessionWorkspaces = useJsonStable(computedSessionWorkspaces); + const sessionsScope = useJsonStable(computedSessionsScope); const computedPinnedWorkspaces = useMemo( () => @@ -523,7 +536,7 @@ export function useDashboardSidebarData() { return { groups, pinnedWorkspaces, - sessionWorkspaces, + sessionsScope, refreshWorkspacePullRequest, toggleProjectCollapsed, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 03d95cdafed..ab642af082d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -6,16 +6,18 @@ import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/u import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; import type { DashboardSidebarProject, + DashboardSidebarSection, DashboardSidebarWorkspace, } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; +import type { DashboardSidebarSessionsScope } from "../useDashboardSidebarData/buildDashboardSidebarProjects"; interface WorkspaceLocation { - /** Null for the Sessions section — no project row to expand. */ + /** Null for the Sessions scope — no project row to expand. */ projectId: string | null; projectIsCollapsed: boolean; - sectionId: string | null; - sectionIsCollapsed: boolean; + /** Collapsed ancestor groups, outermost first — every one must expand. */ + collapsedSectionIds: string[]; } const MAX_SHORTCUT_COUNT = 9; @@ -54,22 +56,54 @@ function useStableWorkspaceShortcutLabels( export function useDashboardSidebarShortcuts( groups: DashboardSidebarProject[], - sessionWorkspaces: DashboardSidebarWorkspace[] = [], + sessionsScope: DashboardSidebarSessionsScope = { + looseWorkspaces: [], + rootSections: [], + }, ) { const navigate = useNavigate(); const { toggleProjectCollapsed, toggleSectionCollapsed } = useDashboardSidebarState(); const { isDeleting } = useDeletingWorkspaces(); + // Sessions render above the project groups, folders-first inside groups. + // Each entry keeps its collapsed-ancestor chain so reveal can expand it. + const sessionEntries = useMemo(() => { + const entries: Array<{ + workspace: DashboardSidebarWorkspace; + collapsedSectionIds: string[]; + }> = sessionsScope.looseWorkspaces.map((workspace) => ({ + workspace, + collapsedSectionIds: [], + })); + const visit = ( + section: DashboardSidebarSection, + collapsedAncestors: string[], + ) => { + const collapsedChain = section.isCollapsed + ? [...collapsedAncestors, section.id] + : collapsedAncestors; + for (const nested of section.childSections) { + visit(nested, collapsedChain); + } + for (const workspace of section.workspaces) { + entries.push({ workspace, collapsedSectionIds: collapsedChain }); + } + }; + for (const section of sessionsScope.rootSections) { + visit(section, []); + } + return entries; + }, [sessionsScope]); const flattenedWorkspaces = useMemo( () => [ // Sessions render above the project groups. - ...sessionWorkspaces, + ...sessionEntries.map((entry) => entry.workspace), ...groups.flatMap((project) => getProjectChildrenWorkspaces(project.children), ), ].filter((workspace) => !isDeleting(workspace.id)), - [groups, sessionWorkspaces, isDeleting], + [groups, sessionEntries, isDeleting], ); const workspaceShortcutLabels = useStableWorkspaceShortcutLabels(flattenedWorkspaces); @@ -82,31 +116,42 @@ export function useDashboardSidebarShortcuts( map.set(child.workspace.id, { projectId: project.id, projectIsCollapsed: project.isCollapsed, - sectionId: null, - sectionIsCollapsed: false, + collapsedSectionIds: [], }); continue; } - for (const workspace of child.section.workspaces) { - map.set(workspace.id, { - projectId: project.id, - projectIsCollapsed: project.isCollapsed, - sectionId: child.section.id, - sectionIsCollapsed: child.section.isCollapsed, - }); - } + // Nested groups: a workspace is hidden while ANY ancestor group + // is collapsed, so reveal must expand the whole collapsed chain. + const visitSection = ( + section: (typeof child)["section"], + collapsedAncestors: string[], + ) => { + const collapsedChain = section.isCollapsed + ? [...collapsedAncestors, section.id] + : collapsedAncestors; + for (const workspace of section.workspaces) { + map.set(workspace.id, { + projectId: project.id, + projectIsCollapsed: project.isCollapsed, + collapsedSectionIds: collapsedChain, + }); + } + for (const nested of section.childSections) { + visitSection(nested, collapsedChain); + } + }; + visitSection(child.section, []); } } - for (const workspace of sessionWorkspaces) { - map.set(workspace.id, { + for (const entry of sessionEntries) { + map.set(entry.workspace.id, { projectId: null, projectIsCollapsed: false, - sectionId: null, - sectionIsCollapsed: false, + collapsedSectionIds: entry.collapsedSectionIds, }); } return map; - }, [groups, sessionWorkspaces]); + }, [groups, sessionEntries]); const revealWorkspace = useCallback( (workspaceId: string) => { @@ -115,8 +160,8 @@ export function useDashboardSidebarShortcuts( if (location.projectId !== null && location.projectIsCollapsed) { toggleProjectCollapsed(location.projectId); } - if (location.sectionId && location.sectionIsCollapsed) { - toggleSectionCollapsed(location.sectionId); + for (const sectionId of location.collapsedSectionIds) { + toggleSectionCollapsed(sectionId); } }, [workspaceLocations, toggleProjectCollapsed, toggleSectionCollapsed], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts index afb943b4c7d..965a26b199e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -169,13 +169,19 @@ export function useSidebarDnd({ const workspacesById = useMemo(() => { const map = new Map(); + const visitSection = (section: DashboardSidebarSection) => { + for (const ws of section.workspaces) { + map.set(ws.id, ws); + } + for (const nested of section.childSections) { + visitSection(nested); + } + }; for (const child of projectChildren) { if (child.type === "workspace") { map.set(child.workspace.id, child.workspace); } else { - for (const ws of child.section.workspaces) { - map.set(ws.id, ws); - } + visitSection(child.section); } } return map; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 9ba0fc84502..11ed4c11ca3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -61,13 +61,19 @@ export type DashboardSidebarPinnedWorkspace = DashboardSidebarWorkspace & { export interface DashboardSidebarSection { id: string; - projectId: string; + /** Null scopes the group to the top-level Sessions area. */ + projectId: string | null; + /** Non-null when this group nests inside another group. */ + parentSectionId: string | null; name: string; createdAt: Date; isCollapsed: boolean; tabOrder: number; color: string | null; + /** Direct workspaces (not those of nested groups). */ workspaces: DashboardSidebarWorkspace[]; + /** Nested groups, rendered folders-first above `workspaces`. */ + childSections: DashboardSidebarSection[]; } export type DashboardSidebarProjectChild = diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts index 4927ded9ba2..5287feabdcc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts @@ -23,22 +23,38 @@ export function getFlattenedV2WorkspaceIds( const result: string[] = []; // Sessions (null projectId) render in the top-level Sessions section - // ABOVE the project groups, ordered by tabOrder. - const sessionWorkspaces = visibleWorkspaces - .filter((workspace) => workspace.sidebarState.projectId === null) + // ABOVE the project groups: loose sessions first, then group trees. + const sessionWorkspaces = visibleWorkspaces.filter( + (workspace) => workspace.sidebarState.projectId === null, + ); + const looseSessions = sessionWorkspaces + .filter((workspace) => workspace.sidebarState.sectionId === null) .sort( (left, right) => left.sidebarState.tabOrder - right.sidebarState.tabOrder, ); - for (const workspace of sessionWorkspaces) { + for (const workspace of looseSessions) { result.push(workspace.workspaceId); } + const sessionRootSections = allSections + .filter( + (section) => + section.projectId === null && section.parentSectionId === null, + ) + .sort((left, right) => left.tabOrder - right.tabOrder); + for (const section of sessionRootSections) { + walkSection(section.sectionId, sessionWorkspaces, allSections, result); + } for (const project of projects) { const projectWorkspaces = visibleWorkspaces.filter( (workspace) => workspace.sidebarState.projectId === project.projectId, ); + // Root sections only — nested ones are reached through walkSection, + // and including them here would emit their workspaces twice. const projectSections = allSections.filter( - (section) => section.projectId === project.projectId, + (section) => + section.projectId === project.projectId && + section.parentSectionId === null, ); const topLevelItems: TopLevelItem[] = []; @@ -71,19 +87,51 @@ export function getFlattenedV2WorkspaceIds( result.push(item.workspaceId); continue; } - const sectionWorkspaces = projectWorkspaces - .filter( - (workspace) => workspace.sidebarState.sectionId === item.sectionId, - ) - .sort( - (left, right) => - left.sidebarState.tabOrder - right.sidebarState.tabOrder, - ); - for (const workspace of sectionWorkspaces) { - result.push(workspace.workspaceId); - } + walkSection(item.sectionId, projectWorkspaces, allSections, result); } } return result; } + +type SectionRow = { + sectionId: string; + projectId: string | null; + parentSectionId: string | null; + tabOrder: number; +}; + +type WorkspaceRow = { + workspaceId: string; + sidebarState: { sectionId: string | null; tabOrder: number }; +}; + +/** + * Depth-first over one group: nested groups first (folders-first, matching + * the render), then the group's direct workspaces. A visited set makes + * corrupt parent cycles terminate instead of recursing forever. + */ +function walkSection( + sectionId: string, + scopeWorkspaces: WorkspaceRow[], + allSections: SectionRow[], + result: string[], + visited: Set = new Set(), +): void { + if (visited.has(sectionId)) return; + visited.add(sectionId); + const childSections = allSections + .filter((section) => section.parentSectionId === sectionId) + .sort((left, right) => left.tabOrder - right.tabOrder); + for (const child of childSections) { + walkSection(child.sectionId, scopeWorkspaces, allSections, result, visited); + } + const sectionWorkspaces = scopeWorkspaces + .filter((workspace) => workspace.sidebarState.sectionId === sectionId) + .sort( + (left, right) => left.sidebarState.tabOrder - right.sidebarState.tabOrder, + ); + for (const workspace of sectionWorkspaces) { + result.push(workspace.workspaceId); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/projectChildren/projectChildren.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/projectChildren/projectChildren.ts index 08c1467157c..c25b15c6c9d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/projectChildren/projectChildren.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/projectChildren/projectChildren.ts @@ -18,6 +18,18 @@ export function getProjectChildrenWorkspaces( children: DashboardSidebarProjectChild[], ): DashboardSidebarWorkspace[] { return children.flatMap((child) => - child.type === "workspace" ? [child.workspace] : child.section.workspaces, + child.type === "workspace" + ? [child.workspace] + : getSectionWorkspaces(child.section), ); } + +/** Folders-first, matching the rendered order of a group's contents. */ +function getSectionWorkspaces( + section: DashboardSidebarSection, +): DashboardSidebarWorkspace[] { + return [ + ...section.childSections.flatMap(getSectionWorkspaces), + ...section.workspaces, + ]; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/index.ts index 4ed0f58753c..85b3b7fc6fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/index.ts @@ -1 +1,5 @@ -export { useDashboardSidebarState } from "./useDashboardSidebarState"; +export { + canMoveSectionToParent, + MAX_SECTION_DEPTH, + useDashboardSidebarState, +} from "./useDashboardSidebarState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 4fc98394d27..aeb5d6b58b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -67,6 +67,7 @@ function getProjectTopLevelItems( .filter( (item) => item.projectId === projectId && + item.parentSectionId === null && item.sectionId !== options.excludeSectionId, ) .map((item) => ({ @@ -77,6 +78,124 @@ function getProjectTopLevelItems( ].sort(compareProjectTopLevelItems); } +/** + * Walks up `parentSectionId` links. Returns true when `candidateAncestorId` + * appears in `sectionId`'s ancestor chain — used to refuse cycle-creating + * moves. A visited set bounds the walk on already-corrupt data. + */ +function isSectionDescendantOf( + collections: Pick, + sectionId: string, + candidateAncestorId: string, +): boolean { + const visited = new Set(); + let current: string | null = sectionId; + while (current !== null && !visited.has(current)) { + if (current === candidateAncestorId) return true; + visited.add(current); + current = + collections.v2SidebarSections.get(current)?.parentSectionId ?? null; + } + return false; +} + +/** Mirrors the tree builder's cap: nesting past this splices to root. */ +export const MAX_SECTION_DEPTH = 4; + +/** 0 = root. Bounded by a visited set on corrupt data. */ +function getSectionDepth( + collections: Pick, + sectionId: string, +): number { + const visited = new Set(); + let depth = 0; + let current: string | null = + collections.v2SidebarSections.get(sectionId)?.parentSectionId ?? null; + while (current !== null && !visited.has(current)) { + visited.add(current); + depth += 1; + current = + collections.v2SidebarSections.get(current)?.parentSectionId ?? null; + } + return depth; +} + +/** Longest descendant chain under `sectionId` (0 = leaf). */ +function getSectionSubtreeHeight( + collections: Pick, + sectionId: string, + visited: Set = new Set(), +): number { + if (visited.has(sectionId)) return 0; + visited.add(sectionId); + let height = 0; + for (const row of collections.v2SidebarSections.state.values()) { + if (row.parentSectionId !== sectionId) continue; + height = Math.max( + height, + 1 + getSectionSubtreeHeight(collections, row.sectionId, visited), + ); + } + return height; +} + +/** + * True when re-parenting `sectionId` under `parentSectionId` keeps the tree + * valid: same scope, no cycle, and the moved subtree stays within + * MAX_SECTION_DEPTH. Shared by the move mutation and the menu's target list + * so users are never offered a move the mutation would refuse. + */ +export function canMoveSectionToParent( + collections: Pick, + sectionId: string, + parentSectionId: string | null, +): boolean { + const section = collections.v2SidebarSections.get(sectionId); + if (!section) return false; + if (parentSectionId === null) return true; + const parent = collections.v2SidebarSections.get(parentSectionId); + if (!parent) return false; + if (parent.projectId !== section.projectId) return false; + if (isSectionDescendantOf(collections, parentSectionId, sectionId)) { + return false; + } + const depthAfterMove = + getSectionDepth(collections, parentSectionId) + + 1 + + getSectionSubtreeHeight(collections, sectionId); + return depthAfterMove < MAX_SECTION_DEPTH; +} + +/** + * Ordered children of one group: workspaces whose sectionId points at it plus + * groups whose parentSectionId points at it. + */ +function getSectionChildItems( + collections: ProjectTopLevelCollections, + sectionId: string, +): ProjectTopLevelItem[] { + return [ + ...Array.from(collections.v2WorkspaceLocalState.state.values()) + .filter( + (item) => + isSidebarWorkspaceVisible(item) && + item.sidebarState.sectionId === sectionId, + ) + .map((item) => ({ + type: "workspace" as const, + id: item.workspaceId, + tabOrder: item.sidebarState.tabOrder, + })), + ...Array.from(collections.v2SidebarSections.state.values()) + .filter((item) => item.parentSectionId === sectionId) + .map((item) => ({ + type: "section" as const, + id: item.sectionId, + tabOrder: item.tabOrder, + })), + ].sort(compareProjectTopLevelItems); +} + function getFirstSectionIndex(items: ProjectTopLevelItem[]): number { const firstSectionIndex = items.findIndex((item) => item.type === "section"); return firstSectionIndex === -1 ? items.length : firstSectionIndex; @@ -311,9 +430,14 @@ export function useDashboardSidebarState() { ); const createSection = useCallback( - (projectId: string, options: { name?: string } = {}) => { - const { name = "New group" } = options; - ensureSidebarProjectRecord(collections, projectId); + ( + projectId: string | null, + options: { name?: string; parentSectionId?: string | null } = {}, + ) => { + const { name = "New group", parentSectionId = null } = options; + if (projectId !== null) { + ensureSidebarProjectRecord(collections, projectId); + } const sectionId = crypto.randomUUID(); const randomColor = @@ -321,13 +445,15 @@ export function useDashboardSidebarState() { Math.floor(Math.random() * PROJECT_CUSTOM_COLORS.length) ].value; - const tabOrder = getNextTabOrder( - getProjectTopLevelItems(collections, projectId), - ); + const tabOrder = + parentSectionId === null + ? getNextTabOrder(getProjectTopLevelItems(collections, projectId)) + : getNextTabOrder(getSectionChildItems(collections, parentSectionId)); collections.v2SidebarSections.insert({ sectionId, projectId, + parentSectionId, name, createdAt: new Date(), tabOrder, @@ -340,6 +466,32 @@ export function useDashboardSidebarState() { [collections], ); + /** + * Re-parents a group. `parentSectionId: null` moves it to its scope's top + * level. Refuses self/descendant targets (would orphan the subtree in a + * cycle) and cross-scope targets (a group's workspaces stay in its scope). + */ + const moveSectionToParent = useCallback( + (sectionId: string, parentSectionId: string | null) => { + const section = collections.v2SidebarSections.get(sectionId); + if (!section) return; + if (!canMoveSectionToParent(collections, sectionId, parentSectionId)) { + return; + } + const tabOrder = + parentSectionId === null + ? getNextTabOrder( + getProjectTopLevelItems(collections, section.projectId), + ) + : getNextTabOrder(getSectionChildItems(collections, parentSectionId)); + collections.v2SidebarSections.update(sectionId, (draft) => { + draft.parentSectionId = parentSectionId; + draft.tabOrder = tabOrder; + }); + }, + [collections], + ); + const toggleSectionCollapsed = useCallback( (sectionId: string) => { if (!collections.v2SidebarSections.get(sectionId)) return; @@ -420,6 +572,35 @@ export function useDashboardSidebarState() { const section = collections.v2SidebarSections.get(sectionId); if (!section) return; + // Child groups climb to the deleted group's parent so their + // contents survive; workspaces splice into the parent scope below. + for (const child of collections.v2SidebarSections.state.values()) { + if (child.parentSectionId !== sectionId) continue; + collections.v2SidebarSections.update(child.sectionId, (draft) => { + draft.parentSectionId = section.parentSectionId; + }); + } + if (section.parentSectionId !== null) { + const parentId = section.parentSectionId; + const sectionWorkspaces = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ).filter( + (item) => + isSidebarWorkspaceVisible(item) && + item.sidebarState.sectionId === sectionId, + ); + for (const workspace of sectionWorkspaces) { + collections.v2WorkspaceLocalState.update( + workspace.workspaceId, + (draft) => { + draft.sidebarState.sectionId = parentId; + }, + ); + } + collections.v2SidebarSections.delete(sectionId); + return; + } + const topLevelItems = getProjectTopLevelItems( collections, section.projectId, @@ -528,6 +709,7 @@ export function useDashboardSidebarState() { return { createSection, deleteSection, + moveSectionToParent, ensureProjectInSidebar, ensureWorkspaceInSidebar, hideWorkspaceInSidebar, diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 10b213338e6..91bdc79d939 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -202,12 +202,15 @@ const WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS = { /** * A sidebar group ("section" historically — the persisted key and field names - * keep that spelling so existing rows parse untouched). Sections are flat and - * project-scoped: one level of grouping inside a project. + * keep that spelling so existing rows parse untouched). Groups form a tree: + * `parentSectionId` nests a group inside another, `projectId: null` scopes a + * group to the top-level Sessions area instead of a project. Both additions + * are widening-with-default, so pre-tree rows heal to flat project groups. */ export const dashboardSidebarSectionSchema = z.object({ sectionId: z.string().uuid(), - projectId: z.string().uuid(), + projectId: z.string().uuid().nullable(), + parentSectionId: z.string().uuid().nullable().default(null), name: z.string().trim().min(1), createdAt: persistedDateSchema, tabOrder: z.number().int().default(0), diff --git a/plans/20260808-optional-project-sessions.md b/plans/20260808-optional-project-sessions.md index 26c6583343b..0394d87fa33 100644 --- a/plans/20260808-optional-project-sessions.md +++ b/plans/20260808-optional-project-sessions.md @@ -77,8 +77,24 @@ Precedent: the unmerged `origin/sidebar-freeform-sessions` branch proved chat/te ### Phase C — universal grouping tree -Ships in a separate stacked PR (branch `nested-workspace-groups`). This PR keeps -sections flat and project-scoped; sessions render as a flat list. +**Implementation notes (as built — diverges from the original sketch in two ways):** + +1. **No new collection, no migration.** Instead of a `v2SidebarGroups` collection with an + id-preserving copy, the existing `dashboardSidebarSectionSchema` was extended in place: + `parentSectionId: uuid | null (default null)` and `projectId` widened to `uuid | null` + (null = Sessions scope). Both are widening-with-default, so every persisted row parses + untouched — zero migration risk, `sidebarState.sectionId` references keep resolving. +2. **DnD granularity unchanged; nesting is explicit-structure + context menus.** The flat + DnD lane's group membership is positional and can't express depth, so root-level drag + behaviors are untouched. Nested groups render as an indented folders-first block under + their parent header (`DashboardSidebarNestedSection`, recursive) and move via context + menus: workspace "Move to group" (all same-scope groups), group "Move to group" / + "Move to top level" (`moveSectionToParent`, refuses self/descendant/cross-scope). + Drag-into-nested-group is a follow-up. + +Tree safety: `buildSectionTree` splices self-cycles, missing direct parents, and +depth > 4 to the scope root (never drops nodes); `getFlattenedV2WorkspaceIds` and the +Sessions scope walk recursively with visited sets. ### Phase D — CLI / MCP / SDK (any time after A)