| 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 | 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: { |
| 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: { |
| 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]!], |
apps/mobile/src/features/threads/threadListV2.test.ts+76-4| ... | ... | @@ -309,7 +309,7 @@ describe("buildThreadListV2Items", () => { |
| 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", () => { |
| 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", () => { |
| 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 |
apps/mobile/src/features/threads/threadListV2.ts+3-10| ... | ... | @@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: { |
| 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: { |
| 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: { |
| 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 | } |
apps/web/src/components/Sidebar.tsx+33-45| ... | ... | @@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 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: { |
| 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: { |
| 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: { |
| 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() { |
| 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() { |
| 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() { |
| 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 |
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 | 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. |
packages/contracts/src/orchestration.ts+2-2| ... | ... | @@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({ |
| 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 |