From b1279705e549c4ba2254fc1ef23a7d80f1b108f8 Mon Sep 17 00:00:00 2001 From: Iliya Lyan <68940374+12ya@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:17:00 +0900 Subject: [PATCH 01/13] feat(sidebar): pin chats within projects Persist pinned thread keys per logical project, keep pinned chats above the project's normal sort order, and separate pinned chats with a compact divider. --- apps/web/src/components/Sidebar.logic.test.ts | 13 ++ apps/web/src/components/Sidebar.logic.ts | 13 ++ apps/web/src/components/Sidebar.tsx | 119 ++++++++++++------ apps/web/src/uiStateStore.test.ts | 22 ++++ apps/web/src/uiStateStore.ts | 37 ++++++ 5 files changed, 168 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..f2f6b70aeb6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { isContextMenuPointerDown, isTrailingDoubleClick, orderItemsByPreferredIds, + prioritizeThreadsByPinnedKeys, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -37,6 +38,18 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("prioritizeThreadsByPinnedKeys", () => { + it("moves only pinned threads to the front while preserving the current sort order", () => { + const threads = [{ id: "newest" }, { id: "pinned" }, { id: "oldest" }]; + + expect(prioritizeThreadsByPinnedKeys(threads, ["pinned"], (thread) => thread.id)).toEqual([ + { id: "pinned" }, + { id: "newest" }, + { id: "oldest" }, + ]); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..1ebea4a391b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -26,6 +26,19 @@ type SidebarProject = { export type ThreadTraversalDirection = "previous" | "next"; +export function prioritizeThreadsByPinnedKeys( + threads: readonly T[], + pinnedKeys: readonly string[], + getKey: (thread: T) => string, +): T[] { + if (pinnedKeys.length === 0) return [...threads]; + const pinned = new Set(pinnedKeys); + return [ + ...threads.filter((thread) => pinned.has(getKey(thread))), + ...threads.filter((thread) => !pinned.has(getKey(thread))), + ]; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..7a28f88256e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -195,6 +195,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + prioritizeThreadsByPinnedKeys, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, @@ -898,6 +899,7 @@ interface SidebarProjectThreadListProps { projectCwd: string; activeRouteThreadKey: string | null; threadJumpLabelByKey: ReadonlyMap; + pinnedThreadKeys: readonly string[]; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; @@ -949,6 +951,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( projectCwd, activeRouteThreadKey, threadJumpLabelByKey, + pinnedThreadKeys, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, @@ -991,36 +994,53 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( ) : null} {shouldShowThreadPanel && - renderedThreads.map((thread) => { + renderedThreads.map((thread, index) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const showPinnedSeparator = + index > 0 && + pinnedThreadKeys.includes( + scopedThreadKey( + scopeThreadRef( + renderedThreads[index - 1]!.environmentId, + renderedThreads[index - 1]!.id, + ), + ), + ) && + !pinnedThreadKeys.includes(threadKey); return ( - + + {showPinnedSeparator && ( +