From 2ab439a53b815a69714bf026eb685b4da066f9d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:06:07 -0700 Subject: [PATCH 1/4] fix(clients): move settled pinned threads into the settled section --- .../src/features/threads/threadListV2.test.ts | 79 ++++++++++++++++++- .../src/features/threads/threadListV2.ts | 13 +-- apps/web/src/components/Sidebar.tsx | 15 +--- docs/user/thread-sidebar.md | 2 + packages/contracts/src/orchestration.ts | 4 +- 5 files changed, 85 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c58dbb67517b..68b24140fc2d 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -309,7 +309,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -317,7 +317,6 @@ describe("buildThreadListV2Items", () => { id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", - // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), @@ -327,8 +326,80 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); - expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); + expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); + expect(layout.settledCount).toBe(1); + }); + + it("moves pinned threads to the settled shelf when their pull request merges", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); + expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); + expect(layout.settledCount).toBe(1); + }); + + it("moves inactive pinned threads to the settled shelf", () => { + const inactive = makeThread({ + id: ThreadId.make("pinned-inactive"), + title: "Pinned inactive thread", + createdAt: "2026-05-20T00:00:00.000Z", + pinnedAt: "2026-05-21T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-inactive"), + state: "completed", + requestedAt: "2026-05-21T00:00:00.000Z", + startedAt: "2026-05-21T00:00:01.000Z", + completedAt: "2026-05-21T00:00:02.000Z", + assistantMessageId: null, + }, + }); + const layout = buildThreadListV2Items({ + threads: [inactive], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-inactive" }, + variant: "slim", + pinned: false, + }); + expect(layout.settledCount).toBe(1); + }); + + it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-merged" }, + variant: "card", + pinned: true, + }); expect(layout.settledCount).toBe(0); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 45079bac6e7f..11ac0e9dcb64 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: { const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequest = input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: snooze outranks everything, including a - // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin (and its pinOrderKey) survives underneath, so a woken - // thread reappears at its exact spot in the pinned block. + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: { } continue; } - // A pin otherwise overrides the lifecycle: pinned threads render above - // the inbox and never auto-settle out of sight. - if (thread.pinnedAt != null) { - pinned.push(thread); - continue; - } if ( supportsSettlement && effectiveSettled(thread, { @@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a8f2ea52995a..716c0635572f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2043,20 +2043,9 @@ export default function Sidebar() { snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) ? snapshot.pr : null; - // Snooze outranks everything, including a pin: "hide until Tuesday" - // temporarily suspends "keep on top". The pin survives underneath — - // and so does its pinOrderKey, so on wake the thread reappears at - // its exact slot in the pinned block. (For unpinned threads - // this is also the snooze-beats-auto-settle rule: the wake time is a - // stronger statement about when the thread matters again.) + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - // A pin otherwise overrides the lifecycle: pinned threads never - // auto-settle out of sight. (The decider clears settled state on - // pin and the pin on settle, so pin-vs-settled conflicts only - // arise from stale or raced writes.) - } else if (thread.pinnedAt != null) { - pinned.push(thread); } else if ( supportsSettlement && effectiveSettled(thread, { @@ -2067,6 +2056,8 @@ export default function Sidebar() { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 70b3cccc962a..3154af83c148 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -4,6 +4,8 @@ Pin a thread from its context menu to keep it in the pinned section above your a Pinned threads are shown independently of their project, including when you connect to more than one environment. +Pinned threads still move to **Settled** when their pull request merges or they become inactive. + 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 other connected devices. diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index adb17879ff2f..1c27e6d3c6b4 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({ // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), - // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the - // thread renders in the pinned block and never classifies into a shelf. + // Active pinned threads render in the pinned block. Settled and snoozed + // threads remain in their respective shelves even when pinned. // Optional so payloads from pre-pinning servers still decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), // Fractional index for user-arranged pinned order. Keyed threads sort by From 65033b96bae43e48be050ad42684c5765a163ada Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:09:11 -0700 Subject: [PATCH 2/4] docs(user): clarify when pinned merged threads settle --- docs/user/thread-sidebar.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3154af83c148..274f596bbc50 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -4,7 +4,8 @@ Pin a thread from its context menu to keep it in the pinned section above your a Pinned threads are shown independently of their project, including when you connect to more than one environment. -Pinned threads still move to **Settled** when their pull request merges or they become inactive. +Pinned threads still move to **Settled** when they become inactive. They also move when their pull +request merges if **Auto-settle merged threads** is enabled. 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 From b6ef811524abd7aea43644f041554fe7ab5a4bdd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:20:47 -0700 Subject: [PATCH 3/4] fix(mobile): keep unpin available for settled threads --- .../src/features/threads/thread-list-v2-items.tsx | 12 +++++++++--- .../mobile/src/features/threads/threadListV2.test.ts | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) 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 fa1e752d619f..e6589cd56300 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } satisfies MenuAction, ] : []), - pinnedRow + thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] @@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { props.canMovePinnedUp, props.pinReorderSupported, props.pinningSupported, + thread.pinnedAt, ], ); const titleRegenerationMenuItems = useMemo( @@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...(thread.pinnedAt != null ? pinMenuItem : []), + ...titleRegenerationMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 68b24140fc2d..4439ea194778 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -347,6 +347,7 @@ describe("buildThreadListV2Items", () => { expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); + expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); expect(layout.settledCount).toBe(1); }); From 00b14505dd34e1c20a839e5beec7f1901e41aa1e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:27:52 -0700 Subject: [PATCH 4/4] fix(web): keep unpin available for settled threads --- apps/web/src/components/Sidebar.tsx | 63 ++++++++++++++--------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 716c0635572f..971ead810f07 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: 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 itself lives in the context menu. + // Pinned threads show the same pin marker in active, settled, and snoozed + // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const pinIndicator = props.isPinned ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - {props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) - ) : null} + {pinIndicator} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -3688,7 +3685,7 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadPinning === true } - isPinned={section === "pinned"} + isPinned={thread.pinnedAt != null} sortable={sortable} snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null