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 (