authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-23 04:28:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-23 04:28:10-07:00
logf70eeeeb06d6d96f292be7eef1ed8948103e68dc
tree8797d23a0a2ca41d2fd333abe15b41a7bacf6766
parent2433f4c1c01cf9e9eca983c9aeec375524e29273
signature Signed by PGP key B5690EEEBB952194

fix(clients): move settled pinned threads into the settled section (#7969)


6 files changed, 126 insertions(+), 64 deletions(-)

apps/mobile/src/features/threads/thread-list-v2-items.tsx+9-3
...@@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
506 } satisfies MenuAction,506 } satisfies MenuAction,
507 ]507 ]
508 : []),508 : []),
509 pinnedRow509 thread.pinnedAt != null
510 ? { id: "unpin", title: "Unpin", image: "pin.slash" }510 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
511 : { id: "pin", title: "Pin", image: "pin" },511 : { id: "pin", title: "Pin", image: "pin" },
512 ]512 ]
...@@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
517 props.canMovePinnedUp,517 props.canMovePinnedUp,
518 props.pinReorderSupported,518 props.pinReorderSupported,
519 props.pinningSupported,519 props.pinningSupported,
520 thread.pinnedAt,
520 ],521 ],
521 );522 );
522 const titleRegenerationMenuItems = useMemo<MenuAction[]>(523 const titleRegenerationMenuItems = useMemo<MenuAction[]>(
...@@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
552 [pinMenuItem, titleRegenerationMenuItems],553 [pinMenuItem, titleRegenerationMenuItems],
553 );554 );
554 const slimMenuActions = useMemo<MenuAction[]>(555 const slimMenuActions = useMemo<MenuAction[]>(
555 () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!],556 () => [
556 [titleRegenerationMenuItems],557 SLIM_MENU_ACTIONS[0]!,
558 ...(thread.pinnedAt != null ? pinMenuItem : []),
559 ...titleRegenerationMenuItems,
560 SLIM_MENU_ACTIONS[1]!,
561 ],
562 [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
557 );563 );
558 const snoozedMenuActions = useMemo<MenuAction[]>(564 const snoozedMenuActions = useMemo<MenuAction[]>(
559 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],565 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
apps/mobile/src/features/threads/threadListV2.test.ts+76-4
...@@ -309,7 +309,7 @@ describe("buildThreadListV2Items", () => {...@@ -309,7 +309,7 @@ describe("buildThreadListV2Items", () => {
309 expect(layout.snoozedCount).toBe(1);309 expect(layout.snoozedCount).toBe(1);
310 });310 });
311311
312 it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {312 it("places settled pinned threads in the settled shelf", () => {
313 const layout = buildThreadListV2Items({313 const layout = buildThreadListV2Items({
314 threads: [314 threads: [
315 makeThread({ id: ThreadId.make("active"), title: "Active" }),315 makeThread({ id: ThreadId.make("active"), title: "Active" }),
...@@ -317,7 +317,6 @@ describe("buildThreadListV2Items", () => {...@@ -317,7 +317,6 @@ describe("buildThreadListV2Items", () => {
317 id: ThreadId.make("pinned-settled"),317 id: ThreadId.make("pinned-settled"),
318 title: "Pinned while settled",318 title: "Pinned while settled",
319 pinnedAt: "2026-06-01T12:00:00.000Z",319 pinnedAt: "2026-06-01T12:00:00.000Z",
320 // Stale settled state (the decider clears it on pin): the pin wins.
321 settledOverride: "settled",320 settledOverride: "settled",
322 settledAt: "2026-06-01T12:00:00.000Z",321 settledAt: "2026-06-01T12:00:00.000Z",
323 }),322 }),
...@@ -327,8 +326,81 @@ describe("buildThreadListV2Items", () => {...@@ -327,8 +326,81 @@ describe("buildThreadListV2Items", () => {
327 now: NOW,326 now: NOW,
328 });327 });
329328
330 expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]);329 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
331 expect(layout.items.map((item) => item.pinned)).toEqual([true, false]);330 expect(layout.items.map((item) => item.pinned)).toEqual([false, false]);
331 expect(layout.settledCount).toBe(1);
332 });
333
334 it("moves pinned threads to the settled shelf when their pull request merges", () => {
335 const merged = makeThread({
336 id: ThreadId.make("pinned-merged"),
337 title: "Pinned merged pull request",
338 pinnedAt: "2026-06-01T12:00:00.000Z",
339 });
340 const layout = buildThreadListV2Items({
341 threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged],
342 environmentId: null,
343 searchQuery: "",
344 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
345 now: NOW,
346 });
347
348 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]);
349 expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]);
350 expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z");
351 expect(layout.settledCount).toBe(1);
352 });
353
354 it("moves inactive pinned threads to the settled shelf", () => {
355 const inactive = makeThread({
356 id: ThreadId.make("pinned-inactive"),
357 title: "Pinned inactive thread",
358 createdAt: "2026-05-20T00:00:00.000Z",
359 pinnedAt: "2026-05-21T00:00:00.000Z",
360 latestTurn: {
361 turnId: TurnId.make("turn-inactive"),
362 state: "completed",
363 requestedAt: "2026-05-21T00:00:00.000Z",
364 startedAt: "2026-05-21T00:00:01.000Z",
365 completedAt: "2026-05-21T00:00:02.000Z",
366 assistantMessageId: null,
367 },
368 });
369 const layout = buildThreadListV2Items({
370 threads: [inactive],
371 environmentId: null,
372 searchQuery: "",
373 now: NOW,
374 });
375
376 expect(layout.items[0]).toMatchObject({
377 thread: { id: "pinned-inactive" },
378 variant: "slim",
379 pinned: false,
380 });
381 expect(layout.settledCount).toBe(1);
382 });
383
384 it("keeps pinned merged threads pinned when auto-settle on merge is off", () => {
385 const merged = makeThread({
386 id: ThreadId.make("pinned-merged"),
387 title: "Pinned merged pull request",
388 pinnedAt: "2026-06-01T12:00:00.000Z",
389 });
390 const layout = buildThreadListV2Items({
391 threads: [merged],
392 environmentId: null,
393 searchQuery: "",
394 changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]),
395 autoSettleOnMerge: false,
396 now: NOW,
397 });
398
399 expect(layout.items[0]).toMatchObject({
400 thread: { id: "pinned-merged" },
401 variant: "card",
402 pinned: true,
403 });
332 expect(layout.settledCount).toBe(0);404 expect(layout.settledCount).toBe(0);
333 });405 });
334406
apps/mobile/src/features/threads/threadListV2.ts+3-10
...@@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: {...@@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: {
386 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;386 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
387 const changeRequest =387 const changeRequest =
388 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;388 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
389 // Visibility parity with web: snooze outranks everything, including a389 // Snooze outranks settlement and pinning until the thread wakes.
390 // pin — a snoozed thread leaves the list until it wakes (or raises its
391 // hand). The pin (and its pinOrderKey) survives underneath, so a woken
392 // thread reappears at its exact spot in the pinned block.
393 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {390 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
394 snoozed.push(thread);391 snoozed.push(thread);
395 if (392 if (
...@@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: {...@@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: {
401 }398 }
402 continue;399 continue;
403 }400 }
404 // A pin otherwise overrides the lifecycle: pinned threads render above
405 // the inbox and never auto-settle out of sight.
406 if (thread.pinnedAt != null) {
407 pinned.push(thread);
408 continue;
409 }
410 if (401 if (
411 supportsSettlement &&402 supportsSettlement &&
412 effectiveSettled(thread, {403 effectiveSettled(thread, {
...@@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: {...@@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: {
417 })408 })
418 ) {409 ) {
419 settled.push(thread);410 settled.push(thread);
411 } else if (thread.pinnedAt != null) {
412 pinned.push(thread);
420 } else {413 } else {
421 active.push(thread);414 active.push(thread);
422 }415 }
apps/web/src/components/Sidebar.tsx+33-45
...@@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
701 autoSettleOnMerge: boolean;701 autoSettleOnMerge: boolean;
702 // Same contract for thread.snooze/unsnooze.702 // Same contract for thread.snooze/unsnooze.
703 snoozeSupported: boolean;703 snoozeSupported: boolean;
704 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick704 // Pinned threads show the same pin marker in active, settled, and snoozed
705 // actions: settling clears the pin server-side, and snoozing hides the705 // rows. The marker can unpin the thread when the server supports pinning.
706 // card until wake with the pin intact underneath. The glyph is also the
707 // in-row pin state cue (the pinned block has no header), so it always
708 // shows while pinned; it only becomes a clickable unpin quick-action once
709 // the pinning capability is confirmed, and stays a passive marker while
710 // the descriptor is not loaded. Pinning itself lives in the context menu.
711 pinningSupported: boolean;706 pinningSupported: boolean;
712 isPinned: boolean;707 isPinned: boolean;
713 // Present only on pinned cards whose server supports reordering: dnd-kit708 // Present only on pinned cards whose server supports reordering: dnd-kit
...@@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1192 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />1187 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
1193 </span>1188 </span>
1194 ) : null;1189 ) : null;
1190 const pinIndicator = props.isPinned ? (
1191 props.pinningSupported ? (
1192 <Tooltip>
1193 <TooltipTrigger
1194 render={
1195 <button
1196 type="button"
1197 aria-label="Unpin thread"
1198 onClick={handleUnpinClick}
1199 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1200 />
1201 }
1202 >
1203 <PinIcon aria-hidden className="size-3 shrink-0" />
1204 </TooltipTrigger>
1205 <TooltipPopup>Unpin thread</TooltipPopup>
1206 </Tooltip>
1207 ) : (
1208 <PinIcon
1209 aria-label="Pinned"
1210 role="img"
1211 className="size-3 shrink-0 text-muted-foreground/65"
1212 />
1213 )
1214 ) : null;
11951215
1196 if (variant === "slim") {1216 if (variant === "slim") {
1197 return (1217 return (
...@@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1233 />1253 />
1234 </span>1254 </span>
1235 {title}1255 {title}
1256 {pinIndicator}
1236 {terminalStatusIcon}1257 {terminalStatusIcon}
1237 {isRegeneratingTitle ? (1258 {isRegeneratingTitle ? (
1238 <span role="status" className="sr-only">1259 <span role="status" className="sr-only">
...@@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1395 ) : (1416 ) : (
1396 <span className="flex-1" />1417 <span className="flex-1" />
1397 )}1418 )}
1398 {props.isPinned ? (1419 {pinIndicator}
1399 props.pinningSupported ? (
1400 <Tooltip>
1401 <TooltipTrigger
1402 render={
1403 <button
1404 type="button"
1405 aria-label="Unpin thread"
1406 onClick={handleUnpinClick}
1407 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1408 />
1409 }
1410 >
1411 <PinIcon aria-hidden className="size-3 shrink-0" />
1412 </TooltipTrigger>
1413 <TooltipPopup>Unpin thread</TooltipPopup>
1414 </Tooltip>
1415 ) : (
1416 <PinIcon
1417 aria-label="Pinned"
1418 role="img"
1419 className="size-3 shrink-0 text-muted-foreground/65"
1420 />
1421 )
1422 ) : null}
1423 {/* The visible state owns this slot's width: status at rest,1420 {/* The visible state owns this slot's width: status at rest,
1424 actions on hover/keyboard focus or while the popover is open. Keeping1421 actions on hover/keyboard focus or while the popover is open. Keeping
1425 the hidden state out of flow lets the project label reclaim1422 the hidden state out of flow lets the project label reclaim
...@@ -2043,20 +2040,9 @@ export default function Sidebar() {...@@ -2043,20 +2040,9 @@ export default function Sidebar() {
2043 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)2040 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
2044 ? snapshot.pr2041 ? snapshot.pr
2045 : null;2042 : null;
2046 // Snooze outranks everything, including a pin: "hide until Tuesday"2043 // Snooze outranks settlement and pinning until the thread wakes.
2047 // temporarily suspends "keep on top". The pin survives underneath —
2048 // and so does its pinOrderKey, so on wake the thread reappears at
2049 // its exact slot in the pinned block. (For unpinned threads
2050 // this is also the snooze-beats-auto-settle rule: the wake time is a
2051 // stronger statement about when the thread matters again.)
2052 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {2044 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
2053 snoozed.push(thread);2045 snoozed.push(thread);
2054 // A pin otherwise overrides the lifecycle: pinned threads never
2055 // auto-settle out of sight. (The decider clears settled state on
2056 // pin and the pin on settle, so pin-vs-settled conflicts only
2057 // arise from stale or raced writes.)
2058 } else if (thread.pinnedAt != null) {
2059 pinned.push(thread);
2060 } else if (2046 } else if (
2061 supportsSettlement &&2047 supportsSettlement &&
2062 effectiveSettled(thread, {2048 effectiveSettled(thread, {
...@@ -2067,6 +2053,8 @@ export default function Sidebar() {...@@ -2067,6 +2053,8 @@ export default function Sidebar() {
2067 })2053 })
2068 ) {2054 ) {
2069 settled.push(thread);2055 settled.push(thread);
2056 } else if (thread.pinnedAt != null) {
2057 pinned.push(thread);
2070 } else {2058 } else {
2071 active.push(thread);2059 active.push(thread);
2072 }2060 }
...@@ -3697,7 +3685,7 @@ export default function Sidebar() {...@@ -3697,7 +3685,7 @@ export default function Sidebar() {
3697 serverConfigs.get(thread.environmentId)?.environment.capabilities3685 serverConfigs.get(thread.environmentId)?.environment.capabilities
3698 .threadPinning === true3686 .threadPinning === true
3699 }3687 }
3700 isPinned={section === "pinned"}3688 isPinned={thread.pinnedAt != null}
3701 sortable={sortable}3689 sortable={sortable}
3702 snoozeWakeLabelText={3690 snoozeWakeLabelText={
3703 section === "snoozed" && thread.snoozedUntil != null3691 section === "snoozed" && thread.snoozedUntil != null
docs/user/thread-sidebar.md+3
...@@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a...@@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a
4Pinned threads are shown independently of their project, including when you connect to more than4Pinned threads are shown independently of their project, including when you connect to more than
5one environment.5one environment.
66
7Pinned threads still move to **Settled** when they become inactive. They also move when their pull
8request merges if **Auto-settle merged threads** is enabled.
9
7On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu10On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
8and choose **Move up** or **Move down**. The order is stored by the server and appears on your11and choose **Move up** or **Move down**. The order is stored by the server and appears on your
9other connected devices.12other connected devices.
packages/contracts/src/orchestration.ts+2-2
...@@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({...@@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({
401 // Optional so payloads from pre-snooze servers still decode.401 // Optional so payloads from pre-snooze servers still decode.
402 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),402 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
403 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),403 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
404 // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the404 // Active pinned threads render in the pinned block. Settled and snoozed
405 // thread renders in the pinned block and never classifies into a shelf.405 // threads remain in their respective shelves even when pinned.
406 // Optional so payloads from pre-pinning servers still decode.406 // Optional so payloads from pre-pinning servers still decode.
407 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),407 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
408 // Fractional index for user-arranged pinned order. Keyed threads sort by408 // Fractional index for user-arranged pinned order. Keyed threads sort by