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: {
506506 } satisfies MenuAction,
507507 ]
508508 : []),
509 pinnedRow
509 thread.pinnedAt != null
510510 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
511511 : { id: "pin", title: "Pin", image: "pin" },
512512 ]
......@@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
517517 props.canMovePinnedUp,
518518 props.pinReorderSupported,
519519 props.pinningSupported,
520 thread.pinnedAt,
520521 ],
521522 );
522523 const titleRegenerationMenuItems = useMemo<MenuAction[]>(
......@@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
552553 [pinMenuItem, titleRegenerationMenuItems],
553554 );
554555 const slimMenuActions = useMemo<MenuAction[]>(
555 () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!],
556 [titleRegenerationMenuItems],
556 () => [
557 SLIM_MENU_ACTIONS[0]!,
558 ...(thread.pinnedAt != null ? pinMenuItem : []),
559 ...titleRegenerationMenuItems,
560 SLIM_MENU_ACTIONS[1]!,
561 ],
562 [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
557563 );
558564 const snoozedMenuActions = useMemo<MenuAction[]>(
559565 () => [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", () => {
309309 expect(layout.snoozedCount).toBe(1);
310310 });
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", () => {
313313 const layout = buildThreadListV2Items({
314314 threads: [
315315 makeThread({ id: ThreadId.make("active"), title: "Active" }),
......@@ -317,7 +317,6 @@ describe("buildThreadListV2Items", () => {
317317 id: ThreadId.make("pinned-settled"),
318318 title: "Pinned while settled",
319319 pinnedAt: "2026-06-01T12:00:00.000Z",
320 // Stale settled state (the decider clears it on pin): the pin wins.
321320 settledOverride: "settled",
322321 settledAt: "2026-06-01T12:00:00.000Z",
323322 }),
......@@ -327,8 +326,81 @@ describe("buildThreadListV2Items", () => {
327326 now: NOW,
328327 });
329328
330 expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]);
331 expect(layout.items.map((item) => item.pinned)).toEqual([true, false]);
329 expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]);
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 });
332404 expect(layout.settledCount).toBe(0);
333405 });
334406
apps/mobile/src/features/threads/threadListV2.ts+3-10
......@@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: {
386386 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
387387 const changeRequest =
388388 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
389 // Visibility parity with web: snooze outranks everything, including a
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.
389 // Snooze outranks settlement and pinning until the thread wakes.
393390 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
394391 snoozed.push(thread);
395392 if (
......@@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: {
401398 }
402399 continue;
403400 }
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 }
410401 if (
411402 supportsSettlement &&
412403 effectiveSettled(thread, {
......@@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: {
417408 })
418409 ) {
419410 settled.push(thread);
411 } else if (thread.pinnedAt != null) {
412 pinned.push(thread);
420413 } else {
421414 active.push(thread);
422415 }
apps/web/src/components/Sidebar.tsx+33-45
......@@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
701701 autoSettleOnMerge: boolean;
702702 // Same contract for thread.snooze/unsnooze.
703703 snoozeSupported: boolean;
704 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick
705 // actions: settling clears the pin server-side, and snoozing hides the
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.
704 // Pinned threads show the same pin marker in active, settled, and snoozed
705 // rows. The marker can unpin the thread when the server supports pinning.
711706 pinningSupported: boolean;
712707 isPinned: boolean;
713708 // Present only on pinned cards whose server supports reordering: dnd-kit
......@@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11921187 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
11931188 </span>
11941189 ) : 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
11961216 if (variant === "slim") {
11971217 return (
......@@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12331253 />
12341254 </span>
12351255 {title}
1256 {pinIndicator}
12361257 {terminalStatusIcon}
12371258 {isRegeneratingTitle ? (
12381259 <span role="status" className="sr-only">
......@@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13951416 ) : (
13961417 <span className="flex-1" />
13971418 )}
1398 {props.isPinned ? (
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}
1419 {pinIndicator}
14231420 {/* The visible state owns this slot's width: status at rest,
14241421 actions on hover/keyboard focus or while the popover is open. Keeping
14251422 the hidden state out of flow lets the project label reclaim
......@@ -2043,20 +2040,9 @@ export default function Sidebar() {
20432040 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
20442041 ? snapshot.pr
20452042 : null;
2046 // Snooze outranks everything, including a pin: "hide until Tuesday"
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.)
2043 // Snooze outranks settlement and pinning until the thread wakes.
20522044 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
20532045 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);
20602046 } else if (
20612047 supportsSettlement &&
20622048 effectiveSettled(thread, {
......@@ -2067,6 +2053,8 @@ export default function Sidebar() {
20672053 })
20682054 ) {
20692055 settled.push(thread);
2056 } else if (thread.pinnedAt != null) {
2057 pinned.push(thread);
20702058 } else {
20712059 active.push(thread);
20722060 }
......@@ -3697,7 +3685,7 @@ export default function Sidebar() {
36973685 serverConfigs.get(thread.environmentId)?.environment.capabilities
36983686 .threadPinning === true
36993687 }
3700 isPinned={section === "pinned"}
3688 isPinned={thread.pinnedAt != null}
37013689 sortable={sortable}
37023690 snoozeWakeLabelText={
37033691 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
44Pinned threads are shown independently of their project, including when you connect to more than
55one 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
710On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
811and choose **Move up** or **Move down**. The order is stored by the server and appears on your
912other connected devices.
packages/contracts/src/orchestration.ts+2-2
......@@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({
401401 // Optional so payloads from pre-snooze servers still decode.
402402 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
403403 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
404 // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the
405 // thread renders in the pinned block and never classifies into a shelf.
404 // Active pinned threads render in the pinned block. Settled and snoozed
405 // threads remain in their respective shelves even when pinned.
406406 // Optional so payloads from pre-pinning servers still decode.
407407 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
408408 // Fractional index for user-arranged pinned order. Keyed threads sort by