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 (