Unified · Split
| @@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 505 unmodified lines | ||
| 506 | 506 | } satisfies MenuAction, |
| 507 | 507 | ] |
| 508 | 508 | : []), |
| 509 | − pinnedRow | |
| 509 | + thread.pinnedAt != null | |
| 510 | 510 | ? { id: "unpin", title: "Unpin", image: "pin.slash" } |
| 511 | 511 | : { id: "pin", title: "Pin", image: "pin" }, |
| 512 | 512 | ] |
| @@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 4 unmodified lines | ||
| 517 | 517 | props.canMovePinnedUp, |
| 518 | 518 | props.pinReorderSupported, |
| 519 | 519 | props.pinningSupported, |
| 520 | + thread.pinnedAt, | |
| 520 | 521 | ], |
| 521 | 522 | ); |
| 522 | 523 | const titleRegenerationMenuItems = useMemo<MenuAction[]>( |
| @@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 29 unmodified lines | ||
| 552 | 553 | [pinMenuItem, titleRegenerationMenuItems], |
| 553 | 554 | ); |
| 554 | 555 | 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], | |
| 557 | 563 | ); |
| 558 | 564 | const snoozedMenuActions = useMemo<MenuAction[]>( |
| 559 | 565 | () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], |
| @@ -309,7 +309,7 @@ describe("buildThreadListV2Items", () => { · 308 unmodified lines | ||
| 309 | 309 | expect(layout.snoozedCount).toBe(1); |
| 310 | 310 | }); |
| 311 | 311 | |
| 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 | 313 | const layout = buildThreadListV2Items({ |
| 314 | 314 | threads: [ |
| 315 | 315 | makeThread({ id: ThreadId.make("active"), title: "Active" }), |
| @@ -317,7 +317,6 @@ describe("buildThreadListV2Items", () => { · 1 unmodified line | ||
| 317 | 317 | id: ThreadId.make("pinned-settled"), |
| 318 | 318 | title: "Pinned while settled", |
| 319 | 319 | pinnedAt: "2026-06-01T12:00:00.000Z", |
| 320 | − // Stale settled state (the decider clears it on pin): the pin wins. | |
| 321 | 320 | settledOverride: "settled", |
| 322 | 321 | settledAt: "2026-06-01T12:00:00.000Z", |
| 323 | 322 | }), |
| @@ -327,8 +326,81 @@ describe("buildThreadListV2Items", () => { · 3 unmodified lines | ||
| 327 | 326 | now: NOW, |
| 328 | 327 | }); |
| 329 | 328 | |
| 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 | + }); | |
| 332 | 404 | expect(layout.settledCount).toBe(0); |
| 333 | 405 | }); |
| 334 | 406 | |
| @@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: { · 385 unmodified lines | ||
| 386 | 386 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; |
| 387 | 387 | const changeRequest = |
| 388 | 388 | 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. | |
| 393 | 390 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { |
| 394 | 391 | snoozed.push(thread); |
| 395 | 392 | if ( |
| @@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: { · 5 unmodified lines | ||
| 401 | 398 | } |
| 402 | 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 | 401 | if ( |
| 411 | 402 | supportsSettlement && |
| 412 | 403 | effectiveSettled(thread, { |
| @@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines | ||
| 417 | 408 | }) |
| 418 | 409 | ) { |
| 419 | 410 | settled.push(thread); |
| 411 | + } else if (thread.pinnedAt != null) { | |
| 412 | + pinned.push(thread); | |
| 420 | 413 | } else { |
| 421 | 414 | active.push(thread); |
| 422 | 415 | } |
| @@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 700 unmodified lines | ||
| 701 | 701 | autoSettleOnMerge: boolean; |
| 702 | 702 | // Same contract for thread.snooze/unsnooze. |
| 703 | 703 | 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. | |
| 711 | 706 | pinningSupported: boolean; |
| 712 | 707 | isPinned: boolean; |
| 713 | 708 | // Present only on pinned cards whose server supports reordering: dnd-kit |
| @@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 478 unmodified lines | ||
| 1192 | 1187 | <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} /> |
| 1193 | 1188 | </span> |
| 1194 | 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; | |
| 1195 | 1215 | |
| 1196 | 1216 | if (variant === "slim") { |
| 1197 | 1217 | return ( |
| @@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 35 unmodified lines | ||
| 1233 | 1253 | /> |
| 1234 | 1254 | </span> |
| 1235 | 1255 | {title} |
| 1256 | + {pinIndicator} | |
| 1236 | 1257 | {terminalStatusIcon} |
| 1237 | 1258 | {isRegeneratingTitle ? ( |
| 1238 | 1259 | <span role="status" className="sr-only"> |
| @@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 156 unmodified lines | ||
| 1395 | 1416 | ) : ( |
| 1396 | 1417 | <span className="flex-1" /> |
| 1397 | 1418 | )} |
| 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} | |
| 1423 | 1420 | {/* The visible state owns this slot's width: status at rest, |
| 1424 | 1421 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 1425 | 1422 | the hidden state out of flow lets the project label reclaim |
| @@ -2043,20 +2040,9 @@ export default function Sidebar() { · 617 unmodified lines | ||
| 2043 | 2040 | snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) |
| 2044 | 2041 | ? snapshot.pr |
| 2045 | 2042 | : 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. | |
| 2052 | 2044 | if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { |
| 2053 | 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 | 2046 | } else if ( |
| 2061 | 2047 | supportsSettlement && |
| 2062 | 2048 | effectiveSettled(thread, { |
| @@ -2067,6 +2053,8 @@ export default function Sidebar() { · 4 unmodified lines | ||
| 2067 | 2053 | }) |
| 2068 | 2054 | ) { |
| 2069 | 2055 | settled.push(thread); |
| 2056 | + } else if (thread.pinnedAt != null) { | |
| 2057 | + pinned.push(thread); | |
| 2070 | 2058 | } else { |
| 2071 | 2059 | active.push(thread); |
| 2072 | 2060 | } |
| @@ -3697,7 +3685,7 @@ export default function Sidebar() { · 1624 unmodified lines | ||
| 3697 | 3685 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3698 | 3686 | .threadPinning === true |
| 3699 | 3687 | } |
| 3700 | − isPinned={section === "pinned"} | |
| 3688 | + isPinned={thread.pinnedAt != null} | |
| 3701 | 3689 | sortable={sortable} |
| 3702 | 3690 | snoozeWakeLabelText={ |
| 3703 | 3691 | section === "snoozed" && thread.snoozedUntil != null |
| @@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a · 3 unmodified lines | ||
| 4 | 4 | Pinned threads are shown independently of their project, including when you connect to more than |
| 5 | 5 | one environment. |
| 6 | 6 | |
| 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 | + | |
| 7 | 10 | On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu |
| 8 | 11 | and choose **Move up** or **Move down**. The order is stored by the server and appears on your |
| 9 | 12 | other connected devices. |
| @@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({ · 400 unmodified lines | ||
| 401 | 401 | // Optional so payloads from pre-snooze servers still decode. |
| 402 | 402 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 403 | 403 | 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. | |
| 406 | 406 | // Optional so payloads from pre-pinning servers still decode. |
| 407 | 407 | pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 408 | 408 | // Fractional index for user-arranged pinned order. Keyed threads sort by |