From e70f0bd41493b62f9500e4dc742751e613bcffa8 Mon Sep 17 00:00:00 2001 From: Ingo Wagner Date: Wed, 23 Sep 2026 10:48:06 +0000 Subject: [PATCH] feat(web): drag-to-reorder projects in sidebar - Add @dnd-kit (core, sortable, utilities) for mouse + touch drag reorder - SortableGroup.tsx: sortable wrapper with grip handle icon - project-sidebar-order.ts: localStorage persistence for order - Shell.tsx: DndContext + SortableContext around project sidebar - Folder icon for projects instead of lock icon --- apps/web/src/components/SortableGroup.tsx | 60 +++++++++++ apps/web/src/lib/project-sidebar-order.ts | 32 ++++++ apps/web/src/pages/Shell.tsx | 126 ++++++++++++++++++---- package.json | 5 + pnpm-lock.yaml | 59 ++++++++++ 5 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/SortableGroup.tsx create mode 100644 apps/web/src/lib/project-sidebar-order.ts diff --git a/apps/web/src/components/SortableGroup.tsx b/apps/web/src/components/SortableGroup.tsx new file mode 100644 index 0000000..4a8b270 --- /dev/null +++ b/apps/web/src/components/SortableGroup.tsx @@ -0,0 +1,60 @@ +/** + * Sortable wrapper for a project (space) sidebar group card. + * + * Wraps the card in @dnd-kit's useSortable so it can be dragged to reorder. + * Renders a grip handle icon on the title row. + */ +import { CSS as DndCSS } from "@dnd-kit/utilities"; +import { useSortable } from "@dnd-kit/sortable"; +import { GripVertical } from "lucide-react"; +import { memo, useMemo } from "react"; +import { cn } from "@rakazo/ui-web"; + +interface SortableGroupProps { + group: { spaceId: string; key: string; title?: string | null }; + children: React.ReactNode; +} + +export const SortableGroup = memo(function SortableGroup({ group, children }: SortableGroupProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: group.spaceId }); + + const style: React.CSSProperties = useMemo( + () => ({ + transform: DndCSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + zIndex: isDragging ? 1000 : undefined, + }), + [transform, transition, isDragging], + ); + + return ( +
+ {children} + {/* Invisible drag handle — left edge of the title row */} + {group.title ? ( + + ) : null} +
+ ); +}); diff --git a/apps/web/src/lib/project-sidebar-order.ts b/apps/web/src/lib/project-sidebar-order.ts new file mode 100644 index 0000000..c275981 --- /dev/null +++ b/apps/web/src/lib/project-sidebar-order.ts @@ -0,0 +1,32 @@ +/** + * Persisted project (space) sort order in the sidebar. + * + * Stores a JSON array of space IDs. When the array is present, it + * overrides the natural API order; an empty or missing array means + * “use API order”. + */ + +const STORAGE_KEY = "rakazo:project-sidebar-order"; + +export function readProjectSidebarOrder(): string[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function writeProjectSidebarOrder(ids: string[]): void { + try { + if (ids.length === 0) { + window.localStorage.removeItem(STORAGE_KEY); + } else { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); + } + } catch { + // Ignore quota / private-mode failures + } +} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 4575dfb..ebda84e 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -32,6 +32,8 @@ import { type MessageReaction, normalizeCreateBotProfile, } from "@rakazo/contracts"; +import { SortableGroup } from "../components/SortableGroup"; +import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order"; import { attachmentsForThread, buildComposerMentionOptions, @@ -84,6 +86,7 @@ import { } from "@rakazo/ui-web"; import { ArrowDown, + GripVertical, ArrowLeft, ArrowUp, Bell, @@ -93,8 +96,8 @@ import { Clock, Copy, Gauge, + Folder, LayoutGrid, - Lock, LogOut, Maximize2, Menu, @@ -136,6 +139,21 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; +import { + DndContext, + DragOverlay, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { MouseSensor, TouchSensor } from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArtifactFileCard } from "../components/ArtifactFileCard"; import { AskCard } from "../components/AskCard"; @@ -429,6 +447,23 @@ export function ShellPage() { const [showCreateBotModal, setShowCreateBotModal] = useState(false); + // Persisted project (space) sort order + const [projectOrder, setProjectOrder] = useState(() => readProjectSidebarOrder()); + const handleReorderProjects = useCallback( + (orderedIds: string[]) => { + setProjectOrder(orderedIds); + writeProjectSidebarOrder(orderedIds); + }, + [], + ); + + // Drag-to-reorder project sidebar state & sensors + const [draggedProjectId, setDraggedProjectId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { distance: 8 } }), + ); + // Browser Preview lifecycle useEffect(() => { const bp = bridge?.browserPreview; @@ -1581,7 +1616,7 @@ export function ShellPage() { .filter((group) => group.bots.length > 0); }, [homeSpaceId, sidebarGroups]); - const projectSidebarGroups = useMemo(() => { + const projectSidebarGroups = useMemo((): typeof sidebarGroups => { if (!homeSpaceId) return []; const entries: typeof sidebarGroups = []; @@ -1625,8 +1660,49 @@ export function ShellPage() { }); } + // Apply persisted drag-to-reorder order if present + if (projectOrder.length > 0) { + const orderMap = new Map(entries.map((e, i) => [e.spaceId, i])); + entries.sort((a, b) => { + const aIdx = orderMap.get(a.spaceId) ?? -1; + const bIdx = orderMap.get(b.spaceId) ?? -1; + // Items in order come first, then remaining in original order + const aInOrder = aIdx !== -1; + const bInOrder = bIdx !== -1; + if (aInOrder && bInOrder) return aIdx - bIdx; + if (aInOrder) return -1; + if (bInOrder) return 1; + return orderMap.get(a.spaceId) ?? 0 - (orderMap.get(b.spaceId) ?? 0); + }); + } + return entries; - }, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces]); + }, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces, projectOrder]); + + // DnD handlers for project sidebar reordering + const handleProjectDragEnd = useCallback( + (event: { active: { id: string | number }; over: { id: string | number } | null }) => { + if (!event.over) return; + const activeId = String(event.active.id); + const overId = String(event.over.id); + if (activeId === overId) return; + const sorted = projectSidebarGroups as any[]; + const activeIdx = sorted.findIndex((g) => g.spaceId === activeId); + const overIdx = sorted.findIndex((g) => g.spaceId === overId); + if (activeIdx === -1 || overIdx === -1) return; + const [moved] = sorted.splice(activeIdx, 1); + sorted.splice(overIdx, 0, moved); + const ids = sorted.map((g: any) => g.spaceId) as string[]; + handleReorderProjects(ids); + }, + [projectSidebarGroups, handleReorderProjects], + ); + const handleProjectDragStart = useCallback( + (event: { active: { id: string | number } }) => { + setDraggedProjectId(String(event.active.id)); + }, + [], + ); const openSpaceChat = useCallback( (spaceId: string, path: string) => { @@ -2330,7 +2406,8 @@ export function ShellPage() { }, [clearVoiceEntryParam]); const loadOlder = useCallback(() => loadOlderMessagesRef.current(), []); const jumpToReplyMessage = useCallback((messageId: string) => { - const existing = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`); + const escapedId = messageId.replace(/[^\w-]/g, (c) => `\\${c}`); + const existing = document.querySelector(`[data-message-id="${escapedId}"]`); if (existing) { // Cancel any in-flight around-fetch so it cannot overwrite this scroll. jumpGeneration.current += 1; @@ -3393,9 +3470,7 @@ export function ShellPage() { } > - {group.showLock ? ( - {group.emptySpaceId ? null : ( @@ -3627,11 +3702,23 @@ export function ShellPage() { ) : null} - {projectSidebarGroups.map((group) => { - const collapsed = - Boolean(group.title) && collapsedSidebarSections.has(group.key); - return ( -
+ setDraggedProjectId(null)} + > + g.title).map((g) => g.spaceId)} + strategy={verticalListSortingStrategy} + > + {projectSidebarGroups.map((group) => { + const collapsed = + Boolean(group.title) && collapsedSidebarSections.has(group.key); + return ( + +
{group.title ? (
))}
- ); - })} + + ); + })} + +
diff --git a/package.json b/package.json index d467709..45ff883 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,10 @@ "patchedDependencies": { "app-builder-lib@26.15.3": "patches/app-builder-lib@26.15.3.patch" } + }, + "dependencies": { + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3dbbef..8b7ce8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,16 @@ patchedDependencies: importers: .: + dependencies: + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/sortable': + specifier: 10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.8) devDependencies: '@biomejs/biome': specifier: 2.5.13 @@ -1881,6 +1891,28 @@ packages: '@daytona/toolbox-api-client@0.211.2': resolution: {integrity: sha512-88CUophLGD1XVO8SJMttImrKw/iu2PC0Zz82S5DZkCJc70zBHpIm+svaepGGxQaLJ0EEtNbm7G1sJnIMQZ7zXw==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: 19.2.3 + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: 19.2.3 + react-dom: 19.2.3 + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: 19.2.3 + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: 19.2.3 + '@e2b/desktop@2.4.0': resolution: {integrity: sha512-eeY4p/lz7kkDJMidREe7sp2nln16P1M1p64Fb+csn4SssxC2EjKBGqP0NEIlVDVd6iCuPbM7cVlkE/qbwJX4jg==} engines: {node: '>=20.18.1 <21 || >=22'} @@ -11228,6 +11260,31 @@ snapshots: - debug - supports-color + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + '@e2b/desktop@2.4.0': dependencies: e2b: 2.49.1 @@ -13481,7 +13538,9 @@ snapshots: metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.74.89': {}