| author | |
| committer | |
| log | f70eeeeb06d6d96f292be7eef1ed8948103e68dc |
| tree | 8797d23a0a2ca41d2fd333abe15b41a7bacf6766 |
| parent | 2433f4c1c01cf9e9eca983c9aeec375524e29273 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | pinnedRow | 509 | 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 | }); |
| 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 | 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 | }); |
| 329 | 328 | ||
| 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 | }); |
| 334 | 406 |
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 a | 389 | // 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 quick | 704 | // Pinned threads show the same pin marker in active, settled, and snoozed |
| 705 | // actions: settling clears the pin server-side, and snoozing hides the | 705 | // 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-kit | 708 | // 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; | ||
| 1195 | 1215 | ||
| 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. Keeping | 1421 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 1425 | the hidden state out of flow lets the project label reclaim | 1422 | 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.pr | 2041 | ? 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.capabilities | 3685 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 3698 | .threadPinning === true | 3686 | .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 != null | 3691 | 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 |
| 4 | Pinned threads are shown independently of their project, including when you connect to more than | 4 | Pinned threads are shown independently of their project, including when you connect to more than |
| 5 | one environment. | 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 | On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu | 10 | On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu |
| 8 | and choose **Move up** or **Move down**. The order is stored by the server and appears on your | 11 | and choose **Move up** or **Move down**. The order is stored by the server and appears on your |
| 9 | other connected devices. | 12 | other 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 the | 404 | // 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 by | 408 | // Fractional index for user-arranged pinned order. Keyed threads sort by |