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 ? (
-
- ) : null}
+
{group.title}
{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': {}