Files
paseo/packages/app/src/hooks/use-sidebar-pins.ts
T
Mohamed BoudraandGitHub b03dc804eb feat(app): make pinned workspaces sortable (#3341)
Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.
2026-08-13 23:48:01 +02:00

152 lines
5.2 KiB
TypeScript

import { useMemo, useRef } from "react";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type {
SidebarProjectEntry,
SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model";
import { useSessionStore } from "@/stores/session-store";
export interface PinnedSidebarKeys {
pinnedWorkspaceKeys: string[];
// workspaceKey -> pinnedAt ISO string, used to order by recency.
pinnedAtByKey: Record<string, string>;
}
export interface PinnedSidebarGroups {
// Individually pinned chats, hoisted into the Pinned section and removed from their
// project below. Most recently pinned first.
pinnedChats: SidebarWorkspacePlacement[];
// Everything else, with pinned chats removed. Feeds the draggable project list.
unpinnedProjects: SidebarProjectEntry[];
}
function buildPinnedSidebarKeys(
projects: SidebarProjectEntry[],
workspaceMaps: ReadonlyMap<string, ReadonlyMap<string, { pinnedAt?: string | null }>>,
): PinnedSidebarKeys {
const pinnedWorkspaceKeys: string[] = [];
const pinnedAtByKey: Record<string, string> = {};
for (const project of projects) {
for (const placement of project.workspaces) {
const workspace = workspaceMaps.get(placement.serverId)?.get(placement.workspaceId);
if (workspace?.pinnedAt) {
pinnedWorkspaceKeys.push(placement.workspaceKey);
pinnedAtByKey[placement.workspaceKey] = workspace.pinnedAt;
}
}
}
return { pinnedWorkspaceKeys, pinnedAtByKey };
}
function arePinnedSidebarKeysEqual(left: PinnedSidebarKeys, right: PinnedSidebarKeys): boolean {
if (left.pinnedWorkspaceKeys.length !== right.pinnedWorkspaceKeys.length) {
return false;
}
for (let index = 0; index < left.pinnedWorkspaceKeys.length; index += 1) {
const workspaceKey = left.pinnedWorkspaceKeys[index];
if (
workspaceKey !== right.pinnedWorkspaceKeys[index] ||
(workspaceKey && left.pinnedAtByKey[workspaceKey] !== right.pinnedAtByKey[workspaceKey])
) {
return false;
}
}
return true;
}
export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSidebarKeys {
const previousKeysRef = useRef<PinnedSidebarKeys>({
pinnedWorkspaceKeys: [],
pinnedAtByKey: {},
});
const serverIds = useMemo(
() =>
Array.from(
new Set(
projects.flatMap((project) => project.workspaces.map((workspace) => workspace.serverId)),
),
),
[projects],
);
const workspaceMaps = useStoreWithEqualityFn(
useSessionStore,
(state) => serverIds.map((serverId) => state.sessions[serverId]?.workspaces ?? null),
shallow,
);
return useMemo(() => {
const workspaceMapByServerId = new Map<
string,
ReadonlyMap<string, { pinnedAt?: string | null }>
>();
for (let index = 0; index < serverIds.length; index += 1) {
const serverId = serverIds[index];
const workspaceMap = workspaceMaps[index];
if (serverId && workspaceMap) {
workspaceMapByServerId.set(serverId, workspaceMap);
}
}
const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId);
if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) {
return previousKeysRef.current;
}
previousKeysRef.current = nextKeys;
return nextKeys;
}, [projects, serverIds, workspaceMaps]);
}
// Splits the sidebar into a dedicated Pinned section (chats) and the regular list below.
// Pinned chats are ordered most-recently-pinned first.
export function splitPinnedSidebarGroups(input: {
projects: SidebarProjectEntry[];
keys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
}): PinnedSidebarGroups {
const { projects, keys, pinnedWorkspaceOrder } = input;
if (keys.pinnedWorkspaceKeys.length === 0) {
return { pinnedChats: [], unpinnedProjects: projects };
}
const pinnedWorkspaceKeySet = new Set(keys.pinnedWorkspaceKeys);
const pinnedChats: SidebarWorkspacePlacement[] = [];
const unpinnedProjects: SidebarProjectEntry[] = [];
for (const project of projects) {
const remainingWorkspaces: SidebarWorkspacePlacement[] = [];
for (const workspace of project.workspaces) {
if (pinnedWorkspaceKeySet.has(workspace.workspaceKey)) {
pinnedChats.push(workspace);
} else {
remainingWorkspaces.push(workspace);
}
}
// Every chat got hoisted into the Pinned section: drop the empty shell instead of
// leaving a duplicate project header below. A genuinely empty project (no chats to
// begin with) is kept so its "new workspace" row stays reachable.
if (remainingWorkspaces.length === 0 && project.workspaces.length > 0) {
continue;
}
unpinnedProjects.push(
remainingWorkspaces.length === project.workspaces.length
? project
: { ...project, workspaces: remainingWorkspaces },
);
}
pinnedChats.sort((a, b) =>
(keys.pinnedAtByKey[b.workspaceKey] ?? "").localeCompare(
keys.pinnedAtByKey[a.workspaceKey] ?? "",
),
);
return {
pinnedChats: applyStoredOrdering({
items: pinnedChats,
storedOrder: pinnedWorkspaceOrder,
getKey: (workspace) => workspace.workspaceKey,
}),
unpinnedProjects,
};
}