Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6a12647
feat(desktop): rewrite sidebar DnD with flat list approach
saddlepaddle Apr 7, 2026
d751262
feat(desktop): separate section and workspace drag modes
saddlepaddle Apr 7, 2026
4d3a4d9
feat(desktop): animate section workspace collapse on section drag start
saddlepaddle Apr 7, 2026
69979a7
fix(desktop): hide all workspaces during section drag, not just group…
saddlepaddle Apr 7, 2026
92acb0d
Revert "fix(desktop): hide all workspaces during section drag, not ju…
saddlepaddle Apr 7, 2026
f7dd3d7
feat(desktop): add project-level drag and drop reordering
saddlepaddle Apr 7, 2026
7ee9b13
fix(desktop): disable project drop animation to avoid size mismatch
saddlepaddle Apr 7, 2026
7e760dd
fix(desktop): disable section drop animation to match project behavior
saddlepaddle Apr 7, 2026
e47c14b
feat(desktop): ghost shows predicted section color during workspace drag
saddlepaddle Apr 7, 2026
473659f
fix(desktop): correct predicted color when hovering above a section h…
saddlepaddle Apr 7, 2026
50b1da6
feat(desktop): redesign section headers as ruled dividers
saddlepaddle Apr 7, 2026
0d29663
fix(desktop): auto-size section rename input to text width
saddlepaddle Apr 7, 2026
81a5769
fix(desktop): prevent layout shift on section rename by keeping count…
saddlepaddle Apr 7, 2026
b41c49c
fix(desktop): replace pencil/count with separator line during section…
saddlepaddle Apr 7, 2026
829394c
fix(desktop): contiguous right rule during section rename
saddlepaddle Apr 7, 2026
470e54d
fix(desktop): tighten section rename input width for longer right rule
saddlepaddle Apr 7, 2026
625756b
feat(desktop): polish sidebar DnD UI
saddlepaddle Apr 7, 2026
f8afc6b
feat(desktop): restore original section header style with grip icon
saddlepaddle Apr 7, 2026
d1e04b8
feat(desktop): sidebar UX polish
saddlepaddle Apr 7, 2026
5a61525
fix(desktop): address PR review comments
saddlepaddle Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,34 +1,188 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader";
import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection";
import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData";
import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts";
import type { DashboardSidebarProject } from "./types";

interface DashboardSidebarProps {
isCollapsed?: boolean;
}

function SortableProjectWrapper({
project,
isCollapsed,
isDraggingProject,
workspaceShortcutLabels,
onWorkspaceHover,
onToggleCollapse,
}: {
project: DashboardSidebarProject;
isCollapsed: boolean;
isDraggingProject: boolean;
workspaceShortcutLabels: Map<string, string>;
onWorkspaceHover: (workspaceId: string) => void | Promise<void>;
onToggleCollapse: (projectId: string) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.id });

return (
<div
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
}}
>
<DashboardSidebarProjectSection
project={project}
isSidebarCollapsed={isCollapsed}
isDraggingProject={isDraggingProject}
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={onWorkspaceHover}
onToggleCollapse={onToggleCollapse}
dragHandleListeners={listeners}
dragHandleAttributes={attributes}
/>
</div>
);
}

export function DashboardSidebar({
isCollapsed = false,
}: DashboardSidebarProps) {
const { groups, refreshWorkspacePullRequest, toggleProjectCollapsed } =
useDashboardSidebarData();
const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups);
const { reorderProjects } = useDashboardSidebarState();

const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);

const [activeProject, setActiveProject] =
useState<DashboardSidebarProject | null>(null);

// Local project order — syncs from groups, updated on drag end
const [projectOrder, setProjectOrder] = useState(() =>
groups.map((p) => p.id),
);
useEffect(() => {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This effect can overwrite flatItems during an active drag if projectChildren changes externally (e.g., a workspace is created while the user is mid-drag). This snaps items back to the server order and breaks the drag. Add an early return when activeId is set, and include activeId in the dependency array.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx, line 106:

<comment>This effect can overwrite `flatItems` during an active drag if `projectChildren` changes externally (e.g., a workspace is created while the user is mid-drag). This snaps items back to the server order and breaks the drag. Add an early return when `activeId` is set, and include `activeId` in the dependency array.</comment>

<file context>
@@ -1,34 +1,188 @@
+	const [projectOrder, setProjectOrder] = useState(() =>
+		groups.map((p) => p.id),
+	);
+	useEffect(() => {
+		setProjectOrder(groups.map((p) => p.id));
+	}, [groups]);
</file context>
Fix with Cubic

setProjectOrder(groups.map((p) => p.id));
}, [groups]);
Comment on lines +106 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 - Style] projectOrder resets unconditionally on any groups change

Unlike useSidebarDnd which uses a fingerprint check before resetting flatItems, this effect calls setProjectOrder on every groups reference change — including changes caused by workspace additions within a project that don't alter the project-level order. This creates a new array each render, triggering a redundant state update.

Consider applying the same fingerprint guard used in useSidebarDnd:

Suggested change
useEffect(() => {
setProjectOrder(groups.map((p) => p.id));
}, [groups]);
const prevProjectFingerprintRef = useRef("");
useEffect(() => {
const fingerprint = groups.map((p) => p.id).join(",");
if (fingerprint !== prevProjectFingerprintRef.current) {
prevProjectFingerprintRef.current = fingerprint;
setProjectOrder(groups.map((p) => p.id));
}
}, [groups]);

Comment on lines +106 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 projectOrder resets unconditionally on any groups change

