History

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

Theo Browne committed 2 months agov0.0.34-nightly.20260823.1168

Commit f70eeee1 parent 2433f4c6 files · +126 −64Browse files

Unified · Split

6 files
  1. apps/mobile/src/features/threads/thread-list-v2-items.tsx +9 −3
  2. apps/mobile/src/features/threads/threadListV2.test.ts +76 −4
  3. apps/mobile/src/features/threads/threadListV2.ts +3 −10
  4. apps/web/src/components/Sidebar.tsx +33 −45
  5. docs/user/thread-sidebar.md +3 −0
  6. packages/contracts/src/orchestration.ts +2 −2
apps/mobile/src/features/threads/thread-list-v2-items.tsx+9−3
@@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 505 unmodified lines
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: { · 4 unmodified lines
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: { · 29 unmodified lines
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", () => { · 308 unmodified lines
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", () => { · 1 unmodified line
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", () => { · 3 unmodified lines
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: { · 385 unmodified lines
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: { · 5 unmodified lines
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: { · 4 unmodified lines
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: { · 700 unmodified lines
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: { · 478 unmodified lines
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: { · 35 unmodified lines
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: { · 156 unmodified lines
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() { · 617 unmodified lines
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() { · 4 unmodified lines
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() { · 1624 unmodified lines
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−0
@@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a · 3 unmodified lines
44 Pinned threads are shown independently of their project, including when you connect to more than
55 one environment.
66
7+Pinned threads still move to **Settled** when they become inactive. They also move when their pull
8+request merges if **Auto-settle merged threads** is enabled.
9+
710 On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
811 and choose **Move up** or **Move down**. The order is stored by the server and appears on your
912 other connected devices.
packages/contracts/src/orchestration.ts+2−2
@@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({ · 400 unmodified lines
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

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t