Persist sidebar order preferences

This commit is contained in:
Dotta committed 2026-04-14 11:51:38 -05:00
1 parent 84f0ffb9dd
commit 4694a15217
26 files changed
+14004 -145

No files matched your search

+1
View File
@@ -15,5 +15,6 @@ export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { sidebarPreferencesApi } from "./sidebarPreferences";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";
+12
View File
@@ -0,0 +1,12 @@
import type { SidebarOrderPreference, UpsertSidebarOrderPreference } from "@paperclipai/shared";
import { api } from "./client";
export const sidebarPreferencesApi = {
getCompanyOrder: () => api.get<SidebarOrderPreference>("/sidebar-preferences/me"),
updateCompanyOrder: (data: UpsertSidebarOrderPreference) =>
api.put<SidebarOrderPreference>("/sidebar-preferences/me", data),
getProjectOrder: (companyId: string) =>
api.get<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`),
updateProjectOrder: (companyId: string, data: UpsertSidebarOrderPreference) =>
api.put<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`, data),
};
+19 -88
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { Paperclip, Plus } from "lucide-react";
import { useQueries } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
DndContext,
closestCenter,
@@ -22,6 +22,8 @@ import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { authApi } from "../api/auth";
import { useCompanyOrder } from "../hooks/useCompanyOrder";
import { useLocation, useNavigate } from "@/lib/router";
import {
Tooltip,
@@ -31,42 +33,6 @@ import {
import type { Company } from "@paperclipai/shared";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
function getStoredOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return [];
}
function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
}
/** Sort companies by stored order, appending any new ones at the end. */
function sortByStoredOrder(companies: Company[]): Company[] {
const order = getStoredOrder();
if (order.length === 0) return companies;
const byId = new Map(companies.map((c) => [c.id, c]));
const sorted: Company[] = [];
for (const id of order) {
const c = byId.get(id);
if (c) {
sorted.push(c);
byId.delete(id);
}
}
// Append any companies not in stored order
for (const c of byId.values()) {
sorted.push(c);
}
return sorted;
}
function SortableCompanyItem({
company,
isSelected,
@@ -103,6 +69,10 @@ function SortableCompanyItem({
<a
href={`/${company.issuePrefix}/dashboard`}
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
e.preventDefault();
onSelect();
}}
@@ -164,6 +134,11 @@ export function CompanyRail() {
() => companies.filter((company) => company.status !== "archived"),
[companies],
);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
const liveRunsQueries = useQueries({
@@ -195,52 +170,10 @@ export function CompanyRail() {
return result;
}, [companyIds, sidebarBadgeQueries]);
// Maintain sorted order in local state, synced from companies + localStorage
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
);
// Re-sync orderedIds from localStorage whenever companies changes.
// Handles initial data load (companies starts as [] before query resolves)
// and subsequent refetches triggered by live updates.
useEffect(() => {
if (sidebarCompanies.length === 0) {
setOrderedIds([]);
return;
}
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
}, [sidebarCompanies]);
// Sync order across tabs via the native storage event
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== ORDER_STORAGE_KEY) return;
try {
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
setOrderedIds(ids);
} catch { /* ignore malformed data */ }
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
// Re-derive when companies change (new company added/removed)
const orderedCompanies = useMemo(() => {
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
const result: Company[] = [];
for (const id of orderedIds) {
const c = byId.get(id);
if (c) {
result.push(c);
byId.delete(id);
}
}
// Append any new companies not yet in our order
for (const c of byId.values()) {
result.push(c);
}
return result;
}, [sidebarCompanies, orderedIds]);
const { orderedCompanies, persistOrder } = useCompanyOrder({
companies: sidebarCompanies,
userId: currentUserId,
});
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
@@ -260,11 +193,9 @@ export function CompanyRail() {
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newIds = arrayMove(ids, oldIndex, newIndex);
setOrderedIds(newIds);
saveOrder(newIds);
persistOrder(arrayMove(ids, oldIndex, newIndex));
},
[orderedCompanies]
[orderedCompanies, persistOrder]
);
return (
+5 -1
View File
@@ -76,7 +76,11 @@ function SortableProjectItem({
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
if (isMobile) setSidebarOpen(false);
}}
className={cn(
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Company } from "@paperclipai/shared";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { queryKeys } from "../lib/queryKeys";
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 sortCompaniesByOrder(companies: Company[], orderedIds: string[]): Company[] {
if (companies.length === 0) return [];
if (orderedIds.length === 0) return companies;
const byId = new Map(companies.map((company) => [company.id, company]));
const sorted: Company[] = [];
for (const id of orderedIds) {
const company = byId.get(id);
if (!company) continue;
sorted.push(company);
byId.delete(id);
}
for (const company of byId.values()) {
sorted.push(company);
}
return sorted;
}
function buildOrderIds(companies: Company[], orderedIds: string[]) {
return sortCompaniesByOrder(companies, orderedIds).map((company) => company.id);
}
type UseCompanyOrderParams = {
companies: Company[];
userId: string | null | undefined;
};
export function useCompanyOrder({ companies, userId }: UseCompanyOrderParams) {
const queryClient = useQueryClient();
const queryKey = useMemo(
() => queryKeys.sidebarPreferences.companyOrder(userId ?? "__anon__"),
[userId],
);
const { data } = useQuery({
queryKey,
queryFn: () => sidebarPreferencesApi.getCompanyOrder(),
enabled: Boolean(userId),
});
const [orderedIds, setOrderedIds] = useState<string[]>(() => buildOrderIds(companies, []));
useEffect(() => {
const nextIds = buildOrderIds(companies, data?.orderedIds ?? []);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [companies, data?.orderedIds]);
const mutation = useMutation({
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateCompanyOrder({ orderedIds: nextIds }),
onSuccess: (preference) => {
queryClient.setQueryData(queryKey, preference);
},
});
const orderedCompanies = useMemo(
() => sortCompaniesByOrder(companies, orderedIds),
[companies, orderedIds],
);
const persistOrder = useCallback(
(ids: string[]) => {
const idSet = new Set(companies.map((company) => company.id));
const filtered = ids.filter((id) => idSet.has(id));
for (const company of companies) {
if (!filtered.includes(company.id)) filtered.push(company.id);
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (!userId) return;
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: filtered,
updatedAt: current?.updatedAt ?? null,
}));
mutation.mutate(filtered);
},
[companies, mutation, queryClient, queryKey, userId],
);
return {
orderedCompanies,
orderedIds,
persistOrder,
};
}
+32 -52
View File
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import {
getProjectOrderStorageKey,
PROJECT_ORDER_UPDATED_EVENT,
readProjectOrder,
sortProjectsByStoredOrder,
writeProjectOrder,
} from "../lib/project-order";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { sortProjectsByStoredOrder } from "../lib/project-order";
import { queryKeys } from "../lib/queryKeys";
type UseProjectOrderParams = {
projects: Project[];
@@ -14,11 +11,6 @@ type UseProjectOrderParams = {
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) {
@@ -32,48 +24,33 @@ function buildOrderIds(projects: Project[], orderedIds: string[]) {
}
export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) {
const storageKey = useMemo(() => {
if (!companyId) return null;
return getProjectOrderStorageKey(companyId, userId);
}, [companyId, userId]);
const queryClient = useQueryClient();
const queryKey = useMemo(
() => queryKeys.sidebarPreferences.projectOrder(companyId ?? "__none__", userId ?? "__anon__"),
[companyId, userId],
);
const { data } = useQuery({
queryKey,
queryFn: () => sidebarPreferencesApi.getProjectOrder(companyId!),
enabled: Boolean(companyId && userId),
});
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (!storageKey) return projects.map((project) => project.id);
return buildOrderIds(projects, readProjectOrder(storageKey));
return buildOrderIds(projects, []);
});
useEffect(() => {
const nextIds = storageKey
? buildOrderIds(projects, readProjectOrder(storageKey))
: projects.map((project) => project.id);
const nextIds = buildOrderIds(projects, data?.orderedIds ?? []);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [projects, storageKey]);
}, [data?.orderedIds, projects]);
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<ProjectOrderUpdatedDetail>).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 mutation = useMutation({
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateProjectOrder(companyId!, { orderedIds: nextIds }),
onSuccess: (preference) => {
queryClient.setQueryData(queryKey, preference);
},
});
const orderedProjects = useMemo(
() => sortProjectsByStoredOrder(projects, orderedIds),
@@ -89,11 +66,15 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (storageKey) {
writeProjectOrder(storageKey, filtered);
}
if (!companyId || !userId) return;
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
orderedIds: filtered,
updatedAt: current?.updatedAt ?? null,
}));
mutation.mutate(filtered);
},
[projects, storageKey],
[companyId, mutation, projects, queryClient, queryKey, userId],
);
return {
@@ -102,4 +83,3 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
persistOrder,
};
}
+5
View File
@@ -95,6 +95,11 @@ export const queryKeys = {
auth: {
session: ["auth", "session"] as const,
},
sidebarPreferences: {
companyOrder: (userId: string) => ["sidebar-preferences", "company-order", userId] as const,
projectOrder: (companyId: string, userId: string) =>
["sidebar-preferences", "project-order", companyId, userId] as const,
},
instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
+4 -4
View File
@@ -13,9 +13,9 @@ import { useToast } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
import { queryKeys } from "../lib/queryKeys";
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
import { MarkdownBody } from "../components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
@@ -346,7 +346,7 @@ function prefixedName(prefix: string | null, originalName: string): string {
return `${prefix}-${originalName}`;
}
function applyImportedSidebarOrder(
async function applyImportedSidebarOrder(
preview: CompanyPortabilityPreviewResult | null,
result: {
company: { id: string };
@@ -381,7 +381,7 @@ function applyImportedSidebarOrder(
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
}
if (orderedProjectIds.length > 0) {
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
await sidebarPreferencesApi.updateProjectOrder(result.company.id, { orderedIds: orderedProjectIds });
}
}
@@ -859,7 +859,7 @@ export function CompanyImport() {
?? refreshedSession?.user?.id
?? refreshedSession?.session?.userId
?? null;
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
await applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
setSelectedCompanyId(importedCompany.id);
pushToast({
tone: "success",