From dce61f06e154277a4a7f4360ce02bb9c16ced894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Nied=C5=BAwiecki?= Date: Fri, 13 Nov 2026 06:41:13 +0100 Subject: [PATCH] feat(sidebar): pinned threads fold away behind a Pinned header Pinned threads always took the top of the thread list, with no way to put them out of sight while working on something else. The pinned section now has the same collapsible header as Working, Snoozed and Settled on web, desktop and mobile. It is open by default, each device remembers its choice, and the open thread stays listed while the section is folded. Dropping a thread onto the folded section pins it. --- apps/mobile/src/features/home/HomeScreen.tsx | 14 ++++ .../threads/ThreadNavigationSidebar.tsx | 14 ++++ .../features/threads/thread-list-v2-items.tsx | 13 +++- .../src/features/threads/threadListV2.test.ts | 19 +++++ .../src/features/threads/threadListV2.ts | 54 ++++++++++++-- .../use-thread-list-v2-shelf-preferences.ts | 13 ++++ .../features/threads/useThreadListV2.test.tsx | 1 + .../src/features/threads/useThreadListV2.ts | 6 ++ .../src/persistence/mobile-preferences.ts | 5 ++ .../src/components/Sidebar.browser.test.tsx | 53 ++++++++++++++ apps/web/src/components/Sidebar.drag.test.ts | 32 +++++++++ apps/web/src/components/Sidebar.drag.ts | 3 +- apps/web/src/components/Sidebar.tsx | 71 +++++++++++++------ .../components/sidebar/SidebarListMarkers.tsx | 14 ++-- docs/user/thread-sidebar.md | 7 +- 15 files changed, 284 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index af25be18bb46f..3a48d2a97e059 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -72,6 +72,7 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { ThreadListV2PendingRow, ThreadListV2Row, + ThreadListV2PinnedShelfHeader, ThreadListV2SettledShelfHeader, ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, @@ -379,10 +380,12 @@ export function HomeScreen(props: HomeScreenProps) { setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT); const { loaded: shelfPreferencesLoaded, + pinnedShelfExpanded, settledShelfExpanded, snoozedShelfExpanded, workingShelfEnabled, workingShelfExpanded, + togglePinnedShelf, toggleSettledShelf, toggleSnoozedShelf, toggleWorkingShelf, @@ -424,6 +427,7 @@ export function HomeScreen(props: HomeScreenProps) { now, pendingOrder, shelfPreferencesLoaded, + pinnedShelfExpanded, workingShelfEnabled, workingShelfExpanded, snoozedShelfExpanded, @@ -488,6 +492,16 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-pinned-shelf") { + return ( + + ); + } if (item.type === "v2-working-shelf") { return ( count + THREAD_LIST_V2_SETTLED_PAGE_COUNT); const { loaded: shelfPreferencesLoaded, + pinnedShelfExpanded, settledShelfExpanded, snoozedShelfExpanded, workingShelfEnabled, workingShelfExpanded, + togglePinnedShelf, toggleSettledShelf, toggleSnoozedShelf, toggleWorkingShelf, @@ -286,6 +289,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { now, pendingOrder, shelfPreferencesLoaded, + pinnedShelfExpanded, workingShelfEnabled, workingShelfExpanded, snoozedShelfExpanded, @@ -485,6 +489,16 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { /> ); } + case "v2-pinned-shelf": + return ( + + ); case "v2-working-shelf": return ( ; +}); + export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader( props: ThreadListV2ShelfHeaderProps, ) { diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c848e063e2bed..829e66a89abcb 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -458,6 +458,21 @@ describe("buildThreadListV2Items", () => { expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]); expect(layout.items[0]?.snoozed).toBe(true); expect(layout.snoozedCount).toBe(2); + + const pins = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("pin-open"), title: "Pin open", pinnedAt: NOW }), + makeThread({ id: ThreadId.make("pin-other"), title: "Pin other", pinnedAt: NOW }), + makeThread({ id: ThreadId.make("active"), title: "Active" }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + pinnedShelfExpanded: false, + selectedThreadKey: `${environmentId}:pin-open`, + }); + expect(pins.items.map((item) => item.thread.id)).toEqual(["pin-open", "active"]); + expect(pins.pinnedCount).toBe(2); }); it("keeps snoozed threads visible on environments without the snooze capability", () => { @@ -1730,6 +1745,7 @@ describe("Working section beta", () => { it("places the shelf after queued tasks and before snoozed and settled threads", () => { const layout = buildThreadListV2Items({ threads: [ + makeThread({ id: ThreadId.make("pinned"), title: "pinned", pinnedAt: NOW }), makeThread({ id: ThreadId.make("active"), title: "active" }), makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), makeThread({ @@ -1756,6 +1772,7 @@ describe("Working section beta", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [makePendingTask("queued")], + pinnedCount: layout.pinnedCount, workingCount: layout.workingCount, workingShelfExpanded: true, workingShelfHeaderIndex: layout.workingShelfHeaderIndex, @@ -1775,6 +1792,8 @@ describe("Working section beta", () => { ), ).toEqual([ "queued", + "v2-pinned-shelf", + "pinned", "active", "v2-working-shelf", "working", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 7854f9d7fdb85..787233d904d3e 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -365,6 +365,8 @@ export interface ThreadListV2Layout { readonly hiddenSettledCount: number; /** The settled rows the shelf may list, before its visible limit. */ readonly listedSettledThreads: ReadonlyArray; + /** Pinned threads in scope; their rows lead `items`, so the shelf header sits before them. */ + readonly pinnedCount: number; readonly workingCount: number; readonly workingShelfHeaderIndex: number | null; readonly snoozedCount: number; @@ -399,6 +401,14 @@ export interface ThreadListV2PendingListItem { readonly showTrailingDivider: boolean; } +export interface ThreadListV2PinnedShelfListItem { + readonly type: "v2-pinned-shelf"; + readonly key: "v2-pinned-shelf"; + readonly count: number; + readonly expanded: boolean; + readonly disabled: boolean; +} + export interface ThreadListV2WorkingShelfListItem { readonly type: "v2-working-shelf"; readonly key: "v2-working-shelf"; @@ -427,6 +437,7 @@ export interface ThreadListV2SettledShelfListItem { export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem + | ThreadListV2PinnedShelfListItem | ThreadListV2WorkingShelfListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; @@ -437,6 +448,7 @@ export function isThreadListV2ListItem(value: { return ( value.type === "v2-thread" || value.type === "v2-pending" || + value.type === "v2-pinned-shelf" || value.type === "v2-working-shelf" || value.type === "v2-snoozed-shelf" || value.type === "v2-settled-shelf" @@ -474,6 +486,13 @@ export function threadListV2ListItemsAreEqual( previous.showPendingDivider === item.showPendingDivider && previous.showTrailingDivider === item.showTrailingDivider ); + case "v2-pinned-shelf": + return ( + previous.type === "v2-pinned-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); case "v2-working-shelf": return ( previous.type === "v2-working-shelf" && @@ -521,6 +540,8 @@ function resolveThreadListV2ItemTimeLabel( export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly pinnedCount?: number; + readonly pinnedShelfExpanded?: boolean; readonly workingCount?: number; readonly workingShelfExpanded?: boolean; readonly workingShelfHeaderIndex?: number | null; @@ -584,8 +605,18 @@ export function buildThreadListV2ListItems(input: { const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; const activeEnd = workingShelfHeaderIndex ?? workingEnd; - const result: ThreadListV2ListItem[] = [...pendingItems, ...threadItems.slice(0, activeEnd)]; const shelfDisabled = input.shelfPreferencesLoading === true; + const result: ThreadListV2ListItem[] = [...pendingItems]; + if ((input.pinnedCount ?? 0) > 0) { + result.push({ + type: "v2-pinned-shelf", + key: "v2-pinned-shelf", + count: input.pinnedCount ?? 0, + expanded: input.pinnedShelfExpanded !== false, + disabled: shelfDisabled, + }); + } + result.push(...threadItems.slice(0, activeEnd)); if (workingShelfHeaderIndex !== null && workingCount > 0) { result.push({ type: "v2-working-shelf", @@ -663,6 +694,8 @@ export function buildThreadListV2Items(input: { readonly snoozeEnvironmentIds?: ReadonlySet; readonly settledLimit?: number; readonly now: string; + /** Pins stay listed unless the shelf is explicitly collapsed. */ + readonly pinnedShelfExpanded?: boolean; readonly workingShelfEnabled?: boolean; readonly workingShelfExpanded?: boolean; readonly isWorking?: (thread: EnvironmentThreadShell) => boolean; @@ -788,6 +821,7 @@ export function buildThreadListV2Items(input: { })), hiddenSettledCount: 0, listedSettledThreads: [], + pinnedCount: 0, workingCount: 0, workingShelfHeaderIndex: null, snoozedCount: 0, @@ -808,6 +842,17 @@ export function buildThreadListV2Items(input: { parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), ); const selectedThreadKey = input.selectedThreadKey ?? null; + const orderedPinned = applyPendingThreadOrder( + sortPinnedThreadsByOrderKey(pinned), + "pinned", + pending, + ); + const visiblePinned = + input.pinnedShelfExpanded !== false + ? orderedPinned + : orderedPinned.filter( + (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, + ); const visibleWorking = input.workingShelfExpanded === true ? orderedWorking @@ -846,11 +891,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of applyPendingThreadOrder( - sortPinnedThreadsByOrderKey(pinned), - "pinned", - pending, - )) { + for (const thread of visiblePinned) { items.push({ thread, variant: "card", @@ -911,6 +952,7 @@ export function buildThreadListV2Items(input: { items, hiddenSettledCount: Math.max(0, settledTotal - pagedSettled.length), listedSettledThreads: listedSettled, + pinnedCount: orderedPinned.length, workingCount: orderedWorking.length, workingShelfHeaderIndex, snoozedCount: orderedSnoozed.length, diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index 4348908e5371b..a496db3bcb05b 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -7,6 +7,9 @@ export function useThreadListV2ShelfPreferences() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); + // Pins stay listed until the stored choice says otherwise, so they do not pop in after loading. + const pinnedShelfExpanded = + !loaded || preferencesResult.value.threadListPinnedShelfExpanded !== false; const snoozedShelfExpanded = loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; const settledShelfExpanded = @@ -16,6 +19,14 @@ export function useThreadListV2ShelfPreferences() { loaded && preferencesResult.value.threadListWorkingShelfExpanded === true; // Transforms read optimistic state so same-frame presses toggle twice. + const togglePinnedShelf = () => { + if (!loaded) return; + savePreferences({ + transform: (current) => ({ + threadListPinnedShelfExpanded: current.threadListPinnedShelfExpanded === false, + }), + }); + }; const toggleSnoozedShelf = () => { if (!loaded) return; savePreferences({ @@ -43,10 +54,12 @@ export function useThreadListV2ShelfPreferences() { return { loaded, + pinnedShelfExpanded, settledShelfExpanded, snoozedShelfExpanded, workingShelfEnabled, workingShelfExpanded, + togglePinnedShelf, toggleSettledShelf, toggleSnoozedShelf, toggleWorkingShelf, diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa14..74cc3e65b88b7 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -55,6 +55,7 @@ it("reuses a large thread list across tab renders and updates when its inputs ch now: "2026-01-02T00:00:00.000Z", pendingOrder: null, shelfPreferencesLoaded: true, + pinnedShelfExpanded: true, workingShelfEnabled: true, workingShelfExpanded: false, snoozedShelfExpanded: false, diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index fcf1048d2b6af..9f42c30583e6e 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -60,6 +60,7 @@ export function useThreadListV2(input: { readonly now: string; readonly pendingOrder: LayoutInput["pendingOrder"]; readonly shelfPreferencesLoaded: boolean; + readonly pinnedShelfExpanded: boolean; readonly workingShelfEnabled: boolean; readonly workingShelfExpanded: boolean; readonly snoozedShelfExpanded: boolean; @@ -145,6 +146,7 @@ export function useThreadListV2(input: { queuedThreadKeys: input.queuedThreadKeys, settledLimit: input.settledLimit, now: layoutTime, + pinnedShelfExpanded: input.pinnedShelfExpanded, workingShelfEnabled, workingShelfExpanded: input.workingShelfExpanded, inboxReturnAt: inbox.returnedAt, @@ -177,6 +179,7 @@ export function useThreadListV2(input: { input.queuedThreadKeys, input.settledLimit, layoutTime, + input.pinnedShelfExpanded, workingShelfEnabled, input.workingShelfExpanded, inbox.returnedAt, @@ -214,6 +217,8 @@ export function useThreadListV2(input: { buildThreadListV2ListItems({ items: layout.items, pendingTasks, + pinnedCount: layout.pinnedCount, + pinnedShelfExpanded: input.pinnedShelfExpanded, workingCount: layout.workingCount, workingShelfExpanded: input.workingShelfExpanded, workingShelfHeaderIndex: layout.workingShelfHeaderIndex, @@ -232,6 +237,7 @@ export function useThreadListV2(input: { [ layout, pendingTasks, + input.pinnedShelfExpanded, input.workingShelfExpanded, input.snoozedShelfExpanded, input.settledShelfExpanded, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index f29fbc8c119b4..7c7402da0b760 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -126,6 +126,7 @@ export interface Preferences { readonly provider: ProviderInstanceId; readonly model: string; }>; + readonly threadListPinnedShelfExpanded?: boolean; readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; readonly threadListWorkingShelfExpanded?: boolean; @@ -209,6 +210,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { useLastProjectForNewThreads?: boolean; lastThreadProject?: typeof ThreadProject.Type; modelFavorites?: Preferences["modelFavorites"]; + threadListPinnedShelfExpanded?: boolean; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; threadListWorkingShelfExpanded?: boolean; @@ -348,6 +350,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { favorite.model.trim().length > 0, ); } + if (typeof parsed.threadListPinnedShelfExpanded === "boolean") { + preferences.threadListPinnedShelfExpanded = parsed.threadListPinnedShelfExpanded; + } if (typeof parsed.threadListSettledShelfExpanded === "boolean") { preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index f152cabf01851..2084e13cc68c3 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -351,3 +351,56 @@ describe("sidebar settled pagination", () => { expect(screen.container.querySelectorAll("[data-thread-item]")).toHaveLength(loadedRows); }); }); + +describe("sidebar pinned shelf", () => { + it("folds pins behind the Pinned header and keeps the open thread", async () => { + using stack = new DisposableStack(); + stack.defer(() => localStorage.removeItem("t3code:sidebar:pinned-expanded")); + const activeProject = project("active"); + const threads = [1, 2, 3].map((index) => + makeThreadFixture({ + environmentId: activeProject.environmentId, + projectId: activeProject.id, + id: ThreadId.make(`thread-${index}`), + title: `Thread ${index}`, + pinnedAt: index < 3 ? "2026-01-01T00:00:00.000Z" : null, + }), + ); + const routeThreadRef: ScopedThreadRef = { + environmentId: activeProject.environmentId, + threadId: threads[0]!.id, + }; + vi.mocked(useProjects).mockReturnValue([activeProject]); + vi.mocked(useQuietThreadShells).mockReturnValue(threads); + vi.mocked(useThreadRouteTarget).mockReturnValue({ kind: "server", threadRef: routeThreadRef }); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: { + environmentId: activeProject.environmentId, + projectId: activeProject.id, + }, + defaultProjectFallbackReady: true, + handleNewThread: vi.fn().mockResolvedValue(null), + routeDraftId: null, + routeThreadRef, + }); + await using screen = await render( + + + , + ); + const shelf = screen.getByTestId("sidebar-pinned-shelf-toggle"); + await expect.element(shelf).toHaveAttribute("aria-expanded", "true"); + await expect.element(screen.getByText("Thread 2").first()).toBeInTheDocument(); + + await userEvent.click(shelf); + await expect.element(shelf).toHaveAttribute("aria-expanded", "false"); + await expect.element(screen.getByText("Thread 2").first()).not.toBeInTheDocument(); + await expect.element(screen.getByText("Thread 1").first()).toBeInTheDocument(); + await expect.element(screen.getByText("Thread 3").first()).toBeInTheDocument(); + + await userEvent.click(shelf); + await expect.element(screen.getByText("Thread 2").first()).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index e1febcd68d533..fd9ec874321f6 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -377,6 +377,38 @@ describe("sidebar drag projection", () => { expect(result.get("s")?.y).toBe(32); }); + it("keeps pins in place under the Pinned shelf header, which already has its own row", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const args = layout(items, "a2", "a1"); + args.rects[0]!.height = 32; + args.rects[0]!.bottom += 32; + for (const rect of args.rects.slice(1)) { + rect.top += 32; + rect.bottom += 32; + } + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + boundaryLabelHeight: 16, + }); + const offset = (id: string) => + strategy({ ...args, index: items.findIndex((item) => sidebarListItemId(item) === id) }); + expect(offset(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(offset("p")).toEqual(stationary); + expect(offset(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + expect(offset("a1")?.y).toBe(16 + 83); + expect(offset(sidebarMarkerId("settled-header"))?.y).toBe(16); + }); + it.each(["s1", "missing-target"])( "keeps label clearance when a settled drag is over %s", (over) => { diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 76f2c9bf3a3ec..e9e2e02f9cb46 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -201,7 +201,8 @@ export function createSidebarSortingStrategy(input: { const moved = item.kind === "thread" && item.key === active.key; return item.kind === "marker" && (item.marker === "pinned-header" || item.marker === "pinned-divider") - ? labelHeight + ? // The Pinned shelf header is a row of its own; a boundary with no box opens label clearance. + rect?.height || labelHeight : item.kind === "marker" && item.marker.endsWith("placeholder") ? slimHeight : moved diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a94b9e9497c6b..e035294457849 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -330,6 +330,7 @@ const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +const PINNED_SHELF_EXPANDED_KEY = "t3code:sidebar:pinned-expanded"; const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; @@ -450,6 +451,11 @@ function useSidebarThreads(input: { const nowMinute = useNowMinute(); // Minute-quantized clocks would delay second-precise snooze wakes by up to a minute. const snoozeWakeClock = useSnoozeWakeClock(threads); + const [pinnedShelfExpanded, setPinnedShelfExpanded] = useLocalStorage( + PINNED_SHELF_EXPANDED_KEY, + true, + Schema.Boolean, + ); const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, false, @@ -524,6 +530,9 @@ function useSidebarThreads(input: { const visibleWorkingThreads = workingShelfExpanded ? workingThreads : routeThreadOnly(workingThreads, routeRowThreadKey); + const visiblePinnedThreads = pinnedShelfExpanded + ? pinnedThreads + : routeThreadOnly(pinnedThreads, routeRowThreadKey); const projectFolders = folderGroups === null ? null @@ -545,7 +554,7 @@ function useSidebarThreads(input: { }; }); const orderedThreads = [ - ...pinnedThreads, + ...visiblePinnedThreads, ...(projectFolders === null ? activeThreads : projectFolders.flatMap((folder) => folder.threads)), @@ -583,6 +592,8 @@ function useSidebarThreads(input: { scopeKey: current.scopeKey, count: current.count + SETTLED_TAIL_PAGE_COUNT, })), + pinnedShelfExpanded, + togglePinnedShelf: () => setPinnedShelfExpanded((value) => !value), settledShelfExpanded, toggleSettledShelf: () => setSettledShelfExpanded((value) => !value), snoozedShelfExpanded, @@ -597,12 +608,14 @@ function useSidebarThreads(input: { renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, + visiblePinnedThreads, orderedThreadKeys: orderedThreads.map(threadShellKey), threadByKey: new Map(orderedThreads.map((thread) => [threadShellKey(thread), thread] as const)), settledThreadKeys: new Set(settledThreads.map(threadShellKey)), snoozedThreadKeys: new Set(snoozedThreads.map(threadShellKey)), sectionByThreadKey, - pinnedKeys: pinnedThreads.map(threadShellKey), + // A drop orders only the rows it can see, so a collapsed shelf counts the open thread alone. + pinnedKeys: visiblePinnedThreads.map(threadShellKey), activeKeys: activeThreads.map(threadShellKey), pinnedKeysById: new Map( threads.map((thread) => [threadShellKey(thread), thread.pinOrderKey ?? null] as const), @@ -1331,6 +1344,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve olderSettledShelf, hiddenSettledCount, showMoreSettled, + pinnedShelfExpanded, + togglePinnedShelf, settledShelfExpanded, toggleSettledShelf, snoozedShelfExpanded, @@ -1342,6 +1357,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, + visiblePinnedThreads, orderedThreadKeys, threadByKey, settledThreadKeys, @@ -1934,8 +1950,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (list && header) { const listRect = list.getBoundingClientRect(); const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; + const headerRect = header.getBoundingClientRect(); + // The Pinned shelf header takes its own row; without pins it is a label drawn over the list. dragLabelOffsetRef.current = - header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; + headerRect.top - listRect.top + (headerRect.height || SIDEBAR_DRAG_LABEL_HEIGHT * scale); } else { dragLabelOffsetRef.current = 0; } @@ -1972,7 +1990,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return []; } const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; - const pinnedRows = rowsOf(pinnedThreads, "pinned"); + const pinnedRows = rowsOf(visiblePinnedThreads, "pinned"); items.push(...pinnedRows); items.push({ kind: "marker", marker: "pinned-divider" }); const activeRows = rowsOf(activeThreads, "active"); @@ -2027,9 +2045,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve dragState === null && sidebarListItems.length + visibleDraftSessionCount > 0; // Cache on both fields so layout-key changes trigger the effect even when animate is unchanged. const listMotionPass = { layoutKey: listMotionLayoutKey, animate: listMotionAnimate }; - const shelfExpansionKey = [workingShelfExpanded, snoozedShelfExpanded, settledShelfExpanded].join( - ":", - ); + const shelfExpansionKey = [ + pinnedShelfExpanded, + workingShelfExpanded, + snoozedShelfExpanded, + settledShelfExpanded, + ].join(":"); const previousShelfExpansionKeyRef = useRef(shelfExpansionKey); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records moved rows and updates list motion after each layout-affecting commit useLayoutEffect(() => { @@ -3399,12 +3420,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve thread={thread} variant={rowVariant} navigationParent={ - folderRow ?? - (section === "working" || - section === "snoozed" || - section === "settled" - ? `shelf:${section}` - : undefined) + folderRow ?? (section === "active" ? undefined : `shelf:${section}`) } variantAction={ section === "snoozed" @@ -3534,13 +3550,28 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve switch (item.marker) { case "pinned-header": items.push( - , + pinnedThreads.length > 0 ? ( + + ) : ( + + ), ); break; case "pinned-divider": diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index da9444a5c1b24..217c3ab3f4a80 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -111,7 +111,7 @@ export function SidebarDragBoundary(props: { } export function SidebarSectionHeader(props: { - marker: "working-header" | "snoozed-header" | "settled-header"; + marker: "pinned-header" | "working-header" | "snoozed-header" | "settled-header"; label: string; /** A muted trailing count. */ count: number; @@ -121,11 +121,13 @@ export function SidebarSectionHeader(props: { toggle: { expanded: boolean; onToggle: () => void }; }) { const shelf = - props.marker === "working-header" - ? "working" - : props.marker === "snoozed-header" - ? "snoozed" - : "settled"; + props.marker === "pinned-header" + ? "pinned" + : props.marker === "working-header" + ? "working" + : props.marker === "snoozed-header" + ? "snoozed" + : "settled"; return (