Unlike useSidebarDnd which uses a fingerprint check before resetting flatItems, this effect calls setProjectOrder on every groups reference change — including changes caused by workspace additions within a project that don't alter project-level order. Consider applying the same fingerprint guard:

Suggested change
useEffect(() => {
setProjectOrder(groups.map((p) => p.id));
}, [groups]);
const prevProjectFingerprintRef = useRef("");
useEffect(() => {
const fingerprint = groups.map((p) => p.id).join(",");
if (fingerprint !== prevProjectFingerprintRef.current) {
prevProjectFingerprintRef.current = fingerprint;
setProjectOrder(groups.map((p) => p.id));
}
}, [groups]);


const orderedGroups = useMemo(() => {
const byId = new Map(groups.map((g) => [g.id, g]));
return projectOrder
.map((id) => byId.get(id))
.filter((g): g is DashboardSidebarProject => g != null);
}, [groups, projectOrder]);

const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
const oldIndex = projectOrder.indexOf(String(active.id));
const newIndex = projectOrder.indexOf(String(over.id));
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(projectOrder, oldIndex, newIndex);
setProjectOrder(reordered);
reorderProjects(reordered);
}
}
setActiveProject(null);
},
[projectOrder, reorderProjects],
);

return (
<div className="flex h-full flex-col border-r border-border bg-muted/45 dark:bg-muted/35">
<DashboardSidebarHeader isCollapsed={isCollapsed} />

<div className="flex-1 overflow-y-auto hide-scrollbar">
{groups.map((project) => (
<DashboardSidebarProjectSection
key={project.id}
project={project}
isSidebarCollapsed={isCollapsed}
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={refreshWorkspacePullRequest}
onToggleCollapse={toggleProjectCollapsed}
/>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
measuring={{
droppable: { strategy: MeasuringStrategy.Always },
}}
onDragStart={({ active }) => {
const project = groups.find((p) => p.id === active.id);
setActiveProject(project ?? null);
}}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveProject(null)}
>
<SortableContext
items={projectOrder}
strategy={verticalListSortingStrategy}
>
{orderedGroups.map((project) => (
<SortableProjectWrapper
key={project.id}
project={project}
isCollapsed={isCollapsed}
isDraggingProject={activeProject != null}
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={refreshWorkspacePullRequest}
onToggleCollapse={toggleProjectCollapsed}
/>
))}
</SortableContext>

{createPortal(
<DragOverlay dropAnimation={null}>
{activeProject && (
<div className="bg-background shadow-lg border-b border-border">
<DashboardSidebarProjectSection
project={activeProject}
isSidebarCollapsed={isCollapsed}
isDraggingProject
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={() => {}}
onToggleCollapse={() => {}}
/>
</div>
)}
</DragOverlay>,
document.body,
)}
</DndContext>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import { cn } from "@superset/ui/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import type { DashboardSidebarProject } from "../../types";
import {
getProjectChildrenSections,
getProjectChildrenWorkspaces,
} from "../../utils/projectChildren";
import { getProjectChildrenWorkspaces } from "../../utils/projectChildren";
import { DashboardSidebarCollapsedProjectContent } from "./components/DashboardSidebarCollapsedProjectContent";
import { DashboardSidebarExpandedProjectContent } from "./components/DashboardSidebarExpandedProjectContent";
import { DashboardSidebarProjectContextMenu } from "./components/DashboardSidebarProjectContextMenu";
Expand All @@ -14,23 +16,24 @@ import { useDashboardSidebarProjectSectionActions } from "./hooks/useDashboardSi
interface DashboardSidebarProjectSectionProps {
project: DashboardSidebarProject;
isSidebarCollapsed?: boolean;
isDraggingProject?: boolean;
workspaceShortcutLabels: Map<string, string>;
onWorkspaceHover: (workspaceId: string) => void | Promise<void>;
onToggleCollapse: (projectId: string) => void;
dragHandleListeners?: DraggableSyntheticListeners;
dragHandleAttributes?: DraggableAttributes;
}

export function DashboardSidebarProjectSection({
project,
isSidebarCollapsed = false,
isDraggingProject = false,
workspaceShortcutLabels,
onWorkspaceHover,
onToggleCollapse,
dragHandleListeners,
dragHandleAttributes,
}: DashboardSidebarProjectSectionProps) {
const allSections = useMemo(
() => getProjectChildrenSections(project.children),
[project.children],
);

const flattenedCollapsedWorkspaces = useMemo(
() => getProjectChildrenWorkspaces(project.children),
[project.children],
Expand Down Expand Up @@ -104,19 +107,33 @@ export function DashboardSidebarProjectSection({
onStartRename={startRename}
onToggleCollapse={() => onToggleCollapse(project.id)}
onNewWorkspace={handleNewWorkspace}
{...(dragHandleAttributes ?? {})}
{...(dragHandleListeners ?? {})}
/>
</DashboardSidebarProjectContextMenu>

<DashboardSidebarExpandedProjectContent
isCollapsed={project.isCollapsed}
projectChildren={project.children}
allSections={allSections}
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={onWorkspaceHover}
onDeleteSection={deleteSection}
onRenameSection={renameSection}
onToggleSectionCollapse={toggleSectionCollapsed}
/>
<AnimatePresence initial={false}>
{!isDraggingProject && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="overflow-hidden"
>
<DashboardSidebarExpandedProjectContent
projectId={project.id}
isCollapsed={project.isCollapsed}
projectChildren={project.children}
workspaceShortcutLabels={workspaceShortcutLabels}
onWorkspaceHover={onWorkspaceHover}
onDeleteSection={deleteSection}
onRenameSection={renameSection}
onToggleSectionCollapse={toggleSectionCollapsed}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Loading
Loading