From f54f30cb90924d08a48e125e962be04d3a14d757 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 2 Mar 2026 14:20:49 -0600 Subject: [PATCH] feat(ui): drag-to-reorder sidebar projects with persistent order Add drag-and-drop reordering to sidebar project list using dnd-kit, persisted per-user via localStorage. Use consistent project order in issue properties, new issue dialog, and issue detail mention options. Move projects section below Work section in sidebar. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssueProperties.tsx | 14 ++- ui/src/components/NewIssueDialog.tsx | 25 +++-- ui/src/components/Sidebar.tsx | 4 +- ui/src/components/SidebarProjects.tsx | 153 +++++++++++++++++++++----- ui/src/hooks/useProjectOrder.ts | 105 ++++++++++++++++++ ui/src/lib/project-order.ts | 70 ++++++++++++ ui/src/pages/IssueDetail.tsx | 14 ++- 7 files changed, 337 insertions(+), 48 deletions(-) create mode 100644 ui/src/hooks/useProjectOrder.ts create mode 100644 ui/src/lib/project-order.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 299de898..75e13bcf 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -8,6 +8,7 @@ import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; +import { useProjectOrder } from "../hooks/useProjectOrder"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -125,6 +126,11 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp queryFn: () => projectsApi.list(companyId!), enabled: !!companyId, }); + const { orderedProjects } = useProjectOrder({ + projects: projects ?? [], + companyId, + userId: currentUserId, + }); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(companyId!), @@ -165,8 +171,8 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp }; const projectName = (id: string | null) => { - if (!id || !projects) return id?.slice(0, 8) ?? "None"; - const project = projects.find((p) => p.id === id); + if (!id) return id?.slice(0, 8) ?? "None"; + const project = orderedProjects.find((p) => p.id === id); return project?.name ?? id.slice(0, 8); }; @@ -359,7 +365,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp <> p.id === issue.projectId)?.color ?? "#6366f1" }} + style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }} /> {projectName(issue.projectId)} @@ -389,7 +395,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > No project - {(projects ?? []) + {orderedProjects .filter((p) => { if (!projectSearch.trim()) return true; const q = projectSearch.toLowerCase(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 4f26fa29..955f57a3 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -6,8 +6,10 @@ import { useToast } from "../context/ToastContext"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; +import { useProjectOrder } from "../hooks/useProjectOrder"; import { Dialog, DialogContent, @@ -195,6 +197,16 @@ export function NewIssueDialog() { queryFn: () => projectsApi.list(effectiveCompanyId!), enabled: !!effectiveCompanyId && newIssueOpen, }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const { orderedProjects } = useProjectOrder({ + projects: projects ?? [], + companyId: effectiveCompanyId, + userId: currentUserId, + }); const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null; const supportsAssigneeOverrides = Boolean( @@ -212,8 +224,7 @@ export function NewIssueDialog() { kind: "agent", }); } - const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)); - for (const project of sortedProjects) { + for (const project of orderedProjects) { options.push({ id: `project:${project.id}`, name: project.name, @@ -223,7 +234,7 @@ export function NewIssueDialog() { }); } return options; - }, [agents, projects]); + }, [agents, orderedProjects]); const { data: assigneeAdapterModels } = useQuery({ queryKey: ["adapter-models", assigneeAdapterType], @@ -434,7 +445,7 @@ export function NewIssueDialog() { const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; const currentPriority = priorities.find((p) => p.value === priority); const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); - const currentProject = (projects ?? []).find((p) => p.id === projectId); + const currentProject = orderedProjects.find((project) => project.id === projectId); const assigneeOptionsTitle = assigneeAdapterType === "claude_local" ? "Claude options" @@ -458,12 +469,12 @@ export function NewIssueDialog() { ); const projectOptions = useMemo( () => - (projects ?? []).map((project) => ({ + orderedProjects.map((project) => ({ id: project.id, label: project.name, searchText: project.description ?? "", })), - [projects], + [orderedProjects], ); const modelOverrideOptions = useMemo( () => @@ -663,7 +674,7 @@ export function NewIssueDialog() { } renderOption={(option) => { if (!option.id) return {option.label}; - const project = (projects ?? []).find((item) => item.id === option.id); + const project = orderedProjects.find((item) => item.id === option.id); return ( <> - - + + diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index d4e97be4..0ff66459 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -1,13 +1,25 @@ -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, Plus } from "lucide-react"; +import { + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; +import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; +import { useProjectOrder } from "../hooks/useProjectOrder"; import { Collapsible, CollapsibleContent, @@ -15,6 +27,60 @@ import { } from "@/components/ui/collapsible"; import type { Project } from "@paperclip/shared"; +function SortableProjectItem({ + activeProjectId, + isMobile, + project, + setSidebarOpen, +}: { + activeProjectId: string | null; + isMobile: boolean; + project: Project; + setSidebarOpen: (open: boolean) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: project.id }); + + return ( +
+ { + if (isMobile) setSidebarOpen(false); + }} + className={cn( + "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", + activeProjectId === project.id + ? "bg-accent text-foreground" + : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", + )} + > + + {project.name} + +
+ ); +} + export function SidebarProjects() { const [open, setOpen] = useState(true); const { selectedCompanyId } = useCompany(); @@ -27,15 +93,45 @@ export function SidebarProjects() { queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); - // Filter out archived projects - const visibleProjects = (projects ?? []).filter( - (p: Project) => !p.archivedAt + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + + const visibleProjects = useMemo( + () => (projects ?? []).filter((project: Project) => !project.archivedAt), + [projects], ); + const { orderedProjects, persistOrder } = useProjectOrder({ + projects: visibleProjects, + companyId: selectedCompanyId, + userId: currentUserId, + }); - // Extract current projectId from URL const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/); const activeProjectId = projectMatch?.[1] ?? null; + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const ids = orderedProjects.map((project) => project.id); + const oldIndex = ids.indexOf(active.id as string); + const newIndex = ids.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + persistOrder(arrayMove(ids, oldIndex, newIndex)); + }, + [orderedProjects, persistOrder], + ); return ( @@ -66,31 +162,28 @@ export function SidebarProjects() { -
- {visibleProjects.map((project: Project) => ( - { - if (isMobile) setSidebarOpen(false); - }} - className={cn( - "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", - activeProjectId === project.id - ? "bg-accent text-foreground" - : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" - )} - > - - {project.name} - - ))} -
+ + project.id)} + strategy={verticalListSortingStrategy} + > +
+ {orderedProjects.map((project: Project) => ( + + ))} +
+
+
); diff --git a/ui/src/hooks/useProjectOrder.ts b/ui/src/hooks/useProjectOrder.ts new file mode 100644 index 00000000..9e87de19 --- /dev/null +++ b/ui/src/hooks/useProjectOrder.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Project } from "@paperclip/shared"; +import { + getProjectOrderStorageKey, + PROJECT_ORDER_UPDATED_EVENT, + readProjectOrder, + sortProjectsByStoredOrder, + writeProjectOrder, +} from "../lib/project-order"; + +type UseProjectOrderParams = { + projects: Project[]; + companyId: string | null | undefined; + userId: string | null | undefined; +}; + +type ProjectOrderUpdatedDetail = { + storageKey: string; + orderedIds: string[]; +}; + +function areEqual(a: string[], b: string[]) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function buildOrderIds(projects: Project[], orderedIds: string[]) { + return sortProjectsByStoredOrder(projects, orderedIds).map((project) => project.id); +} + +export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) { + const storageKey = useMemo(() => { + if (!companyId) return null; + return getProjectOrderStorageKey(companyId, userId); + }, [companyId, userId]); + + const [orderedIds, setOrderedIds] = useState(() => { + if (!storageKey) return projects.map((project) => project.id); + return buildOrderIds(projects, readProjectOrder(storageKey)); + }); + + useEffect(() => { + const nextIds = storageKey + ? buildOrderIds(projects, readProjectOrder(storageKey)) + : projects.map((project) => project.id); + setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds)); + }, [projects, storageKey]); + + useEffect(() => { + if (!storageKey) return; + + const syncFromIds = (ids: string[]) => { + const nextIds = buildOrderIds(projects, ids); + setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds)); + }; + + const onStorage = (event: StorageEvent) => { + if (event.key !== storageKey) return; + syncFromIds(readProjectOrder(storageKey)); + }; + const onCustomEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || detail.storageKey !== storageKey) return; + syncFromIds(detail.orderedIds); + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent); + }; + }, [projects, storageKey]); + + const orderedProjects = useMemo( + () => sortProjectsByStoredOrder(projects, orderedIds), + [projects, orderedIds], + ); + + const persistOrder = useCallback( + (ids: string[]) => { + const idSet = new Set(projects.map((project) => project.id)); + const filtered = ids.filter((id) => idSet.has(id)); + for (const project of projects) { + if (!filtered.includes(project.id)) filtered.push(project.id); + } + + setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered)); + if (storageKey) { + writeProjectOrder(storageKey, filtered); + } + }, + [projects, storageKey], + ); + + return { + orderedProjects, + orderedIds, + persistOrder, + }; +} + diff --git a/ui/src/lib/project-order.ts b/ui/src/lib/project-order.ts new file mode 100644 index 00000000..9d118b84 --- /dev/null +++ b/ui/src/lib/project-order.ts @@ -0,0 +1,70 @@ +import type { Project } from "@paperclip/shared"; + +export const PROJECT_ORDER_UPDATED_EVENT = "paperclip:project-order-updated"; +const PROJECT_ORDER_STORAGE_PREFIX = "paperclip.projectOrder"; +const ANONYMOUS_USER_ID = "anonymous"; + +type ProjectOrderUpdatedDetail = { + storageKey: string; + orderedIds: string[]; +}; + +function normalizeIdList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string" && item.length > 0); +} + +function resolveUserId(userId: string | null | undefined): string { + if (!userId) return ANONYMOUS_USER_ID; + const trimmed = userId.trim(); + return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID; +} + +export function getProjectOrderStorageKey(companyId: string, userId: string | null | undefined): string { + return `${PROJECT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`; +} + +export function readProjectOrder(storageKey: string): string[] { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return []; + return normalizeIdList(JSON.parse(raw)); + } catch { + return []; + } +} + +export function writeProjectOrder(storageKey: string, orderedIds: string[]) { + const normalized = normalizeIdList(orderedIds); + try { + localStorage.setItem(storageKey, JSON.stringify(normalized)); + } catch { + // Ignore storage write failures in restricted browser contexts. + } + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(PROJECT_ORDER_UPDATED_EVENT, { + detail: { storageKey, orderedIds: normalized }, + }), + ); + } +} + +export function sortProjectsByStoredOrder(projects: Project[], orderedIds: string[]): Project[] { + if (projects.length === 0) return []; + if (orderedIds.length === 0) return projects; + + const byId = new Map(projects.map((project) => [project.id, project])); + const sorted: Project[] = []; + + for (const id of orderedIds) { + const project = byId.get(id); + if (!project) continue; + sorted.push(project); + byId.delete(id); + } + for (const project of byId.values()) { + sorted.push(project); + } + return sorted; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 2175df93..3d50dc38 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -12,6 +12,7 @@ import { useToast } from "../context/ToastContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; @@ -228,6 +229,12 @@ export function IssueDetail() { queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const { orderedProjects } = useProjectOrder({ + projects: projects ?? [], + companyId: selectedCompanyId, + userId: currentUserId, + }); const agentMap = useMemo(() => { const map = new Map(); @@ -247,8 +254,7 @@ export function IssueDetail() { kind: "agent", }); } - const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)); - for (const project of sortedProjects) { + for (const project of orderedProjects) { options.push({ id: `project:${project.id}`, name: project.name, @@ -258,7 +264,7 @@ export function IssueDetail() { }); } return options; - }, [agents, projects]); + }, [agents, orderedProjects]); const childIssues = useMemo(() => { if (!allIssues || !issue) return []; @@ -267,8 +273,6 @@ export function IssueDetail() { .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [allIssues, issue]); - const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; - const canReassignFromComment = Boolean( issue?.assigneeUserId && (issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),