From 4dbf7038eed89a42b146a5356ae96ee2b3aaae4c Mon Sep 17 00:00:00 2001 From: pandec Date: Fri, 21 Aug 2026 11:48:18 +0200 Subject: [PATCH 1/3] feat(sidebar): pin quick-action, icon-only Settle, collapsible pinned section Thread card hover actions lead with a pin/unpin toggle (filled pin while pinned) and the Settle button drops its text label. The pinned block gets a collapsible shelf header on web and mobile, expanded by default and remembered per device, with the open thread kept visible while folded. Implemented by Claude Fable 5 via Claude Code. --- README.md | 2 +- apps/mobile/src/features/home/HomeScreen.tsx | 19 +++ .../threads/ThreadNavigationSidebar.tsx | 23 ++++ .../features/threads/thread-list-v2-items.tsx | 43 ++++++- .../src/features/threads/threadListV2.test.ts | 46 ++++++- .../src/features/threads/threadListV2.ts | 51 +++++++- .../src/persistence/mobile-preferences.ts | 5 + .../src/state/thread-shelf-expansion.ts | 9 +- apps/web/src/components/Sidebar.tsx | 118 ++++++++++++++++-- docs/user/thread-sidebar.md | 11 +- 10 files changed, 299 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 121fb6ef0c22..c3912e0b1517 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Per-project default model** — Project Settings can pin the model that new threads pre-select (a selection carried from a thread you're viewing still wins within the same project). Picking a model in an existing thread changes that thread only, without rewriting the project default. - **Composer thread commands** — `/t3-rename` (prefilled with the current title, on web and mobile) and `/t3-status` to set a thread's status emoji. - **Saved prompt library** — reusable prompts (title + content) managed in Settings → Prompts and synced across connected environments with whole-library last-write-wins, including catch-up for environments that were offline during an edit. `/prompt` in the composer opens a filterable picker (titles and content previews) that inserts the prompt at the cursor without sending; the command palette's **Prompts...** submenu copies the selected prompt on Enter and inserts it into the composer on mod+Enter when one is open. -- **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, manual “Move to top” actions reflected across connected clients plus an opt-in latest-user-message order (mobile has its own device-local switch for the same ordering), an opt-in placement for the New thread button beside New project, per-project color accents shared across connected machines and shown in mobile thread lists (cached on-device so a cold mobile launch paints them instead of flashing them in as environments connect), accent tinting for web thread rows and new-thread project choices plus per-device mobile tinting (Settings → Appearance) — off keeps the accent as a project-header dot on both — one-click project-filter clearing, and a setting to show each thread's provider icon on hover, always, or never. Mobile now remembers where you left each section of the thread list: Older, Snoozed, Settled, and Archived keep their fold state per device across relaunches, the way the web sidebar always has. +- **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, manual “Move to top” actions reflected across connected clients plus an opt-in latest-user-message order (mobile has its own device-local switch for the same ordering), an opt-in placement for the New thread button beside New project, per-project color accents shared across connected machines and shown in mobile thread lists (cached on-device so a cold mobile launch paints them instead of flashing them in as environments connect), accent tinting for web thread rows and new-thread project choices plus per-device mobile tinting (Settings → Appearance) — off keeps the accent as a project-header dot on both — one-click project-filter clearing, and a setting to show each thread's provider icon on hover, always, or never. Thread cards' hover actions now lead with a pin/unpin toggle (filled pin while pinned), and the Settle button is just the checkmark. The pinned block folds behind its own header like the other sections, on web and mobile alike, and the Pinned, Older, Snoozed, Settled, and Archived sections all keep their fold state per device across relaunches. - **Older thread section** — an opt-in foldable **Older** section (Settings → Extras → Sidebar on web and desktop, Settings → General on mobile) collects threads that have gone quiet for longer than a configurable window, seven days by default. It is a grouping, not a lifecycle state: nothing is settled, snoozed, or archived, the rows stay full cards with their usual actions, and a message or a Move to top returns a thread to the list immediately. Pinned, snoozed, and settled threads keep their own sections, live and waiting work is never folded away (a running session, background work outliving its turn, an approval or input request, an undecided plan, or a thread that just came back from a snooze), and the section steps aside entirely while the Attention filter is on. The shelf's starting fold state is a setting; folding or unfolding it by hand overrides that per device on every client, and the thread you have open keeps its row even while the section is folded. Mobile stores the section's three settings per device and offers the window as a scale of stops rather than web's free-form day count. - **Thread actions beyond the sidebar row** — acting on the thread you're reading never means finding its row first. The chat header title (click it, or right-click the breadcrumb) opens a menu with Move to top, Fork conversation, Archive thread, and Copy thread ID, and the web command palette (⌘K) carries the full set: Archive, Move to top, Pin/Unpin, Settle/Un-settle, Fork, and Copy thread ID. Each lifecycle pair shows the one verb that applies, resolved from the same settled and pinned state as the sidebar row menu (so the three surfaces never disagree); Settle stays visible but disabled while the thread still has running or queued work, Pin and Settle appear only on environments advertising those capabilities, and Fork is withheld on archived threads the server would refuse. The palette also gains **Open archived threads** and a project-scoped **Open archived threads in <project>**, which open the archived settings page with that project's filter pre-applied. - **Conversation navigation rails** — desktop threads keep the existing left-side user-message navigator and add a mirrored right-side navigator for completed agent responses, with hover previews and click-to-jump navigation. A reading position you scrolled to yourself is kept as the turn streams in, rather than being yanked back by delayed anchoring. diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index aa2c4243028f..8f506410114e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -59,6 +59,7 @@ import { import { ThreadListV2PendingRow, ThreadListV2PinnedDivider, + ThreadListV2PinnedShelfHeader, ThreadListV2Row, ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, @@ -705,6 +706,8 @@ export function HomeScreen(props: HomeScreenProps) { useThreadShelfExpansion("snoozed"); const { expanded: settledShelfExpanded, toggle: toggleSettledShelf } = useThreadShelfExpansion("settled"); + const { expanded: pinnedShelfExpanded, toggle: togglePinnedShelf } = + useThreadShelfExpansion("pinned"); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -789,6 +792,7 @@ export function HomeScreen(props: HomeScreenProps) { return { items: [], hiddenSettledCount: 0, + pinnedCount: 0, olderCount: 0, olderShelfHeaderIndex: null, snoozedCount: 0, @@ -822,6 +826,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeNow: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, + pinnedShelfExpanded, selectedThreadKey: null, }); }, [ @@ -837,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, snoozedShelfExpanded, settledShelfExpanded, + pinnedShelfExpanded, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -907,6 +913,8 @@ export function HomeScreen(props: HomeScreenProps) { buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + pinnedCount: threadListV2Layout.pinnedCount, + pinnedShelfExpanded, olderCount: threadListV2Layout.olderCount, olderShelfExpanded, olderShelfHeaderIndex: threadListV2Layout.olderShelfHeaderIndex, @@ -920,6 +928,7 @@ export function HomeScreen(props: HomeScreenProps) { }), [ olderShelfExpanded, + pinnedShelfExpanded, settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, @@ -957,6 +966,15 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-pinned-shelf") { + return ( + + ); + } if (item.type === "v2-pinned-divider") { return ; } @@ -1084,6 +1102,7 @@ export function HomeScreen(props: HomeScreenProps) { threadSearchMatchByKey, titleRegenerationEnvironmentIds, toggleOlderShelf, + togglePinnedShelf, toggleSettledShelf, toggleSnoozedShelf, v2ProjectTitleByProjectKey, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e148658d983d..10f873dcc266 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -93,6 +93,7 @@ import { import { ThreadListV2PendingRow, ThreadListV2PinnedDivider, + ThreadListV2PinnedShelfHeader, ThreadListV2Row, ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, @@ -620,6 +621,8 @@ function ThreadNavigationSidebarPane( useThreadShelfExpansion("snoozed"); const { expanded: settledShelfExpanded, toggle: toggleSettledShelf } = useThreadShelfExpansion("settled"); + const { expanded: pinnedShelfExpanded, toggle: togglePinnedShelf } = + useThreadShelfExpansion("pinned"); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -700,6 +703,7 @@ function ThreadNavigationSidebarPane( return { items: [], hiddenSettledCount: 0, + pinnedCount: 0, olderCount: 0, olderShelfHeaderIndex: null, snoozedCount: 0, @@ -731,6 +735,7 @@ function ThreadNavigationSidebarPane( snoozeNow: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, + pinnedShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ @@ -747,6 +752,7 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, snoozedShelfExpanded, settledShelfExpanded, + pinnedShelfExpanded, props.selectedThreadKey, options.selectedEnvironmentId, options.selectedModel, @@ -804,6 +810,8 @@ function ThreadNavigationSidebarPane( const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + pinnedCount: threadListV2Layout.pinnedCount, + pinnedShelfExpanded, olderCount: threadListV2Layout.olderCount, olderShelfExpanded, olderShelfHeaderIndex: threadListV2Layout.olderShelfHeaderIndex, @@ -833,6 +841,7 @@ function ThreadNavigationSidebarPane( props.searchQuery, selectedProjectRefs, olderShelfExpanded, + pinnedShelfExpanded, settledShelfExpanded, snoozedShelfExpanded, threadListV2Enabled, @@ -1124,6 +1133,9 @@ function ThreadNavigationSidebarPane( if (previous.type === "v2-pinned-divider" || item.type === "v2-pinned-divider") { return previous.type === item.type; } + if (previous.type === "v2-pinned-shelf" && item.type === "v2-pinned-shelf") { + return previous.count === item.count && previous.expanded === item.expanded; + } if (previous.type === "v2-older-shelf" && item.type === "v2-older-shelf") { return previous.count === item.count && previous.expanded === item.expanded; } @@ -1137,12 +1149,14 @@ function ThreadNavigationSidebarPane( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending" || + previous.type === "v2-pinned-shelf" || previous.type === "v2-older-shelf" || previous.type === "v2-snoozed-shelf" || previous.type === "v2-settled-shelf" || item.type === "v2-thread" || item.type === "v2-show-more" || item.type === "v2-pending" || + item.type === "v2-pinned-shelf" || item.type === "v2-older-shelf" || item.type === "v2-snoozed-shelf" || item.type === "v2-settled-shelf" @@ -1267,6 +1281,15 @@ function ThreadNavigationSidebarPane( /> ); } + case "v2-pinned-shelf": + return ( + + ); case "v2-pinned-divider": return ; case "v2-older-shelf": diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index af316438999c..6a9e0fa354be 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -179,9 +179,9 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid }); /** - * Closes the pinned block, matching the web sidebar: a headerless rule with - * more breathing room than the hairline between rows. The pin glyphs already - * say what the block is, so it carries no label. + * Closes the pinned block, matching the web sidebar: a rule with more + * breathing room than the hairline between rows. The shelf header above + * carries the label, so the divider stays unlabeled. */ export const ThreadListV2PinnedDivider = memo(function ThreadListV2PinnedDivider(props: { readonly pane?: "screen" | "sidebar"; @@ -195,6 +195,43 @@ export const ThreadListV2PinnedDivider = memo(function ThreadListV2PinnedDivider ); }); +export const ThreadListV2PinnedShelfHeader = memo(function ThreadListV2PinnedShelfHeader(props: { + readonly count: number; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly pane?: "screen" | "sidebar"; +}) { + const mutedColor = useThemeColor("--color-foreground-muted"); + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + {props.expanded ? "Pinned" : `Pinned (${props.count})`} + + + + + ); +}); + const SNOOZE_ACCENT_LIGHT = "#2563eb"; const SNOOZE_ACCENT_DARK = "#60a5fa"; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 9ddf45e08bd1..50da80ad9dc6 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1161,7 +1161,7 @@ describe("buildThreadListV2ListItems", () => { expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); }); - it("closes the pinned block with a divider above the inbox", () => { + it("opens the pinned block with a shelf header and closes it with a divider", () => { const pinnedLayout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "active" }), @@ -1178,9 +1178,12 @@ describe("buildThreadListV2ListItems", () => { const items = buildThreadListV2ListItems({ items: pinnedLayout.items, pendingTasks: [makePendingTask("queued")], + pinnedCount: pinnedLayout.pinnedCount, + pinnedShelfExpanded: true, }); expect(items.map((item) => item.key)).toEqual([ + "v2-pinned-shelf", `v2-thread:${environmentId}:pinned`, "v2-pinned-divider", `v2-thread:${environmentId}:active`, @@ -1188,6 +1191,47 @@ describe("buildThreadListV2ListItems", () => { ]); }); + it("collapses the pinned shelf to its header, keeping only the open thread", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ + id: ThreadId.make("pinned-a"), + title: "pinned a", + pinnedAt: "2026-06-01T10:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("pinned-b"), + title: "pinned b", + pinnedAt: "2026-06-01T11:00:00.000Z", + }), + ]; + const collapsed = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + pinnedShelfExpanded: false, + selectedThreadKey: `${environmentId}:pinned-a`, + }); + expect(collapsed.pinnedCount).toBe(2); + + const items = buildThreadListV2ListItems({ + items: collapsed.items, + pendingTasks: [], + pinnedCount: collapsed.pinnedCount, + pinnedShelfExpanded: false, + }); + expect(items.map((item) => item.key)).toEqual([ + "v2-pinned-shelf", + `v2-thread:${environmentId}:pinned-a`, + "v2-pinned-divider", + `v2-thread:${environmentId}:active`, + ]); + const header = items[0]; + expect(header?.type === "v2-pinned-shelf" && header.expanded).toBe(false); + expect(header?.type === "v2-pinned-shelf" && header.count).toBe(2); + }); + it("omits the pinned divider when nothing is pinned", () => { const items = buildThreadListV2ListItems({ items: layout.items, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 40f5054d3d68..d3e7f4de24cf 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -289,6 +289,9 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; + /** Pinned threads matching the current filters, including rows hidden by + collapse. */ + readonly pinnedCount: number; /** Threads folded under the Older shelf, including rows hidden by collapse. */ readonly olderCount: number; /** Index in `items` where the Older shelf header belongs. */ @@ -324,13 +327,22 @@ export interface ThreadListV2PendingListItem { readonly showPendingDivider: boolean; } -/** Closes the pinned block, matching the web sidebar's headerless rule. The - pin glyphs carry the meaning, so the divider stays unlabeled. */ +/** Closes the pinned block, matching the web sidebar. The shelf header above + carries the label, so the divider stays unlabeled. */ export interface ThreadListV2PinnedDividerListItem { readonly type: "v2-pinned-divider"; readonly key: "v2-pinned-divider"; } +/** Opens the pinned block: collapsible like the other shelves, expanded by + default. */ +export interface ThreadListV2PinnedShelfListItem { + readonly type: "v2-pinned-shelf"; + readonly key: "v2-pinned-shelf"; + readonly count: number; + readonly expanded: boolean; +} + /** Fork addition: the shelf quiet-but-active threads fold behind. */ export interface ThreadListV2OlderShelfListItem { readonly type: "v2-older-shelf"; @@ -356,6 +368,7 @@ export interface ThreadListV2SettledShelfListItem { export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem + | ThreadListV2PinnedShelfListItem | ThreadListV2PinnedDividerListItem | ThreadListV2OlderShelfListItem | ThreadListV2SnoozedShelfListItem @@ -370,6 +383,8 @@ export type ThreadListV2ListItem = export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly pinnedCount?: number; + readonly pinnedShelfExpanded?: boolean; readonly olderCount?: number; readonly olderShelfExpanded?: boolean; readonly olderShelfHeaderIndex?: number | null; @@ -413,15 +428,28 @@ export function buildThreadListV2ListItems(input: { threadItems.length; const olderEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; - // Pinned rows lead the list; close them with the same headerless rule the - // web sidebar draws, so the inbox reads as its own block. + // Pinned rows lead the list under their own collapsible shelf header; + // close them with the same rule the web sidebar draws, so the inbox reads + // as its own block. The header renders whenever anything is pinned (the + // count is the whole footprint when collapsed); rows only when expanded, + // or for the thread currently open. let pinnedEnd = 0; while (pinnedEnd < activeEnd) { const item = threadItems[pinnedEnd]; if (item?.type !== "v2-thread" || !item.item.pinned) break; pinnedEnd += 1; } - const result: ThreadListV2ListItem[] = threadItems.slice(0, pinnedEnd); + const pinnedCount = input.pinnedCount ?? pinnedEnd; + const result: ThreadListV2ListItem[] = []; + if (pinnedCount > 0) { + result.push({ + type: "v2-pinned-shelf", + key: "v2-pinned-shelf", + count: pinnedCount, + expanded: input.pinnedShelfExpanded !== false, + }); + } + result.push(...threadItems.slice(0, pinnedEnd)); if (pinnedEnd > 0) { result.push({ type: "v2-pinned-divider", key: "v2-pinned-divider" }); } @@ -512,6 +540,8 @@ export function buildThreadListV2Items(input: { readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ readonly settledShelfExpanded?: boolean; + /** Expands the pinned block into rows. Expanded is the default. */ + readonly pinnedShelfExpanded?: boolean; /** The selected thread remains visible on an otherwise collapsed shelf so a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; @@ -669,8 +699,16 @@ export function buildThreadListV2Items(input: { (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); + const orderedPinned = sortPinnedThreadsByOrderKey(pinned); + const visiblePinned = + input.pinnedShelfExpanded !== false + ? orderedPinned + : orderedPinned.filter( + (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, + ); + const items: ThreadListV2Item[] = []; - for (const thread of sortPinnedThreadsByOrderKey(pinned)) { + for (const thread of visiblePinned) { items.push({ thread, variant: "card", @@ -727,6 +765,7 @@ export function buildThreadListV2Items(input: { return { items, hiddenSettledCount: orderedSettled.length - pagedSettled.length, + pinnedCount: orderedPinned.length, olderCount: orderedOlder.length, olderShelfHeaderIndex, snoozedCount: orderedSnoozed.length, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index edb007cd3cda..adc0e8ec0fc5 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -55,6 +55,7 @@ export interface Preferences { * clobber a sibling's state, and absence genuinely means "never toggled" — * see `resolveThreadShelfExpanded` for the defaults each shelf falls back to. */ + readonly sidebarPinnedShelfExpanded?: boolean; readonly sidebarOlderShelfExpanded?: boolean; readonly sidebarSnoozedShelfExpanded?: boolean; readonly sidebarSettledShelfExpanded?: boolean; @@ -149,6 +150,7 @@ export function sanitizePreferences(parsed: Preferences): Preferences { sidebarOlderSectionEnabled?: boolean; sidebarOlderSectionAfterDays?: number; sidebarOlderSectionCollapsedByDefault?: boolean; + sidebarPinnedShelfExpanded?: boolean; sidebarOlderShelfExpanded?: boolean; sidebarSnoozedShelfExpanded?: boolean; sidebarSettledShelfExpanded?: boolean; @@ -241,6 +243,9 @@ export function sanitizePreferences(parsed: Preferences): Preferences { preferences.sidebarOlderSectionCollapsedByDefault = parsed.sidebarOlderSectionCollapsedByDefault; } + if (typeof parsed.sidebarPinnedShelfExpanded === "boolean") { + preferences.sidebarPinnedShelfExpanded = parsed.sidebarPinnedShelfExpanded; + } if (typeof parsed.sidebarOlderShelfExpanded === "boolean") { preferences.sidebarOlderShelfExpanded = parsed.sidebarOlderShelfExpanded; } diff --git a/apps/mobile/src/state/thread-shelf-expansion.ts b/apps/mobile/src/state/thread-shelf-expansion.ts index acb065039d38..2063232e5d7e 100644 --- a/apps/mobile/src/state/thread-shelf-expansion.ts +++ b/apps/mobile/src/state/thread-shelf-expansion.ts @@ -1,9 +1,10 @@ import type { Preferences } from "../persistence/mobile-preferences"; /** The thread list's collapsible shelves, in the order they render. */ -export type ThreadShelfId = "older" | "snoozed" | "settled" | "archived"; +export type ThreadShelfId = "pinned" | "older" | "snoozed" | "settled" | "archived"; const SHELF_PREFERENCE_KEYS = { + pinned: "sidebarPinnedShelfExpanded", older: "sidebarOlderShelfExpanded", snoozed: "sidebarSnoozedShelfExpanded", settled: "sidebarSettledShelfExpanded", @@ -14,8 +15,9 @@ const SHELF_PREFERENCE_KEYS = { * Fold state for one shelf: the stored choice when there is one, otherwise the * shelf's starting state. * - * Older follows its Extras setting, settled opens because recent history is - * the common lookup, and snoozed and archived stay folded — both are work the + * Older follows its Extras setting, pinned and settled open — pinned is the + * block the user curated to the top, and recent history is the common lookup — + * and snoozed and archived stay folded — both are work the * user deliberately put away. A stored value always wins, including while * preferences are still loading: `preferences` is empty until they arrive, so * an untouched shelf never latches the pre-hydration default. @@ -30,6 +32,7 @@ export function resolveThreadShelfExpanded(input: { switch (input.shelf) { case "older": return !input.olderCollapsedByDefault; + case "pinned": case "settled": return true; default: diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 77f6dd107bd0..013943f33857 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -242,6 +242,7 @@ const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; const OLDER_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:older-expanded"; const ARCHIVED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:archived-expanded"; +const PINNED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:pinned-expanded"; function threadTimeLabel(thread: SidebarThreadSummary): string { const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; @@ -819,7 +820,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // in-row pin state cue (the pinned block has no header), so it always // shows while pinned; it only becomes a clickable unpin quick-action once // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. + // the descriptor is not loaded. Pinning lives in the context menu and in + // the card's hover quick-actions (pin/unpin toggle). pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -860,6 +862,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onPin: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; changeRequestSnapshot: ThreadChangeRequestSnapshot | null; @@ -885,6 +888,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onThreadClick, onUnsettle, onUnsnooze, + onPin, onUnpin, openPullRequestsInRightPanel, renamingTitle, @@ -1191,6 +1195,18 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onUnpin, threadRef], ); + const handlePinToggleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (props.isPinned) { + onUnpin(threadRef); + } else { + onPin(threadRef); + } + }, + [onPin, onUnpin, props.isPinned, threadRef], + ); const handleSnoozePreset = useCallback( (preset: SnoozePreset) => { onSnooze(threadRef, preset); @@ -1684,7 +1700,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { threadTimeLabel(thread) )} - {props.settlementSupported || showSnoozeButton || showArchiveButton ? ( + {props.settlementSupported || + props.pinningSupported || + showSnoozeButton || + showArchiveButton ? ( + {props.pinningSupported ? ( + + + + + } + /> + + {props.isPinned ? "Unpin thread" : "Pin thread"} + + + ) : null} {showSnoozeButton ? ( - Settle Settle thread @@ -2848,6 +2887,28 @@ export default function Sidebar() { ), [archivedShelfExpanded, displayedRecentArchive.threads, routeThreadKey], ); + // Pinned is the block the user curated to the top, so it starts expanded; + // folding it is still remembered per device like every other shelf. + const [pinnedShelfExpanded, setPinnedShelfExpanded] = useLocalStorage( + PINNED_SHELF_EXPANDED_KEY, + true, + Schema.Boolean, + ); + const togglePinnedShelf = useCallback( + () => setPinnedShelfExpanded((value) => !value), + [setPinnedShelfExpanded], + ); + // Same exception every other shelf makes: the open thread keeps its row, + // so a folded pinned block never hides the thread being read. + const visiblePinnedThreads = useMemo(() => { + if (pinnedShelfExpanded) return pinnedThreads; + if (routeThreadKey === null) return []; + const routeThread = pinnedThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [pinnedShelfExpanded, pinnedThreads, routeThreadKey]); // The Older shelf's starting state comes from Extras; toggling it writes a // per-device preference that outranks the setting from then on. const [olderShelfExpanded, setOlderShelfExpanded] = useLocalStorage( @@ -2887,14 +2948,14 @@ export default function Sidebar() { const orderedThreads = useMemo( () => [ - ...pinnedThreads, + ...visiblePinnedThreads, ...activeThreads, ...visibleOlderThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, ], [ - pinnedThreads, + visiblePinnedThreads, activeThreads, visibleOlderThreads, visibleSnoozedThreads, @@ -4649,6 +4710,7 @@ export default function Sidebar() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} + onPin={attemptPin} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} @@ -4657,12 +4719,19 @@ export default function Sidebar() { ); }; // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. + // full cards above the inbox, opened by a collapsible + // header and closed by a thin divider. Both vanish + // entirely at count 0. // Pinned rows render in the one shared pinned order; only // reorder-capable rows register as sortable (legacy-server // pins render in place as plain rows). + const renderedPinnedThreads = pinnedShelfExpanded + ? orderedPinnedThreads + : orderedPinnedThreads.filter( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + routeThreadKey, + ); const items: ReactNode[] = [ , pinnedThreads.length > 0 ? ( +
  • + +
  • + ) : null, + renderedPinnedThreads.length > 0 ? (
  • scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ) @@ -4696,7 +4792,7 @@ export default function Sidebar() { aria-label="Pinned threads" className="flex flex-col gap-px" > - {orderedPinnedThreads.map((thread) => { + {renderedPinnedThreads.map((thread) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); @@ -4715,7 +4811,7 @@ export default function Sidebar() {
  • ) : null, ]; - if (pinnedThreads.length > 0) { + if (renderedPinnedThreads.length > 0) { items.push(
  • Date: Fri, 21 Aug 2026 12:35:29 +0200 Subject: [PATCH 2/3] fix(sidebar): pinned collapse steps aside for search and the Attention filter Sol review findings: a collapsed pinned shelf kept hiding rows that an active search or the Attention filter explicitly surfaced. The collapse (and its header) now only applies outside those modes, matching the Older shelf's contract, on web and mobile. Implemented by Claude Fable 5 via Claude Code. --- apps/mobile/src/features/home/HomeScreen.tsx | 2 + .../threads/ThreadNavigationSidebar.tsx | 2 + .../src/features/threads/threadListV2.test.ts | 42 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 23 +++++++++- apps/web/src/components/Sidebar.tsx | 27 +++++++----- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8f506410114e..c556bea24ae8 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -793,6 +793,7 @@ export function HomeScreen(props: HomeScreenProps) { items: [], hiddenSettledCount: 0, pinnedCount: 0, + pinnedShelfHeaderIndex: null, olderCount: 0, olderShelfHeaderIndex: null, snoozedCount: 0, @@ -915,6 +916,7 @@ export function HomeScreen(props: HomeScreenProps) { pendingTasks: v2PendingTasks, pinnedCount: threadListV2Layout.pinnedCount, pinnedShelfExpanded, + pinnedShelfHeaderIndex: threadListV2Layout.pinnedShelfHeaderIndex, olderCount: threadListV2Layout.olderCount, olderShelfExpanded, olderShelfHeaderIndex: threadListV2Layout.olderShelfHeaderIndex, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 10f873dcc266..eb51cfbce8c8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -704,6 +704,7 @@ function ThreadNavigationSidebarPane( items: [], hiddenSettledCount: 0, pinnedCount: 0, + pinnedShelfHeaderIndex: null, olderCount: 0, olderShelfHeaderIndex: null, snoozedCount: 0, @@ -812,6 +813,7 @@ function ThreadNavigationSidebarPane( pendingTasks: v2PendingTasks, pinnedCount: threadListV2Layout.pinnedCount, pinnedShelfExpanded, + pinnedShelfHeaderIndex: threadListV2Layout.pinnedShelfHeaderIndex, olderCount: threadListV2Layout.olderCount, olderShelfExpanded, olderShelfHeaderIndex: threadListV2Layout.olderShelfHeaderIndex, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 50da80ad9dc6..3785b8b56931 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1232,6 +1232,48 @@ describe("buildThreadListV2ListItems", () => { expect(header?.type === "v2-pinned-shelf" && header.count).toBe(2); }); + it("ignores the pinned collapse while searching or the Attention filter is on", () => { + const threads = [ + makeThread({ + id: ThreadId.make("pinned"), + title: "pinned needle", + pinnedAt: "2026-06-01T10:00:00.000Z", + }), + ]; + const searched = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "needle", + now: NOW, + pinnedShelfExpanded: false, + }); + expect(searched.items.map((item) => item.thread.id)).toEqual(["pinned"]); + expect(searched.pinnedShelfHeaderIndex).toBeNull(); + + const attention = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + pinnedShelfExpanded: false, + attentionMemberThreadKeys: new Set([`${environmentId}:pinned`]), + }); + expect(attention.items.map((item) => item.thread.id)).toEqual(["pinned"]); + expect(attention.pinnedShelfHeaderIndex).toBeNull(); + + const items = buildThreadListV2ListItems({ + items: searched.items, + pendingTasks: [], + pinnedCount: searched.pinnedCount, + pinnedShelfExpanded: false, + pinnedShelfHeaderIndex: searched.pinnedShelfHeaderIndex, + }); + expect(items.map((item) => item.key)).toEqual([ + `v2-thread:${environmentId}:pinned`, + "v2-pinned-divider", + ]); + }); + it("omits the pinned divider when nothing is pinned", () => { const items = buildThreadListV2ListItems({ items: layout.items, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index d3e7f4de24cf..403b0b2ce9e7 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -292,6 +292,11 @@ export interface ThreadListV2Layout { /** Pinned threads matching the current filters, including rows hidden by collapse. */ readonly pinnedCount: number; + /** 0 when the pinned shelf header should render, null otherwise. The header + steps aside (and the collapse stops applying) while the Attention filter + or a search is active — folding rows those modes asked for would answer + a different question, the same contract the Older shelf follows. */ + readonly pinnedShelfHeaderIndex: number | null; /** Threads folded under the Older shelf, including rows hidden by collapse. */ readonly olderCount: number; /** Index in `items` where the Older shelf header belongs. */ @@ -385,6 +390,9 @@ export function buildThreadListV2ListItems(input: { readonly pendingTasks: ReadonlyArray; readonly pinnedCount?: number; readonly pinnedShelfExpanded?: boolean; + /** Null hides the pinned shelf header (Attention filter or search active) + while its rows still render. Absent = header whenever rows exist. */ + readonly pinnedShelfHeaderIndex?: number | null; readonly olderCount?: number; readonly olderShelfExpanded?: boolean; readonly olderShelfHeaderIndex?: number | null; @@ -440,8 +448,14 @@ export function buildThreadListV2ListItems(input: { pinnedEnd += 1; } const pinnedCount = input.pinnedCount ?? pinnedEnd; + const pinnedShelfHeaderIndex = + input.pinnedShelfHeaderIndex !== undefined + ? input.pinnedShelfHeaderIndex + : pinnedCount > 0 + ? 0 + : null; const result: ThreadListV2ListItem[] = []; - if (pinnedCount > 0) { + if (pinnedShelfHeaderIndex !== null && pinnedCount > 0) { result.push({ type: "v2-pinned-shelf", key: "v2-pinned-shelf", @@ -700,8 +714,12 @@ export function buildThreadListV2Items(input: { ); const orderedPinned = sortPinnedThreadsByOrderKey(pinned); + // The collapse must never hide rows the Attention filter or a search asked + // for, so the shelf only folds (and only draws its header) outside those + // modes — the same contract the Older shelf follows. + const pinnedShelfCollapsible = input.attentionMemberThreadKeys == null && query.length === 0; const visiblePinned = - input.pinnedShelfExpanded !== false + !pinnedShelfCollapsible || input.pinnedShelfExpanded !== false ? orderedPinned : orderedPinned.filter( (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, @@ -766,6 +784,7 @@ export function buildThreadListV2Items(input: { items, hiddenSettledCount: orderedSettled.length - pagedSettled.length, pinnedCount: orderedPinned.length, + pinnedShelfHeaderIndex: orderedPinned.length > 0 && pinnedShelfCollapsible ? 0 : null, olderCount: orderedOlder.length, olderShelfHeaderIndex, snoozedCount: orderedSnoozed.length, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 013943f33857..9545565d207d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2899,16 +2899,20 @@ export default function Sidebar() { [setPinnedShelfExpanded], ); // Same exception every other shelf makes: the open thread keeps its row, - // so a folded pinned block never hides the thread being read. + // so a folded pinned block never hides the thread being read. The collapse + // also stops applying (and the header steps aside) while the Attention + // filter is on: it already narrowed the list to rows the user asked to + // see, and folding a subset of them away would answer a different + // question — the same contract the Older shelf follows. const visiblePinnedThreads = useMemo(() => { - if (pinnedShelfExpanded) return pinnedThreads; + if (pinnedShelfExpanded || attentionFilterEnabled) return pinnedThreads; if (routeThreadKey === null) return []; const routeThread = pinnedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [routeThread]; - }, [pinnedShelfExpanded, pinnedThreads, routeThreadKey]); + }, [attentionFilterEnabled, pinnedShelfExpanded, pinnedThreads, routeThreadKey]); // The Older shelf's starting state comes from Extras; toggling it writes a // per-device preference that outranks the setting from then on. const [olderShelfExpanded, setOlderShelfExpanded] = useLocalStorage( @@ -4725,13 +4729,14 @@ export default function Sidebar() { // Pinned rows render in the one shared pinned order; only // reorder-capable rows register as sortable (legacy-server // pins render in place as plain rows). - const renderedPinnedThreads = pinnedShelfExpanded - ? orderedPinnedThreads - : orderedPinnedThreads.filter( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - routeThreadKey, - ); + const renderedPinnedThreads = + pinnedShelfExpanded || attentionFilterEnabled + ? orderedPinnedThreads + : orderedPinnedThreads.filter( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + routeThreadKey, + ); const items: ReactNode[] = [ , - pinnedThreads.length > 0 ? ( + pinnedThreads.length > 0 && !attentionFilterEnabled ? (
  • Date: Fri, 21 Aug 2026 12:44:03 +0200 Subject: [PATCH 3/3] refactor(sidebar): apply Opus review findings Drop the in-row unpin button now that the hover toggle owns the action (the glyph stays as a passive pinned marker), fix comments that contradicted the shipped behavior, rename pinnedShelfHeaderIndex to the honest pinnedShelfHeaderVisible boolean, remove dead classes from the icon-only Settle button, note the header spacing rules, and align README and user docs with the search/Attention step-aside (including a stale Older-shelf persistence claim the doc still carried). Implemented by Claude Fable 5 via Claude Code. --- README.md | 2 +- apps/mobile/src/features/home/HomeScreen.tsx | 4 +- .../threads/ThreadNavigationSidebar.tsx | 4 +- .../features/threads/thread-list-v2-items.tsx | 2 + .../src/features/threads/threadListV2.test.ts | 6 +- .../src/features/threads/threadListV2.ts | 38 ++++----- apps/web/src/components/Sidebar.tsx | 84 +++++++------------ docs/user/thread-sidebar.md | 12 +-- 8 files changed, 65 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index c3912e0b1517..9fce0caacb1a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Per-project default model** — Project Settings can pin the model that new threads pre-select (a selection carried from a thread you're viewing still wins within the same project). Picking a model in an existing thread changes that thread only, without rewriting the project default. - **Composer thread commands** — `/t3-rename` (prefilled with the current title, on web and mobile) and `/t3-status` to set a thread's status emoji. - **Saved prompt library** — reusable prompts (title + content) managed in Settings → Prompts and synced across connected environments with whole-library last-write-wins, including catch-up for environments that were offline during an edit. `/prompt` in the composer opens a filterable picker (titles and content previews) that inserts the prompt at the cursor without sending; the command palette's **Prompts...** submenu copies the selected prompt on Enter and inserts it into the composer on mod+Enter when one is open. -- **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, manual “Move to top” actions reflected across connected clients plus an opt-in latest-user-message order (mobile has its own device-local switch for the same ordering), an opt-in placement for the New thread button beside New project, per-project color accents shared across connected machines and shown in mobile thread lists (cached on-device so a cold mobile launch paints them instead of flashing them in as environments connect), accent tinting for web thread rows and new-thread project choices plus per-device mobile tinting (Settings → Appearance) — off keeps the accent as a project-header dot on both — one-click project-filter clearing, and a setting to show each thread's provider icon on hover, always, or never. Thread cards' hover actions now lead with a pin/unpin toggle (filled pin while pinned), and the Settle button is just the checkmark. The pinned block folds behind its own header like the other sections, on web and mobile alike, and the Pinned, Older, Snoozed, Settled, and Archived sections all keep their fold state per device across relaunches. +- **Thread naming & sidebar polish** — split thread naming with refined fork titles; the thread sidebar adds optional two-line active-thread cards, manual “Move to top” actions reflected across connected clients plus an opt-in latest-user-message order (mobile has its own device-local switch for the same ordering), an opt-in placement for the New thread button beside New project, per-project color accents shared across connected machines and shown in mobile thread lists (cached on-device so a cold mobile launch paints them instead of flashing them in as environments connect), accent tinting for web thread rows and new-thread project choices plus per-device mobile tinting (Settings → Appearance) — off keeps the accent as a project-header dot on both — one-click project-filter clearing, and a setting to show each thread's provider icon on hover, always, or never. On web and desktop, thread cards' hover actions now lead with a pin/unpin toggle (filled pin while pinned) and the Settle button is just the checkmark; mobile reaches pin through the swipe action and the row menu. The pinned block folds behind its own header like the other sections, on web and mobile alike, and the Pinned, Older, Snoozed, Settled, and Archived sections all keep their fold state per device across relaunches. - **Older thread section** — an opt-in foldable **Older** section (Settings → Extras → Sidebar on web and desktop, Settings → General on mobile) collects threads that have gone quiet for longer than a configurable window, seven days by default. It is a grouping, not a lifecycle state: nothing is settled, snoozed, or archived, the rows stay full cards with their usual actions, and a message or a Move to top returns a thread to the list immediately. Pinned, snoozed, and settled threads keep their own sections, live and waiting work is never folded away (a running session, background work outliving its turn, an approval or input request, an undecided plan, or a thread that just came back from a snooze), and the section steps aside entirely while the Attention filter is on. The shelf's starting fold state is a setting; folding or unfolding it by hand overrides that per device on every client, and the thread you have open keeps its row even while the section is folded. Mobile stores the section's three settings per device and offers the window as a scale of stops rather than web's free-form day count. - **Thread actions beyond the sidebar row** — acting on the thread you're reading never means finding its row first. The chat header title (click it, or right-click the breadcrumb) opens a menu with Move to top, Fork conversation, Archive thread, and Copy thread ID, and the web command palette (⌘K) carries the full set: Archive, Move to top, Pin/Unpin, Settle/Un-settle, Fork, and Copy thread ID. Each lifecycle pair shows the one verb that applies, resolved from the same settled and pinned state as the sidebar row menu (so the three surfaces never disagree); Settle stays visible but disabled while the thread still has running or queued work, Pin and Settle appear only on environments advertising those capabilities, and Fork is withheld on archived threads the server would refuse. The palette also gains **Open archived threads** and a project-scoped **Open archived threads in <project>**, which open the archived settings page with that project's filter pre-applied. - **Conversation navigation rails** — desktop threads keep the existing left-side user-message navigator and add a mirrored right-side navigator for completed agent responses, with hover previews and click-to-jump navigation. A reading position you scrolled to yourself is kept as the turn streams in, rather than being yanked back by delayed anchoring. diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c556bea24ae8..d000b4fc2827 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -793,7 +793,7 @@ export function HomeScreen(props: HomeScreenProps) { items: [], hiddenSettledCount: 0, pinnedCount: 0, - pinnedShelfHeaderIndex: null, + pinnedShelfHeaderVisible: false, olderCount: 0, olderShelfHeaderIndex: null, snoozedCount: 0, @@ -916,7 +916,7 @@ export function HomeScreen(props: HomeScreenProps) { pendingTasks: v2PendingTasks, pinnedCount: threadListV2Layout.pinnedCount, pinnedShelfExpanded, - pinnedShelfHeaderIndex: threadListV2Layout.pinnedShelfHeaderIndex, + pinnedShelfHeaderVisible: threadListV2Layout.pinnedShelfHeaderVisible, olderCount: threadListV2Layout.olderCount, olderShelfExpanded, olderShelfHeaderIndex: threadListV2Layout.olderShelfHeaderIndex, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index eb51cfbce8c8..0885f5eab207 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -704,7 +704,7 @@ function ThreadNavigationSidebarPane( items: [], hiddenSettledCount: 0, pinnedCount: 0, - pinnedShelfHeaderIndex: null, + pinnedShelfHeaderVisible: false, olderCount: 0, olderShelfHeaderIndex: null, snoozedCount: 0, @@ -813,7 +813,7 @@ function ThreadNavigationSidebarPane( pendingTasks: v2PendingTasks, pinnedCount: threadListV2Layout.pinnedCount, pinnedShelfExpanded, - pinnedShelfHeaderIndex: threadListV2Layout.pinnedShelfHeaderIndex, + pinnedShelfHeaderVisible: threadListV2Layout.pinnedShelfHeaderVisible, olderCount: threadListV2Layout.olderCount, olderShelfExpanded, olderShelfHeaderIndex: threadListV2Layout.olderShelfHeaderIndex, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6a9e0fa354be..77c3c696944d 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -211,6 +211,8 @@ export const ThreadListV2PinnedShelfHeader = memo(function ThreadListV2PinnedShe accessibilityRole="button" accessibilityState={{ expanded: props.expanded }} className={cn( + // mt-2 rather than the sibling headers' mt-4: this header leads the + // list, so it only needs breathing room from the list edge. "mb-1.5 mt-2 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 3785b8b56931..85d307caa1e8 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1248,7 +1248,7 @@ describe("buildThreadListV2ListItems", () => { pinnedShelfExpanded: false, }); expect(searched.items.map((item) => item.thread.id)).toEqual(["pinned"]); - expect(searched.pinnedShelfHeaderIndex).toBeNull(); + expect(searched.pinnedShelfHeaderVisible).toBe(false); const attention = buildThreadListV2Items({ threads, @@ -1259,14 +1259,14 @@ describe("buildThreadListV2ListItems", () => { attentionMemberThreadKeys: new Set([`${environmentId}:pinned`]), }); expect(attention.items.map((item) => item.thread.id)).toEqual(["pinned"]); - expect(attention.pinnedShelfHeaderIndex).toBeNull(); + expect(attention.pinnedShelfHeaderVisible).toBe(false); const items = buildThreadListV2ListItems({ items: searched.items, pendingTasks: [], pinnedCount: searched.pinnedCount, pinnedShelfExpanded: false, - pinnedShelfHeaderIndex: searched.pinnedShelfHeaderIndex, + pinnedShelfHeaderVisible: searched.pinnedShelfHeaderVisible, }); expect(items.map((item) => item.key)).toEqual([ `v2-thread:${environmentId}:pinned`, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 403b0b2ce9e7..ad5400468954 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -292,11 +292,13 @@ export interface ThreadListV2Layout { /** Pinned threads matching the current filters, including rows hidden by collapse. */ readonly pinnedCount: number; - /** 0 when the pinned shelf header should render, null otherwise. The header - steps aside (and the collapse stops applying) while the Attention filter - or a search is active — folding rows those modes asked for would answer - a different question, the same contract the Older shelf follows. */ - readonly pinnedShelfHeaderIndex: number | null; + /** Whether the pinned shelf header should render. Unlike the other + shelves, pinned is structurally always the leading block, so it needs a + visibility flag rather than a header index. The header steps aside (and + the collapse stops applying) while the Attention filter or a search is + active — folding rows those modes asked for would answer a different + question, the same contract the Older shelf follows. */ + readonly pinnedShelfHeaderVisible: boolean; /** Threads folded under the Older shelf, including rows hidden by collapse. */ readonly olderCount: number; /** Index in `items` where the Older shelf header belongs. */ @@ -390,9 +392,11 @@ export function buildThreadListV2ListItems(input: { readonly pendingTasks: ReadonlyArray; readonly pinnedCount?: number; readonly pinnedShelfExpanded?: boolean; - /** Null hides the pinned shelf header (Attention filter or search active) - while its rows still render. Absent = header whenever rows exist. */ - readonly pinnedShelfHeaderIndex?: number | null; + /** False hides the pinned shelf header (Attention filter or search active) + while its rows still render. Absent = header whenever anything is + pinned; callers building from a real layout should always pass the + layout's flag, or a collapsed shelf leaks into those modes. */ + readonly pinnedShelfHeaderVisible?: boolean; readonly olderCount?: number; readonly olderShelfExpanded?: boolean; readonly olderShelfHeaderIndex?: number | null; @@ -438,9 +442,10 @@ export function buildThreadListV2ListItems(input: { const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; // Pinned rows lead the list under their own collapsible shelf header; // close them with the same rule the web sidebar draws, so the inbox reads - // as its own block. The header renders whenever anything is pinned (the - // count is the whole footprint when collapsed); rows only when expanded, - // or for the thread currently open. + // as its own block. While the header shows, the count is the whole + // footprint when collapsed and rows render only when expanded (or for the + // thread currently open); under the Attention filter or a search the + // layout hides the header and the rows render unconditionally. let pinnedEnd = 0; while (pinnedEnd < activeEnd) { const item = threadItems[pinnedEnd]; @@ -448,14 +453,9 @@ export function buildThreadListV2ListItems(input: { pinnedEnd += 1; } const pinnedCount = input.pinnedCount ?? pinnedEnd; - const pinnedShelfHeaderIndex = - input.pinnedShelfHeaderIndex !== undefined - ? input.pinnedShelfHeaderIndex - : pinnedCount > 0 - ? 0 - : null; + const pinnedShelfHeaderVisible = input.pinnedShelfHeaderVisible ?? true; const result: ThreadListV2ListItem[] = []; - if (pinnedShelfHeaderIndex !== null && pinnedCount > 0) { + if (pinnedShelfHeaderVisible && pinnedCount > 0) { result.push({ type: "v2-pinned-shelf", key: "v2-pinned-shelf", @@ -784,7 +784,7 @@ export function buildThreadListV2Items(input: { items, hiddenSettledCount: orderedSettled.length - pagedSettled.length, pinnedCount: orderedPinned.length, - pinnedShelfHeaderIndex: orderedPinned.length > 0 && pinnedShelfCollapsible ? 0 : null, + pinnedShelfHeaderVisible: orderedPinned.length > 0 && pinnedShelfCollapsible, olderCount: orderedOlder.length, olderShelfHeaderIndex, snoozedCount: orderedSnoozed.length, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9545565d207d..7097db46d3a8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -814,14 +814,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Server accepts a null wake time (indefinite "Until I wake it" snooze); // gates that preset without hiding the timed ones. snoozeUntilWokenSupported: boolean; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning lives in the context menu and in - // the card's hover quick-actions (pin/unpin toggle). + // Gates the pin/unpin hover quick-action. Pinned cards keep the full + // settle/snooze quick actions: settling clears the pin server-side, and + // snoozing hides the card until wake with the pin intact underneath. The + // in-row glyph beside the project title is a passive pinned marker only; + // acting on the pin lives in the hover quick-actions and the context menu. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -1187,14 +1184,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onUnsnooze, threadRef], ); - const handleUnpinClick = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onUnpin(threadRef); - }, - [onUnpin, threadRef], - ); const handlePinToggleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -1611,29 +1600,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) + ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping @@ -1753,7 +1724,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + className="-mr-1 inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-muted-foreground hover:text-foreground" /> } > @@ -2898,21 +2869,22 @@ export default function Sidebar() { () => setPinnedShelfExpanded((value) => !value), [setPinnedShelfExpanded], ); - // Same exception every other shelf makes: the open thread keeps its row, - // so a folded pinned block never hides the thread being read. The collapse - // also stops applying (and the header steps aside) while the Attention - // filter is on: it already narrowed the list to rows the user asked to - // see, and folding a subset of them away would answer a different + // The collapse stops applying (and the header steps aside) while the + // Attention filter is on: it already narrowed the list to rows the user + // asked to see, and folding a subset of them away would answer a different // question — the same contract the Older shelf follows. + const pinnedShelfCollapsed = !pinnedShelfExpanded && !attentionFilterEnabled; + // Same exception every other shelf makes: the open thread keeps its row, + // so a folded pinned block never hides the thread being read. const visiblePinnedThreads = useMemo(() => { - if (pinnedShelfExpanded || attentionFilterEnabled) return pinnedThreads; + if (!pinnedShelfCollapsed) return pinnedThreads; if (routeThreadKey === null) return []; const routeThread = pinnedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [routeThread]; - }, [attentionFilterEnabled, pinnedShelfExpanded, pinnedThreads, routeThreadKey]); + }, [pinnedShelfCollapsed, pinnedThreads, routeThreadKey]); // The Older shelf's starting state comes from Extras; toggling it writes a // per-device preference that outranks the setting from then on. const [olderShelfExpanded, setOlderShelfExpanded] = useLocalStorage( @@ -4729,14 +4701,13 @@ export default function Sidebar() { // Pinned rows render in the one shared pinned order; only // reorder-capable rows register as sortable (legacy-server // pins render in place as plain rows). - const renderedPinnedThreads = - pinnedShelfExpanded || attentionFilterEnabled - ? orderedPinnedThreads - : orderedPinnedThreads.filter( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - routeThreadKey, - ); + const renderedPinnedThreads = pinnedShelfCollapsed + ? orderedPinnedThreads.filter( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + routeThreadKey, + ) + : orderedPinnedThreads; const items: ReactNode[] = [ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 38d20836d82c..33a15ddbac92 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -6,8 +6,10 @@ while the thread is pinned, unpins it again. Pinned threads are shown independen project, including when you connect to more than one environment. The pinned section folds behind its **Pinned** header like the other sections, with a count while -it is folded, and remembers that choice per device. The thread you have open keeps its row even -while the section is folded. +it is folded. It starts unfolded, and folding it is remembered per device. The thread you have +open keeps its row even while the section is folded, and while the Attention filter is on or you +are searching, the fold steps aside entirely — both have already narrowed the list to what you +asked to see, and a pinned match should never sit behind a fold. On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu and choose **Move up** or **Move down**. The order is stored by the server and appears on your @@ -38,9 +40,9 @@ on, or while you are searching, the section steps aside entirely: both have alre list to what you asked to see, and a search match should never sit behind a fold. The section starts folded; a second setting starts it unfolded instead. Whichever you choose, once -you fold or unfold the section yourself that choice wins — for good on web and desktop, and for as -long as the thread list stays open on mobile. The thread you have open keeps its row even while the section is -folded, so it never disappears out from under you. +you fold or unfold the section yourself that choice wins, remembered per device on every client. +The thread you have open keeps its row even while the section is folded, so it never disappears +out from under you. The window and the choices around it are set per device: mobile keeps its own copy of these settings rather than following the ones on your desktop.