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 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-02 14:20:49 -06:00
parent cabf09e7b1
commit f54f30cb90
7 changed files with 337 additions and 48 deletions

View File

@@ -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
<>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: projects?.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
/>
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
</>
@@ -389,7 +395,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
>
No project
</button>
{(projects ?? [])
{orderedProjects
.filter((p) => {
if (!projectSearch.trim()) return true;
const q = projectSearch.toLowerCase();

View File

@@ -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<InlineEntityOption[]>(
() =>
(projects ?? []).map((project) => ({
orderedProjects.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[projects],
[orderedProjects],
);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() =>
@@ -663,7 +674,7 @@ export function NewIssueDialog() {
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = (projects ?? []).find((item) => item.id === option.id);
const project = orderedProjects.find((item) => item.id === option.id);
return (
<>
<span

View File

@@ -88,13 +88,13 @@ export function Sidebar() {
/>
</div>
<SidebarProjects />
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
<SidebarProjects />
<SidebarAgents />
<SidebarSection label="Company">

View File

@@ -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 (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
}}
className={cn(isDragging && "opacity-80")}
{...attributes}
{...listeners}
>
<NavLink
to={`/projects/${project.id}/issues`}
onClick={() => {
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",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
</div>
);
}
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 (
<Collapsible open={open} onOpenChange={setOpen}>
@@ -66,31 +162,28 @@ export function SidebarProjects() {
</div>
<CollapsibleContent>
<div className="flex flex-col gap-0.5 mt-0.5">
{visibleProjects.map((project: Project) => (
<NavLink
key={project.id}
to={`/projects/${project.id}/issues`}
onClick={() => {
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"
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{
backgroundColor: project.color ?? "#6366f1",
}}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
))}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedProjects.map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5 mt-0.5">
{orderedProjects.map((project: Project) => (
<SortableProjectItem
key={project.id}
activeProjectId={activeProjectId}
isMobile={isMobile}
project={project}
setSidebarOpen={setSidebarOpen}
/>
))}
</div>
</SortableContext>
</DndContext>
</CollapsibleContent>
</Collapsible>
);