| author | |
| committer | |
| log | 202e5609ffb294bc0aa86c08ce1d3751de567226 |
| tree | 9734b3e37b6a8b99a0843ddeb78ac531ffe7e4ff |
| parent | bb38c3320d0a4eec4e9097fdc400970d881ff559 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: maria <maria@kuuro.net>34 files changed, 2389 insertions(+), 235 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+56-4| ... | ... | @@ -472,6 +472,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 472 | 472 | // boundary is actually crossed while the app stays open (mirrors web); |
| 473 | 473 | // without a clock dependency the partition memoizes a frozen "now". |
| 474 | 474 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); |
| 475 | // Snooze wake times are second-precise; a counter bumped exactly at the | |
| 476 | // next wake boundary re-runs the partition with a fresh clock so a woken | |
| 477 | // thread reappears immediately instead of on the next minute tick. | |
| 478 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | |
| 475 | 479 | useEffect(() => { |
| 476 | 480 | if (!threadListV2Enabled) return; |
| 477 | 481 | // Refresh immediately on enable: the mount-time value can be hours old |
| ... | ... | @@ -493,8 +497,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 493 | 497 | } |
| 494 | 498 | return supported; |
| 495 | 499 | }, [serverConfigs]); |
| 500 | const snoozeEnvironmentIds = useMemo(() => { | |
| 501 | const supported = new Set<EnvironmentId>(); | |
| 502 | for (const [environmentId, config] of serverConfigs) { | |
| 503 | if (config.environment.capabilities.threadSnooze === true) { | |
| 504 | supported.add(environmentId); | |
| 505 | } | |
| 506 | } | |
| 507 | return supported; | |
| 508 | }, [serverConfigs]); | |
| 496 | 509 | const threadListV2Layout = useMemo(() => { |
| 497 | if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; | |
| 510 | if (!threadListV2Enabled) | |
| 511 | return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; | |
| 498 | 512 | // Settled threads are live shells; archived threads keep their original |
| 499 | 513 | // "hidden from lists" meaning. |
| 500 | 514 | return buildThreadListV2Items({ |
| ... | ... | @@ -504,20 +518,38 @@ export function HomeScreen(props: HomeScreenProps) { |
| 504 | 518 | searchQuery: props.searchQuery, |
| 505 | 519 | changeRequestStateByKey, |
| 506 | 520 | settlementEnvironmentIds, |
| 521 | snoozeEnvironmentIds, | |
| 507 | 522 | settledLimit: settledVisibleCount, |
| 508 | 523 | now: `${nowMinute}:00.000Z`, |
| 524 | snoozeNow: new Date().toISOString(), | |
| 509 | 525 | }); |
| 510 | 526 | }, [ |
| 511 | 527 | changeRequestStateByKey, |
| 512 | 528 | nowMinute, |
| 529 | snoozeWakeTick, | |
| 513 | 530 | settledVisibleCount, |
| 514 | 531 | settlementEnvironmentIds, |
| 532 | snoozeEnvironmentIds, | |
| 515 | 533 | props.searchQuery, |
| 516 | 534 | props.selectedEnvironmentId, |
| 517 | 535 | props.threads, |
| 518 | 536 | threadListV2Enabled, |
| 519 | 537 | v2ScopedProjectGroup, |
| 520 | 538 | ]); |
| 539 | // Re-partition the moment the earliest snooze expires (clamped to the | |
| 540 | // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). | |
| 541 | const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; | |
| 542 | useEffect(() => { | |
| 543 | if (nextSnoozeWakeAt === null) return; | |
| 544 | const wakeAtMs = Date.parse(nextSnoozeWakeAt); | |
| 545 | if (Number.isNaN(wakeAtMs)) return; | |
| 546 | const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); | |
| 547 | const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); | |
| 548 | return () => clearTimeout(id); | |
| 549 | // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is | |
| 550 | // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout | |
| 551 | // range) the boundary string is identical and the chain would die. | |
| 552 | }, [nextSnoozeWakeAt, snoozeWakeTick]); | |
| 521 | 553 | const threadListV2Items = threadListV2Layout.items; |
| 522 | 554 | |
| 523 | 555 | const renderV2Item = useCallback( |
| ... | ... | @@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) { |
| 813 | 845 | // Self-contained: v1's listEmpty keys off projectGroups, which ignores the |
| 814 | 846 | // v2 project scope, so it can be null (results elsewhere) while this list |
| 815 | 847 | // is empty. Search outranks the scope — "No results" names the actionable |
| 816 | // fact when a query is active. Pending tasks render in the header, so the | |
| 817 | // list showing them isn't empty in the user's eyes. | |
| 848 | // fact when a query is active. Snoozed threads outrank the rest: "No | |
| 849 | // threads yet" over an inbox that is merely all-snoozed reads as data | |
| 850 | // loss. Pending tasks render in the header, so the list showing them | |
| 851 | // isn't empty in the user's eyes. | |
| 852 | const v2SnoozedCount = threadListV2Layout.snoozedCount; | |
| 818 | 853 | const v2ListEmpty = |
| 819 | 854 | v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( |
| 820 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 855 | v2SnoozedCount > 0 ? ( | |
| 856 | // The snoozed threads already passed this search filter: "No | |
| 857 | // results" would claim nothing matched when matches are merely | |
| 858 | // parked. | |
| 859 | <EmptyState | |
| 860 | title={ | |
| 861 | v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed` | |
| 862 | } | |
| 863 | detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`} | |
| 864 | /> | |
| 865 | ) : ( | |
| 866 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 867 | ) | |
| 868 | ) : v2SnoozedCount > 0 ? ( | |
| 869 | <EmptyState | |
| 870 | title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`} | |
| 871 | detail="Snoozed threads return when their wake time passes." | |
| 872 | /> | |
| 821 | 873 | ) : v2ScopedProjectGroup !== null ? ( |
| 822 | 874 | <EmptyState |
| 823 | 875 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+50-5| ... | ... | @@ -399,6 +399,10 @@ function ThreadNavigationSidebarPane( |
| 399 | 399 | // crossed while the pane stays open; without a clock dependency the |
| 400 | 400 | // partition memoizes a frozen "now". |
| 401 | 401 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); |
| 402 | // Snooze wake times are second-precise; a counter bumped exactly at the | |
| 403 | // next wake boundary re-runs the partition with a fresh clock so a woken | |
| 404 | // thread reappears immediately instead of on the next minute tick. | |
| 405 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | |
| 402 | 406 | useEffect(() => { |
| 403 | 407 | if (!threadListV2Enabled) return; |
| 404 | 408 | // Refresh immediately on enable: the mount-time value can be hours old |
| ... | ... | @@ -420,8 +424,18 @@ function ThreadNavigationSidebarPane( |
| 420 | 424 | } |
| 421 | 425 | return supported; |
| 422 | 426 | }, [serverConfigs]); |
| 427 | const snoozeEnvironmentIds = useMemo(() => { | |
| 428 | const supported = new Set<EnvironmentId>(); | |
| 429 | for (const [environmentId, config] of serverConfigs) { | |
| 430 | if (config.environment.capabilities.threadSnooze === true) { | |
| 431 | supported.add(environmentId); | |
| 432 | } | |
| 433 | } | |
| 434 | return supported; | |
| 435 | }, [serverConfigs]); | |
| 423 | 436 | const threadListV2Layout = useMemo(() => { |
| 424 | if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; | |
| 437 | if (!threadListV2Enabled) | |
| 438 | return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; | |
| 425 | 439 | return buildThreadListV2Items({ |
| 426 | 440 | threads: threads.filter((thread) => thread.archivedAt === null), |
| 427 | 441 | environmentId: options.selectedEnvironmentId, |
| ... | ... | @@ -429,20 +443,38 @@ function ThreadNavigationSidebarPane( |
| 429 | 443 | searchQuery: props.searchQuery, |
| 430 | 444 | changeRequestStateByKey, |
| 431 | 445 | settlementEnvironmentIds, |
| 446 | snoozeEnvironmentIds, | |
| 432 | 447 | settledLimit: settledVisibleCount, |
| 433 | 448 | now: `${nowMinute}:00.000Z`, |
| 449 | snoozeNow: new Date().toISOString(), | |
| 434 | 450 | }); |
| 435 | 451 | }, [ |
| 436 | 452 | changeRequestStateByKey, |
| 437 | 453 | nowMinute, |
| 454 | snoozeWakeTick, | |
| 438 | 455 | options.selectedEnvironmentId, |
| 439 | 456 | props.searchQuery, |
| 440 | 457 | settledVisibleCount, |
| 441 | 458 | settlementEnvironmentIds, |
| 459 | snoozeEnvironmentIds, | |
| 442 | 460 | threadListV2Enabled, |
| 443 | 461 | threads, |
| 444 | 462 | selectedProjectScope, |
| 445 | 463 | ]); |
| 464 | // Re-partition the moment the earliest snooze expires (clamped to the | |
| 465 | // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). | |
| 466 | const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; | |
| 467 | useEffect(() => { | |
| 468 | if (nextSnoozeWakeAt === null) return; | |
| 469 | const wakeAtMs = Date.parse(nextSnoozeWakeAt); | |
| 470 | if (Number.isNaN(wakeAtMs)) return; | |
| 471 | const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); | |
| 472 | const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); | |
| 473 | return () => clearTimeout(id); | |
| 474 | // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is | |
| 475 | // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout | |
| 476 | // range) the boundary string is identical and the chain would die. | |
| 477 | }, [nextSnoozeWakeAt, snoozeWakeTick]); | |
| 446 | 478 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 447 | 479 | if (!threadListV2Enabled) return listLayout.items; |
| 448 | 480 | // Queued offline tasks render above the thread rows (mirrors the |
| ... | ... | @@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane( |
| 930 | 962 | }), |
| 931 | 963 | [filterIcon, filterMenu, props.onOpenSettings], |
| 932 | 964 | ); |
| 965 | // "No threads yet" over an inbox that is merely all-snoozed reads as | |
| 966 | // data loss; name the snoozed threads instead. | |
| 967 | const snoozedCount = threadListV2Layout.snoozedCount; | |
| 933 | 968 | const listEmpty = ( |
| 934 | 969 | <Text className="px-2 py-4 text-sm text-foreground-muted"> |
| 935 | 970 | {catalogState.isLoadingConnections |
| 936 | 971 | ? "Loading threads…" |
| 937 | 972 | : props.searchQuery.trim().length > 0 |
| 938 | ? "No matching threads" | |
| 939 | : selectedProjectScope !== null | |
| 940 | ? `No threads in ${selectedProjectScope.title}` | |
| 941 | : "No threads yet"} | |
| 973 | ? snoozedCount > 0 | |
| 974 | ? // Snoozed matches passed this same search filter — "No | |
| 975 | // matching threads" would misreport them as nonexistent. | |
| 976 | snoozedCount === 1 | |
| 977 | ? "1 matching thread snoozed" | |
| 978 | : "All matching threads snoozed" | |
| 979 | : "No matching threads" | |
| 980 | : snoozedCount > 0 | |
| 981 | ? snoozedCount === 1 | |
| 982 | ? "1 thread snoozed" | |
| 983 | : `${snoozedCount} threads snoozed` | |
| 984 | : selectedProjectScope !== null | |
| 985 | ? `No threads in ${selectedProjectScope.title}` | |
| 986 | : "No threads yet"} | |
| 942 | 987 | </Text> |
| 943 | 988 | ); |
| 944 | 989 |
apps/mobile/src/features/threads/threadListV2.test.ts+79| ... | ... | @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => { |
| 77 | 77 | }); |
| 78 | 78 | |
| 79 | 79 | describe("buildThreadListV2Items", () => { |
| 80 | it("hides snoozed threads and counts them — visibility parity with web", () => { | |
| 81 | const layout = buildThreadListV2Items({ | |
| 82 | threads: [ | |
| 83 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | |
| 84 | makeThread({ | |
| 85 | id: ThreadId.make("snoozed"), | |
| 86 | title: "Snoozed", | |
| 87 | snoozedUntil: "2026-06-03T09:00:00.000Z", | |
| 88 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 89 | }), | |
| 90 | makeThread({ | |
| 91 | id: ThreadId.make("woken"), | |
| 92 | title: "Woken", | |
| 93 | // Wake time already passed: back in the active list. | |
| 94 | snoozedUntil: "2026-06-01T18:00:00.000Z", | |
| 95 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 96 | }), | |
| 97 | ], | |
| 98 | environmentId: null, | |
| 99 | searchQuery: "", | |
| 100 | now: NOW, | |
| 101 | }); | |
| 102 | ||
| 103 | // Same createdAt → static sort tiebreaks by id; the point is the woken | |
| 104 | // thread is BACK in the card block and the snoozed one is gone. | |
| 105 | expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); | |
| 106 | expect(layout.snoozedCount).toBe(1); | |
| 107 | }); | |
| 108 | ||
| 109 | it("classifies snooze with the second-precise clock and reports the next wake", () => { | |
| 110 | const layout = buildThreadListV2Items({ | |
| 111 | threads: [ | |
| 112 | makeThread({ | |
| 113 | id: ThreadId.make("just-woke"), | |
| 114 | title: "Just woke", | |
| 115 | // Woke 30s ago: hidden under the minute-floored clock, visible | |
| 116 | // under the precise one. | |
| 117 | snoozedUntil: "2026-06-02T00:00:30.000Z", | |
| 118 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 119 | }), | |
| 120 | makeThread({ | |
| 121 | id: ThreadId.make("still-snoozed"), | |
| 122 | title: "Still snoozed", | |
| 123 | snoozedUntil: "2026-06-02T09:00:00.000Z", | |
| 124 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 125 | }), | |
| 126 | ], | |
| 127 | environmentId: null, | |
| 128 | searchQuery: "", | |
| 129 | // Minute-floored partition clock vs precise snooze clock. | |
| 130 | now: "2026-06-02T00:01:00.000Z", | |
| 131 | snoozeNow: "2026-06-02T00:01:07.500Z", | |
| 132 | }); | |
| 133 | ||
| 134 | expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); | |
| 135 | expect(layout.snoozedCount).toBe(1); | |
| 136 | expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); | |
| 137 | }); | |
| 138 | ||
| 139 | it("keeps snoozed threads visible on environments without the snooze capability", () => { | |
| 140 | const layout = buildThreadListV2Items({ | |
| 141 | threads: [ | |
| 142 | makeThread({ | |
| 143 | id: ThreadId.make("snoozed"), | |
| 144 | title: "Snoozed", | |
| 145 | snoozedUntil: "2026-06-03T09:00:00.000Z", | |
| 146 | snoozedAt: "2026-06-01T12:00:00.000Z", | |
| 147 | }), | |
| 148 | ], | |
| 149 | environmentId: null, | |
| 150 | searchQuery: "", | |
| 151 | snoozeEnvironmentIds: new Set(), | |
| 152 | now: NOW, | |
| 153 | }); | |
| 154 | ||
| 155 | expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]); | |
| 156 | expect(layout.snoozedCount).toBe(0); | |
| 157 | }); | |
| 158 | ||
| 80 | 159 | it("partitions settled threads into a slim tail with one divider", () => { |
| 81 | 160 | const { items } = buildThreadListV2Items({ |
| 82 | 161 | threads: [ |
apps/mobile/src/features/threads/threadListV2.ts+40-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | |
| 1 | import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 4 | 4 | |
| ... | ... | @@ -84,6 +84,13 @@ export interface ThreadListV2Layout { |
| 84 | 84 | readonly items: ThreadListV2Item[]; |
| 85 | 85 | /** Settled threads beyond the render limit (behind "Show more"). */ |
| 86 | 86 | readonly hiddenSettledCount: number; |
| 87 | /** Snoozed threads hidden from the list (visibility parity with web's | |
| 88 | collapsed Snoozed shelf; mobile has no shelf UI yet). */ | |
| 89 | readonly snoozedCount: number; | |
| 90 | /** Soonest wake time among hidden snoozed threads, or null. Callers arm | |
| 91 | a timeout at this boundary so the list re-partitions the moment a | |
| 92 | snooze expires instead of on the next minute tick. */ | |
| 93 | readonly nextSnoozeWakeAt: string | null; | |
| 87 | 94 | } |
| 88 | 95 | |
| 89 | 96 | /** |
| ... | ... | @@ -106,13 +113,22 @@ export function buildThreadListV2Items(input: { |
| 106 | 113 | other environments never classify as settled — the user could neither |
| 107 | 114 | un-settle nor pin them. Absent = no gating (tests). */ |
| 108 | 115 | readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 116 | /** Environments whose server supports thread.snooze/unsnooze. Same | |
| 117 | contract as settlementEnvironmentIds. */ | |
| 118 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | |
| 109 | 119 | readonly autoSettleAfterDays?: number; |
| 110 | 120 | /** Max settled rows to render; the rest are counted, not built. */ |
| 111 | 121 | readonly settledLimit?: number; |
| 112 | 122 | /** Injectable for tests; defaults to now. */ |
| 113 | 123 | readonly now?: string; |
| 124 | /** Second-precise clock for snooze classification. Callers pass a | |
| 125 | minute-quantized `now` for memoization; snooze wake times are | |
| 126 | second-precise, so classifying with the floored minute would hold a | |
| 127 | woken thread hidden for up to a minute. Defaults to `now`. */ | |
| 128 | readonly snoozeNow?: string; | |
| 114 | 129 | }): ThreadListV2Layout { |
| 115 | 130 | const now = input.now ?? new Date().toISOString(); |
| 131 | const snoozeNow = input.snoozeNow ?? now; | |
| 116 | 132 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; |
| 117 | 133 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 118 | 134 | const projectKeys = input.projectRefs |
| ... | ... | @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: { |
| 121 | 137 | |
| 122 | 138 | const active: EnvironmentThreadShell[] = []; |
| 123 | 139 | const settled: EnvironmentThreadShell[] = []; |
| 140 | let snoozedCount = 0; | |
| 141 | let nextSnoozeWakeAt: string | null = null; | |
| 124 | 142 | for (const thread of input.threads) { |
| 125 | 143 | // Callers pass live (unarchived) shells; settled threads are among them |
| 126 | 144 | // and partition into the tail via effectiveSettled. |
| ... | ... | @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: { |
| 130 | 148 | } |
| 131 | 149 | if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; |
| 132 | 150 | const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; |
| 151 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; | |
| 133 | 152 | const changeRequestState = |
| 134 | 153 | input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; |
| 154 | // Visibility parity with web: a snoozed thread leaves the list until it | |
| 155 | // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed | |
| 156 | // work). Snooze outranks settled classification, same as web. | |
| 157 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { | |
| 158 | snoozedCount += 1; | |
| 159 | if ( | |
| 160 | thread.snoozedUntil != null && | |
| 161 | (nextSnoozeWakeAt === null || | |
| 162 | parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) | |
| 163 | ) { | |
| 164 | nextSnoozeWakeAt = thread.snoozedUntil; | |
| 165 | } | |
| 166 | continue; | |
| 167 | } | |
| 135 | 168 | if ( |
| 136 | 169 | supportsSettlement && |
| 137 | 170 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) |
| ... | ... | @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: { |
| 168 | 201 | if (last) { |
| 169 | 202 | items[items.length - 1] = { ...last, isLast: true }; |
| 170 | 203 | } |
| 171 | return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; | |
| 204 | return { | |
| 205 | items, | |
| 206 | hiddenSettledCount: orderedSettled.length - visibleSettled.length, | |
| 207 | snoozedCount, | |
| 208 | nextSnoozeWakeAt, | |
| 209 | }; | |
| 172 | 210 | } |
apps/mobile/src/state/use-thread-selection.ts+2| ... | ... | @@ -60,6 +60,8 @@ function threadDetailToShell( |
| 60 | 60 | archivedAt: thread.archivedAt, |
| 61 | 61 | settledOverride: thread.settledOverride, |
| 62 | 62 | settledAt: thread.settledAt, |
| 63 | snoozedUntil: thread.snoozedUntil ?? null, | |
| 64 | snoozedAt: thread.snoozedAt ?? null, | |
| 63 | 65 | session: thread.session, |
| 64 | 66 | latestUserMessageAt: latestUserMessageAt(thread), |
| 65 | 67 | hasPendingApprovals: false, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { |
| 141 | 141 | repositoryIdentity: true, |
| 142 | 142 | connectionProbe: true, |
| 143 | 143 | threadSettlement: true, |
| 144 | threadSnooze: true, | |
| 144 | 145 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 145 | 146 | }, |
| 146 | 147 | }; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+34| ... | ... | @@ -609,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 609 | 609 | archivedAt: null, |
| 610 | 610 | settledOverride: null, |
| 611 | 611 | settledAt: null, |
| 612 | snoozedUntil: null, | |
| 613 | snoozedAt: null, | |
| 612 | 614 | latestUserMessageAt: null, |
| 613 | 615 | pendingApprovalCount: 0, |
| 614 | 616 | pendingUserInputCount: 0, |
| ... | ... | @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 679 | 681 | return; |
| 680 | 682 | } |
| 681 | 683 | |
| 684 | case "thread.snoozed": { | |
| 685 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 686 | threadId: event.payload.threadId, | |
| 687 | }); | |
| 688 | if (Option.isNone(existingRow)) { | |
| 689 | return; | |
| 690 | } | |
| 691 | yield* projectionThreadRepository.upsert({ | |
| 692 | ...existingRow.value, | |
| 693 | snoozedUntil: event.payload.snoozedUntil, | |
| 694 | snoozedAt: event.payload.snoozedAt, | |
| 695 | updatedAt: event.payload.updatedAt, | |
| 696 | }); | |
| 697 | return; | |
| 698 | } | |
| 699 | ||
| 700 | case "thread.unsnoozed": { | |
| 701 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 702 | threadId: event.payload.threadId, | |
| 703 | }); | |
| 704 | if (Option.isNone(existingRow)) { | |
| 705 | return; | |
| 706 | } | |
| 707 | yield* projectionThreadRepository.upsert({ | |
| 708 | ...existingRow.value, | |
| 709 | snoozedUntil: null, | |
| 710 | snoozedAt: null, | |
| 711 | updatedAt: event.payload.updatedAt, | |
| 712 | }); | |
| 713 | return; | |
| 714 | } | |
| 715 | ||
| 682 | 716 | case "thread.meta-updated": { |
| 683 | 717 | const existingRow = yield* projectionThreadRepository.getById({ |
| 684 | 718 | threadId: event.payload.threadId, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4| ... | ... | @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 310 | 310 | archivedAt: null, |
| 311 | 311 | settledOverride: null, |
| 312 | 312 | settledAt: null, |
| 313 | snoozedUntil: null, | |
| 314 | snoozedAt: null, | |
| 313 | 315 | deletedAt: null, |
| 314 | 316 | messages: [ |
| 315 | 317 | { |
| ... | ... | @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 422 | 424 | archivedAt: null, |
| 423 | 425 | settledOverride: null, |
| 424 | 426 | settledAt: null, |
| 427 | snoozedUntil: null, | |
| 428 | snoozedAt: null, | |
| 425 | 429 | session: { |
| 426 | 430 | threadId: ThreadId.make("thread-1"), |
| 427 | 431 | status: "running", |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+20| ... | ... | @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 336 | 336 | archived_at AS "archivedAt", |
| 337 | 337 | settled_override AS "settledOverride", |
| 338 | 338 | settled_at AS "settledAt", |
| 339 | snoozed_until AS "snoozedUntil", | |
| 340 | snoozed_at AS "snoozedAt", | |
| 339 | 341 | latest_user_message_at AS "latestUserMessageAt", |
| 340 | 342 | pending_approval_count AS "pendingApprovalCount", |
| 341 | 343 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 366 | 368 | archived_at AS "archivedAt", |
| 367 | 369 | settled_override AS "settledOverride", |
| 368 | 370 | settled_at AS "settledAt", |
| 371 | snoozed_until AS "snoozedUntil", | |
| 372 | snoozed_at AS "snoozedAt", | |
| 369 | 373 | latest_user_message_at AS "latestUserMessageAt", |
| 370 | 374 | pending_approval_count AS "pendingApprovalCount", |
| 371 | 375 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 398 | 402 | archived_at AS "archivedAt", |
| 399 | 403 | settled_override AS "settledOverride", |
| 400 | 404 | settled_at AS "settledAt", |
| 405 | snoozed_until AS "snoozedUntil", | |
| 406 | snoozed_at AS "snoozedAt", | |
| 401 | 407 | latest_user_message_at AS "latestUserMessageAt", |
| 402 | 408 | pending_approval_count AS "pendingApprovalCount", |
| 403 | 409 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 762 | 768 | archived_at AS "archivedAt", |
| 763 | 769 | settled_override AS "settledOverride", |
| 764 | 770 | settled_at AS "settledAt", |
| 771 | snoozed_until AS "snoozedUntil", | |
| 772 | snoozed_at AS "snoozedAt", | |
| 765 | 773 | latest_user_message_at AS "latestUserMessageAt", |
| 766 | 774 | pending_approval_count AS "pendingApprovalCount", |
| 767 | 775 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1196 | 1204 | archivedAt: row.archivedAt, |
| 1197 | 1205 | settledOverride: row.settledOverride, |
| 1198 | 1206 | settledAt: row.settledAt, |
| 1207 | snoozedUntil: row.snoozedUntil, | |
| 1208 | snoozedAt: row.snoozedAt, | |
| 1199 | 1209 | deletedAt: row.deletedAt, |
| 1200 | 1210 | messages: messagesByThread.get(row.threadId) ?? [], |
| 1201 | 1211 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
| ... | ... | @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1396 | 1406 | archivedAt: row.archivedAt, |
| 1397 | 1407 | settledOverride: row.settledOverride, |
| 1398 | 1408 | settledAt: row.settledAt, |
| 1409 | snoozedUntil: row.snoozedUntil, | |
| 1410 | snoozedAt: row.snoozedAt, | |
| 1399 | 1411 | deletedAt: row.deletedAt, |
| 1400 | 1412 | messages: [], |
| 1401 | 1413 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
| ... | ... | @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1527 | 1539 | archivedAt: row.archivedAt, |
| 1528 | 1540 | settledOverride: row.settledOverride, |
| 1529 | 1541 | settledAt: row.settledAt, |
| 1542 | snoozedUntil: row.snoozedUntil, | |
| 1543 | snoozedAt: row.snoozedAt, | |
| 1530 | 1544 | session: sessionByThread.get(row.threadId) ?? null, |
| 1531 | 1545 | latestUserMessageAt: row.latestUserMessageAt, |
| 1532 | 1546 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| ... | ... | @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1663 | 1677 | archivedAt: row.archivedAt, |
| 1664 | 1678 | settledOverride: row.settledOverride, |
| 1665 | 1679 | settledAt: row.settledAt, |
| 1680 | snoozedUntil: row.snoozedUntil, | |
| 1681 | snoozedAt: row.snoozedAt, | |
| 1666 | 1682 | session: sessionByThread.get(row.threadId) ?? null, |
| 1667 | 1683 | latestUserMessageAt: row.latestUserMessageAt, |
| 1668 | 1684 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| ... | ... | @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1905 | 1921 | archivedAt: threadRow.value.archivedAt, |
| 1906 | 1922 | settledOverride: threadRow.value.settledOverride, |
| 1907 | 1923 | settledAt: threadRow.value.settledAt, |
| 1924 | snoozedUntil: threadRow.value.snoozedUntil, | |
| 1925 | snoozedAt: threadRow.value.snoozedAt, | |
| 1908 | 1926 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, |
| 1909 | 1927 | latestUserMessageAt: threadRow.value.latestUserMessageAt, |
| 1910 | 1928 | hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, |
| ... | ... | @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2001 | 2019 | archivedAt: threadRow.value.archivedAt, |
| 2002 | 2020 | settledOverride: threadRow.value.settledOverride, |
| 2003 | 2021 | settledAt: threadRow.value.settledAt, |
| 2022 | snoozedUntil: threadRow.value.snoozedUntil, | |
| 2023 | snoozedAt: threadRow.value.snoozedAt, | |
| 2004 | 2024 | deletedAt: null, |
| 2005 | 2025 | messages: messageRows.map((row) => { |
| 2006 | 2026 | const message = { |
apps/server/src/orchestration/Schemas.ts+4| ... | ... | @@ -11,6 +11,8 @@ import { |
| 11 | 11 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, |
| 12 | 12 | ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, |
| 13 | 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, |
| 14 | ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, | |
| 15 | ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, | |
| 14 | 16 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 15 | 17 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 16 | 18 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | ... | @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet |
| 38 | 40 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 39 | 41 | export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; |
| 40 | 42 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; |
| 43 | export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; | |
| 44 | export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; | |
| 41 | 45 | |
| 42 | 46 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 43 | 47 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; |
apps/server/src/orchestration/decider.snoozed.test.ts created+286| ... | ... | @@ -0,0 +1,286 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ProviderInstanceId, | |
| 7 | ThreadId, | |
| 8 | type OrchestrationReadModel, | |
| 9 | type OrchestrationThread, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 12 | import { expect, it } from "@effect/vitest"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | ||
| 15 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 16 | ||
| 17 | const NOW = "2026-01-01T00:00:00.000Z"; | |
| 18 | // The decider's clock is the Effect test clock, pinned to the epoch, so | |
| 19 | // "future" wake times are relative to 1970-01-01T00:00:00.000Z. | |
| 20 | const FUTURE_WAKE = "1970-01-02T09:00:00.000Z"; | |
| 21 | const PAST_WAKE = "1969-12-31T09:00:00.000Z"; | |
| 22 | const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; | |
| 23 | ||
| 24 | function makeReadModel(input: { | |
| 25 | readonly snoozedUntil?: string | null; | |
| 26 | readonly snoozedAt?: string | null; | |
| 27 | readonly archivedAt?: string | null; | |
| 28 | readonly activities?: OrchestrationThread["activities"]; | |
| 29 | readonly messages?: OrchestrationThread["messages"]; | |
| 30 | }): OrchestrationReadModel { | |
| 31 | return { | |
| 32 | snapshotSequence: 0, | |
| 33 | projects: [], | |
| 34 | threads: [ | |
| 35 | { | |
| 36 | id: ThreadId.make("thread-1"), | |
| 37 | projectId: ProjectId.make("project-1"), | |
| 38 | title: "Thread", | |
| 39 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 40 | runtimeMode: "full-access", | |
| 41 | interactionMode: "default", | |
| 42 | branch: null, | |
| 43 | worktreePath: null, | |
| 44 | latestTurn: null, | |
| 45 | createdAt: NOW, | |
| 46 | updatedAt: NOW, | |
| 47 | archivedAt: input.archivedAt ?? null, | |
| 48 | settledOverride: null, | |
| 49 | settledAt: null, | |
| 50 | snoozedUntil: input.snoozedUntil ?? null, | |
| 51 | snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), | |
| 52 | deletedAt: null, | |
| 53 | messages: input.messages ?? [], | |
| 54 | proposedPlans: [], | |
| 55 | activities: input.activities ?? [], | |
| 56 | checkpoints: [], | |
| 57 | session: null, | |
| 58 | }, | |
| 59 | ], | |
| 60 | updatedAt: NOW, | |
| 61 | }; | |
| 62 | } | |
| 63 | ||
| 64 | it.layer(NodeServices.layer)("snoozed thread decider", (it) => { | |
| 65 | it.effect("snoozes a thread to a future wake time", () => | |
| 66 | Effect.gen(function* () { | |
| 67 | const event = yield* decideOrchestrationCommand({ | |
| 68 | command: { | |
| 69 | type: "thread.snooze", | |
| 70 | commandId: CommandId.make("cmd-snooze"), | |
| 71 | threadId: ThreadId.make("thread-1"), | |
| 72 | snoozedUntil: FUTURE_WAKE, | |
| 73 | }, | |
| 74 | readModel: makeReadModel({}), | |
| 75 | }); | |
| 76 | const events = Array.isArray(event) ? event : [event]; | |
| 77 | expect(events).toHaveLength(1); | |
| 78 | expect(events[0]?.type).toBe("thread.snoozed"); | |
| 79 | if (events[0]?.type === "thread.snoozed") { | |
| 80 | expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE); | |
| 81 | expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt); | |
| 82 | } | |
| 83 | }), | |
| 84 | ); | |
| 85 | ||
| 86 | it.effect("rejects a wake time that is not in the future", () => | |
| 87 | Effect.gen(function* () { | |
| 88 | const error = yield* decideOrchestrationCommand({ | |
| 89 | command: { | |
| 90 | type: "thread.snooze", | |
| 91 | commandId: CommandId.make("cmd-snooze-past"), | |
| 92 | threadId: ThreadId.make("thread-1"), | |
| 93 | snoozedUntil: PAST_WAKE, | |
| 94 | }, | |
| 95 | readModel: makeReadModel({}), | |
| 96 | }).pipe(Effect.flip); | |
| 97 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | it.effect("rejects an unparseable wake time", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | // IsoDateTime is structurally a string, so garbage can reach the | |
| 104 | // decider; a NaN wake time must never persist as snooze state. | |
| 105 | const error = yield* decideOrchestrationCommand({ | |
| 106 | command: { | |
| 107 | type: "thread.snooze", | |
| 108 | commandId: CommandId.make("cmd-snooze-garbage"), | |
| 109 | threadId: ThreadId.make("thread-1"), | |
| 110 | snoozedUntil: "not-a-date", | |
| 111 | }, | |
| 112 | readModel: makeReadModel({}), | |
| 113 | }).pipe(Effect.flip); | |
| 114 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 115 | }), | |
| 116 | ); | |
| 117 | ||
| 118 | it.effect("rejects snoozing blocked-on-you work", () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const requestActivity = { | |
| 121 | id: EventId.make("activity-req-1"), | |
| 122 | tone: "approval" as const, | |
| 123 | kind: "approval.requested", | |
| 124 | summary: "approval.requested", | |
| 125 | payload: { requestId: "req-1" }, | |
| 126 | turnId: null, | |
| 127 | createdAt: NOW, | |
| 128 | } as OrchestrationThread["activities"][number]; | |
| 129 | const error = yield* decideOrchestrationCommand({ | |
| 130 | command: { | |
| 131 | type: "thread.snooze", | |
| 132 | commandId: CommandId.make("cmd-snooze-blocked"), | |
| 133 | threadId: ThreadId.make("thread-1"), | |
| 134 | snoozedUntil: FUTURE_WAKE, | |
| 135 | }, | |
| 136 | readModel: makeReadModel({ activities: [requestActivity] }), | |
| 137 | }).pipe(Effect.flip); | |
| 138 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | const reEmit = yield* decideOrchestrationCommand({ | |
| 145 | command: { | |
| 146 | type: "thread.snooze", | |
| 147 | commandId: CommandId.make("cmd-snooze-again"), | |
| 148 | threadId: ThreadId.make("thread-1"), | |
| 149 | snoozedUntil: FUTURE_WAKE, | |
| 150 | }, | |
| 151 | readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), | |
| 152 | }); | |
| 153 | const events = Array.isArray(reEmit) ? reEmit : [reEmit]; | |
| 154 | expect(events).toHaveLength(1); | |
| 155 | if (events[0]?.type === "thread.snoozed") { | |
| 156 | // Original snoozedAt preserved; updatedAt must not churn. | |
| 157 | expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT); | |
| 158 | expect(events[0].payload.updatedAt).toBe(NOW); | |
| 159 | } | |
| 160 | }), | |
| 161 | ); | |
| 162 | ||
| 163 | it.effect("re-snoozing to a DIFFERENT wake time stamps fresh", () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const event = yield* decideOrchestrationCommand({ | |
| 166 | command: { | |
| 167 | type: "thread.snooze", | |
| 168 | commandId: CommandId.make("cmd-snooze-extend"), | |
| 169 | threadId: ThreadId.make("thread-1"), | |
| 170 | snoozedUntil: "1970-01-03T09:00:00.000Z", | |
| 171 | }, | |
| 172 | readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), | |
| 173 | }); | |
| 174 | const events = Array.isArray(event) ? event : [event]; | |
| 175 | if (events[0]?.type === "thread.snoozed") { | |
| 176 | expect(events[0].payload.snoozedUntil).toBe("1970-01-03T09:00:00.000Z"); | |
| 177 | expect(events[0].payload.updatedAt).not.toBe(NOW); | |
| 178 | } | |
| 179 | }), | |
| 180 | ); | |
| 181 | ||
| 182 | it.effect("unsnoozes with reason user and re-emits idempotently when awake", () => | |
| 183 | Effect.gen(function* () { | |
| 184 | const event = yield* decideOrchestrationCommand({ | |
| 185 | command: { | |
| 186 | type: "thread.unsnooze", | |
| 187 | commandId: CommandId.make("cmd-unsnooze"), | |
| 188 | threadId: ThreadId.make("thread-1"), | |
| 189 | reason: "user", | |
| 190 | }, | |
| 191 | readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), | |
| 192 | }); | |
| 193 | const events = Array.isArray(event) ? event : [event]; | |
| 194 | expect(events[0]?.type).toBe("thread.unsnoozed"); | |
| 195 | if (events[0]?.type === "thread.unsnoozed") { | |
| 196 | expect(events[0].payload.reason).toBe("user"); | |
| 197 | expect(events[0].payload.updatedAt).not.toBe(NOW); | |
| 198 | } | |
| 199 | ||
| 200 | const awake = yield* decideOrchestrationCommand({ | |
| 201 | command: { | |
| 202 | type: "thread.unsnooze", | |
| 203 | commandId: CommandId.make("cmd-unsnooze-awake"), | |
| 204 | threadId: ThreadId.make("thread-1"), | |
| 205 | reason: "user", | |
| 206 | }, | |
| 207 | readModel: makeReadModel({}), | |
| 208 | }); | |
| 209 | const awakeEvents = Array.isArray(awake) ? awake : [awake]; | |
| 210 | expect(awakeEvents[0]?.type).toBe("thread.unsnoozed"); | |
| 211 | if (awakeEvents[0]?.type === "thread.unsnoozed") { | |
| 212 | // No state change — keep the existing updatedAt. | |
| 213 | expect(awakeEvents[0].payload.updatedAt).toBe(NOW); | |
| 214 | } | |
| 215 | }), | |
| 216 | ); | |
| 217 | ||
| 218 | it.effect("rejects snoozing a thread with a queued turn start", () => | |
| 219 | Effect.gen(function* () { | |
| 220 | // The decider clock is the Effect test clock pinned to the epoch: a | |
| 221 | // user message 30s before it with no adopting turn is queued work. | |
| 222 | const queuedMessage = { | |
| 223 | id: MessageId.make("message-queued"), | |
| 224 | role: "user", | |
| 225 | text: "Continue", | |
| 226 | turnId: null, | |
| 227 | streaming: false, | |
| 228 | createdAt: "1969-12-31T23:59:30.000Z", | |
| 229 | updatedAt: "1969-12-31T23:59:30.000Z", | |
| 230 | } as OrchestrationThread["messages"][number]; | |
| 231 | const error = yield* decideOrchestrationCommand({ | |
| 232 | command: { | |
| 233 | type: "thread.snooze", | |
| 234 | commandId: CommandId.make("cmd-snooze-queued"), | |
| 235 | threadId: ThreadId.make("thread-1"), | |
| 236 | snoozedUntil: FUTURE_WAKE, | |
| 237 | }, | |
| 238 | readModel: makeReadModel({ messages: [queuedMessage] }), | |
| 239 | }).pipe(Effect.flip); | |
| 240 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 241 | }), | |
| 242 | ); | |
| 243 | ||
| 244 | it.effect("rejects snoozing an archived thread", () => | |
| 245 | Effect.gen(function* () { | |
| 246 | const error = yield* decideOrchestrationCommand({ | |
| 247 | command: { | |
| 248 | type: "thread.snooze", | |
| 249 | commandId: CommandId.make("cmd-snooze-archived"), | |
| 250 | threadId: ThreadId.make("thread-1"), | |
| 251 | snoozedUntil: FUTURE_WAKE, | |
| 252 | }, | |
| 253 | readModel: makeReadModel({ archivedAt: NOW }), | |
| 254 | }).pipe(Effect.flip); | |
| 255 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 256 | }), | |
| 257 | ); | |
| 258 | ||
| 259 | it.effect("a user message spends the snooze return ticket (activity wake)", () => | |
| 260 | Effect.gen(function* () { | |
| 261 | const result = yield* decideOrchestrationCommand({ | |
| 262 | command: { | |
| 263 | type: "thread.turn.start", | |
| 264 | commandId: CommandId.make("cmd-turn-start"), | |
| 265 | threadId: ThreadId.make("thread-1"), | |
| 266 | message: { | |
| 267 | messageId: MessageId.make("message-1"), | |
| 268 | role: "user", | |
| 269 | text: "Continue", | |
| 270 | attachments: [], | |
| 271 | }, | |
| 272 | runtimeMode: "full-access", | |
| 273 | interactionMode: "default", | |
| 274 | createdAt: NOW, | |
| 275 | }, | |
| 276 | readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), | |
| 277 | }); | |
| 278 | const events = Array.isArray(result) ? result : [result]; | |
| 279 | const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); | |
| 280 | expect(unsnoozed).toBeDefined(); | |
| 281 | if (unsnoozed?.type === "thread.unsnoozed") { | |
| 282 | expect(unsnoozed.payload.reason).toBe("activity"); | |
| 283 | } | |
| 284 | }), | |
| 285 | ); | |
| 286 | }); |
apps/server/src/orchestration/decider.ts+203-58| ... | ... | @@ -53,6 +53,13 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b |
| 53 | 53 | ); |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | // Scans the read model's activities, which the projector caps at the most | |
| 57 | // recent 500. That bound is safe here: an OPEN approval/user-input request | |
| 58 | // blocks its turn, so the thread cannot accumulate hundreds of later | |
| 59 | // activities while one is outstanding — a request that has scrolled out of | |
| 60 | // the window is one whose turn kept running, i.e. it was resolved or went | |
| 61 | // stale. (The projection pipeline's pendingApprovalCount reads the same | |
| 62 | // capped stream and stays consistent with this view.) | |
| 56 | 63 | function hasOpenBlockingRequest(thread: { |
| 57 | 64 | readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; |
| 58 | 65 | }): boolean { |
| ... | ... | @@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: { |
| 79 | 86 | return openRequestIds.size > 0; |
| 80 | 87 | } |
| 81 | 88 | |
| 89 | /** | |
| 90 | * A queued turn start — a user message no turn has picked up yet — is work | |
| 91 | * in flight even though session is still null (turn.start emits | |
| 92 | * message-sent + turn-start-requested; the session arrives later). Detection | |
| 93 | * mirrors the client's hasQueuedTurnStart: the newest user message is | |
| 94 | * strictly newer than every latestTurn timestamp (adoption stamps the new | |
| 95 | * turn's requestedAt with the message time, clearing this), and only within | |
| 96 | * the adoption grace window — historical threads whose last user message | |
| 97 | * postdates their turn timestamps (older-server data, mid-turn messages) | |
| 98 | * must not be blocked forever. A failed session start (status "error") | |
| 99 | * clears the block immediately. | |
| 100 | * | |
| 101 | * The age check is bounded on BOTH sides: message timestamps are | |
| 102 | * client-supplied, so a client clock ahead of the server yields a negative | |
| 103 | * age. Without the lower bound that negative age satisfies `<= grace` for | |
| 104 | * as long as the skew lasts, extending the block far past the intended two | |
| 105 | * minutes. | |
| 106 | */ | |
| 107 | function threadHasQueuedTurnStart( | |
| 108 | thread: { | |
| 109 | readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; | |
| 110 | readonly latestTurn: { | |
| 111 | readonly requestedAt: string; | |
| 112 | readonly startedAt: string | null; | |
| 113 | readonly completedAt: string | null; | |
| 114 | } | null; | |
| 115 | readonly session: { readonly status: string } | null; | |
| 116 | }, | |
| 117 | occurredAt: string, | |
| 118 | ): boolean { | |
| 119 | const latestUserMessageAtMs = thread.messages.reduce( | |
| 120 | (latest, message) => | |
| 121 | message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, | |
| 122 | Number.NEGATIVE_INFINITY, | |
| 123 | ); | |
| 124 | const latestTurnAtMs = | |
| 125 | thread.latestTurn === null | |
| 126 | ? Number.NEGATIVE_INFINITY | |
| 127 | : Math.max( | |
| 128 | ...[ | |
| 129 | thread.latestTurn.requestedAt, | |
| 130 | thread.latestTurn.startedAt, | |
| 131 | thread.latestTurn.completedAt, | |
| 132 | ].map((candidate) => | |
| 133 | candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), | |
| 134 | ), | |
| 135 | ); | |
| 136 | const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; | |
| 137 | return ( | |
| 138 | thread.session?.status !== "error" && | |
| 139 | Number.isFinite(latestUserMessageAtMs) && | |
| 140 | latestUserMessageAtMs > latestTurnAtMs && | |
| 141 | Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 82 | 145 | function withEventBase( |
| 83 | 146 | input: Pick<OrchestrationCommand, "commandId"> & { |
| 84 | 147 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| ... | ... | @@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 411 | 474 | ); |
| 412 | 475 | } |
| 413 | 476 | const occurredAt = yield* nowIso; |
| 414 | // A queued turn start — a user message no turn has picked up yet — is | |
| 415 | // work in flight even though session is still null (turn.start emits | |
| 416 | // message-sent + turn-start-requested; the session arrives later). | |
| 417 | // Settling in that window would hide just-requested work. Detection | |
| 418 | // mirrors the client's hasQueuedTurnStart: the newest user message is | |
| 419 | // strictly newer than every latestTurn timestamp (adoption stamps the | |
| 420 | // new turn's requestedAt with the message time, clearing this), and | |
| 421 | // only within the adoption grace window — historical threads whose | |
| 422 | // last user message postdates their turn timestamps (older-server | |
| 423 | // data, mid-turn messages) must stay settleable. A failed session | |
| 424 | // start (status "error") clears the block immediately. | |
| 425 | const latestUserMessageAtMs = thread.messages.reduce( | |
| 426 | (latest, message) => | |
| 427 | message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, | |
| 428 | Number.NEGATIVE_INFINITY, | |
| 429 | ); | |
| 430 | const latestTurnAtMs = | |
| 431 | thread.latestTurn === null | |
| 432 | ? Number.NEGATIVE_INFINITY | |
| 433 | : Math.max( | |
| 434 | ...[ | |
| 435 | thread.latestTurn.requestedAt, | |
| 436 | thread.latestTurn.startedAt, | |
| 437 | thread.latestTurn.completedAt, | |
| 438 | ].map((candidate) => | |
| 439 | candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), | |
| 440 | ), | |
| 441 | ); | |
| 442 | // The age check is bounded on BOTH sides: message timestamps are | |
| 443 | // client-supplied, so a client clock ahead of the server yields a | |
| 444 | // negative age. Without the lower bound that negative age satisfies | |
| 445 | // `<= grace` for as long as the skew lasts, extending the settle | |
| 446 | // block far past the intended two minutes. | |
| 447 | const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; | |
| 448 | const hasQueuedTurnStart = | |
| 449 | thread.session?.status !== "error" && | |
| 450 | Number.isFinite(latestUserMessageAtMs) && | |
| 451 | latestUserMessageAtMs > latestTurnAtMs && | |
| 452 | Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; | |
| 453 | if (hasQueuedTurnStart) { | |
| 477 | // Settling inside the adoption window would hide just-requested work. | |
| 478 | if (threadHasQueuedTurnStart(thread, occurredAt)) { | |
| 454 | 479 | return yield* Effect.fail( |
| 455 | 480 | new OrchestrationCommandInvariantError({ |
| 456 | 481 | commandType: command.type, |
| ... | ... | @@ -508,6 +533,103 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 508 | 533 | }; |
| 509 | 534 | } |
| 510 | 535 | |
| 536 | case "thread.snooze": { | |
| 537 | const thread = yield* requireThreadNotArchived({ | |
| 538 | readModel, | |
| 539 | command, | |
| 540 | threadId: command.threadId, | |
| 541 | }); | |
| 542 | const occurredAt = yield* nowIso; | |
| 543 | // A wake time in the past would create a thread that is snoozed and | |
| 544 | // woken at once — the row would never leave the inbox but still carry | |
| 545 | // snooze state. Reject instead of silently normalizing. The negated | |
| 546 | // comparison also catches unparseable wake times (IsoDateTime is | |
| 547 | // structurally just a string): NaN fails every comparison, and an | |
| 548 | // unparseable snoozedUntil must never persist. | |
| 549 | if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { | |
| 550 | return yield* Effect.fail( | |
| 551 | new OrchestrationCommandInvariantError({ | |
| 552 | commandType: command.type, | |
| 553 | detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, | |
| 554 | }), | |
| 555 | ); | |
| 556 | } | |
| 557 | // Blocked-on-you work must not be snoozed away: a pending approval or | |
| 558 | // user-input request is the agent waiting on the user, and hiding it | |
| 559 | // defeats the request. (A running session IS snoozable — snooze only | |
| 560 | // affects visibility, never the agent.) | |
| 561 | if (hasOpenBlockingRequest(thread)) { | |
| 562 | return yield* Effect.fail( | |
| 563 | new OrchestrationCommandInvariantError({ | |
| 564 | commandType: command.type, | |
| 565 | detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, | |
| 566 | }), | |
| 567 | ); | |
| 568 | } | |
| 569 | // A queued turn start — a user message no turn has adopted yet — is | |
| 570 | // invisible pending work: no session, no pending flags. Snoozing in | |
| 571 | // that window would hide a just-requested turn exactly the way settle | |
| 572 | // would. | |
| 573 | if (threadHasQueuedTurnStart(thread, occurredAt)) { | |
| 574 | return yield* Effect.fail( | |
| 575 | new OrchestrationCommandInvariantError({ | |
| 576 | commandType: command.type, | |
| 577 | detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, | |
| 578 | }), | |
| 579 | ); | |
| 580 | } | |
| 581 | // Re-snoozing an already-snoozed thread to the SAME wake time is a | |
| 582 | // duplicate (double-click, raced clients): re-emit with the original | |
| 583 | // timestamps so the projection is a no-op. A different wake time is a | |
| 584 | // real change and stamps fresh. | |
| 585 | const existingSnoozedAt = | |
| 586 | thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null | |
| 587 | ? thread.snoozedAt | |
| 588 | : null; | |
| 589 | return { | |
| 590 | ...(yield* withEventBase({ | |
| 591 | aggregateKind: "thread", | |
| 592 | aggregateId: command.threadId, | |
| 593 | occurredAt, | |
| 594 | commandId: command.commandId, | |
| 595 | })), | |
| 596 | type: "thread.snoozed", | |
| 597 | payload: { | |
| 598 | threadId: command.threadId, | |
| 599 | snoozedUntil: command.snoozedUntil, | |
| 600 | snoozedAt: existingSnoozedAt ?? occurredAt, | |
| 601 | updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, | |
| 602 | }, | |
| 603 | }; | |
| 604 | } | |
| 605 | ||
| 606 | case "thread.unsnooze": { | |
| 607 | const thread = yield* requireThreadNotArchived({ | |
| 608 | readModel, | |
| 609 | command, | |
| 610 | threadId: command.threadId, | |
| 611 | }); | |
| 612 | // Idempotent by re-emission (see thread.settle): waking a thread that | |
| 613 | // is not snoozed lands on the same null state without churning | |
| 614 | // updatedAt. | |
| 615 | const alreadyAwake = thread.snoozedUntil == null; | |
| 616 | const occurredAt = yield* nowIso; | |
| 617 | return { | |
| 618 | ...(yield* withEventBase({ | |
| 619 | aggregateKind: "thread", | |
| 620 | aggregateId: command.threadId, | |
| 621 | occurredAt, | |
| 622 | commandId: command.commandId, | |
| 623 | })), | |
| 624 | type: "thread.unsnoozed", | |
| 625 | payload: { | |
| 626 | threadId: command.threadId, | |
| 627 | reason: command.reason, | |
| 628 | updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, | |
| 629 | }, | |
| 630 | }; | |
| 631 | } | |
| 632 | ||
| 511 | 633 | case "thread.meta.update": { |
| 512 | 634 | const thread = yield* requireThread({ |
| 513 | 635 | readModel, |
| ... | ... | @@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 663 | 785 | // Real activity resets ANY override: it wakes an explicitly settled |
| 664 | 786 | // thread, and it clears a keep-active pin back to neutral so the |
| 665 | 787 | // thread can auto-settle again after this burst of work goes stale. |
| 666 | if (targetThread.settledOverride === null) { | |
| 667 | return [userMessageEvent, turnStartRequestedEvent]; | |
| 788 | // A snooze clears the same way — sending a message to a snoozed | |
| 789 | // thread is the user re-engaging, so the return ticket is spent. | |
| 790 | const lifecycleResetEvents: Array<Omit<OrchestrationEvent, "sequence">> = []; | |
| 791 | if (targetThread.settledOverride !== null) { | |
| 792 | lifecycleResetEvents.push({ | |
| 793 | ...(yield* withEventBase({ | |
| 794 | aggregateKind: "thread", | |
| 795 | aggregateId: command.threadId, | |
| 796 | occurredAt: command.createdAt, | |
| 797 | commandId: command.commandId, | |
| 798 | })), | |
| 799 | type: "thread.unsettled", | |
| 800 | payload: { | |
| 801 | threadId: command.threadId, | |
| 802 | reason: "activity", | |
| 803 | updatedAt: command.createdAt, | |
| 804 | }, | |
| 805 | }); | |
| 668 | 806 | } |
| 669 | const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = { | |
| 670 | ...(yield* withEventBase({ | |
| 671 | aggregateKind: "thread", | |
| 672 | aggregateId: command.threadId, | |
| 673 | occurredAt: command.createdAt, | |
| 674 | commandId: command.commandId, | |
| 675 | })), | |
| 676 | type: "thread.unsettled", | |
| 677 | payload: { | |
| 678 | threadId: command.threadId, | |
| 679 | reason: "activity", | |
| 680 | updatedAt: command.createdAt, | |
| 681 | }, | |
| 682 | }; | |
| 683 | return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; | |
| 807 | if (targetThread.snoozedUntil != null) { | |
| 808 | lifecycleResetEvents.push({ | |
| 809 | ...(yield* withEventBase({ | |
| 810 | aggregateKind: "thread", | |
| 811 | aggregateId: command.threadId, | |
| 812 | occurredAt: command.createdAt, | |
| 813 | commandId: command.commandId, | |
| 814 | })), | |
| 815 | type: "thread.unsnoozed", | |
| 816 | payload: { | |
| 817 | threadId: command.threadId, | |
| 818 | reason: "activity", | |
| 819 | updatedAt: command.createdAt, | |
| 820 | }, | |
| 821 | }); | |
| 822 | } | |
| 823 | return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; | |
| 684 | 824 | } |
| 685 | 825 | |
| 686 | 826 | case "thread.turn.interrupt": { |
| ... | ... | @@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 822 | 962 | }; |
| 823 | 963 | // Only a session coming alive is activity worth waking a settled thread |
| 824 | 964 | // for — status writes like ready/stopped/error arrive after the fact and |
| 825 | // must not fight a user's explicit settle. | |
| 965 | // must not fight a user's explicit settle. Snooze is deliberately NOT | |
| 966 | // cleared here: snooze never pauses the agent, so its session starting | |
| 967 | // or erroring is not the user re-engaging. Blocked/failed work still | |
| 968 | // surfaces immediately — effectiveSnoozed refuses to classify a thread | |
| 969 | // with a raised hand (approval / input / failure / fresh completion) | |
| 970 | // as snoozed, without spending the return ticket. | |
| 826 | 971 | const isSessionActivity = |
| 827 | 972 | command.session.status === "starting" || command.session.status === "running"; |
| 828 | 973 | // Real activity resets ANY override (settled wakes, active unpins). |
apps/server/src/orchestration/projector.test.ts+2| ... | ... | @@ -91,6 +91,8 @@ describe("orchestration projector", () => { |
| 91 | 91 | archivedAt: null, |
| 92 | 92 | settledOverride: null, |
| 93 | 93 | settledAt: null, |
| 94 | snoozedUntil: null, | |
| 95 | snoozedAt: null, | |
| 94 | 96 | deletedAt: null, |
| 95 | 97 | messages: [], |
| 96 | 98 | proposedPlans: [], |
apps/server/src/orchestration/projector.ts+28| ... | ... | @@ -23,8 +23,10 @@ import { |
| 23 | 23 | ThreadProposedPlanUpsertedPayload, |
| 24 | 24 | ThreadRuntimeModeSetPayload, |
| 25 | 25 | ThreadSettledPayload, |
| 26 | ThreadSnoozedPayload, | |
| 26 | 27 | ThreadUnarchivedPayload, |
| 27 | 28 | ThreadUnsettledPayload, |
| 29 | ThreadUnsnoozedPayload, | |
| 28 | 30 | ThreadRevertedPayload, |
| 29 | 31 | ThreadSessionSetPayload, |
| 30 | 32 | ThreadTurnDiffCompletedPayload, |
| ... | ... | @@ -290,6 +292,8 @@ export function projectEvent( |
| 290 | 292 | archivedAt: null, |
| 291 | 293 | settledOverride: null, |
| 292 | 294 | settledAt: null, |
| 295 | snoozedUntil: null, | |
| 296 | snoozedAt: null, | |
| 293 | 297 | deletedAt: null, |
| 294 | 298 | messages: [], |
| 295 | 299 | activities: [], |
| ... | ... | @@ -365,6 +369,30 @@ export function projectEvent( |
| 365 | 369 | })), |
| 366 | 370 | ); |
| 367 | 371 | |
| 372 | case "thread.snoozed": | |
| 373 | return decodeForEvent(ThreadSnoozedPayload, event.payload, event.type, "payload").pipe( | |
| 374 | Effect.map((payload) => ({ | |
| 375 | ...nextBase, | |
| 376 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 377 | snoozedUntil: payload.snoozedUntil, | |
| 378 | snoozedAt: payload.snoozedAt, | |
| 379 | updatedAt: payload.updatedAt, | |
| 380 | }), | |
| 381 | })), | |
| 382 | ); | |
| 383 | ||
| 384 | case "thread.unsnoozed": | |
| 385 | return decodeForEvent(ThreadUnsnoozedPayload, event.payload, event.type, "payload").pipe( | |
| 386 | Effect.map((payload) => ({ | |
| 387 | ...nextBase, | |
| 388 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 389 | snoozedUntil: null, | |
| 390 | snoozedAt: null, | |
| 391 | updatedAt: payload.updatedAt, | |
| 392 | }), | |
| 393 | })), | |
| 394 | ); | |
| 395 | ||
| 368 | 396 | case "thread.meta-updated": |
| 369 | 397 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( |
| 370 | 398 | Effect.map((payload) => ({ |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+12-1| ... | ... | @@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 93 | 93 | archivedAt: null, |
| 94 | 94 | settledOverride: null, |
| 95 | 95 | settledAt: null, |
| 96 | snoozedUntil: null, | |
| 97 | snoozedAt: null, | |
| 96 | 98 | latestUserMessageAt: null, |
| 97 | 99 | pendingApprovalCount: 0, |
| 98 | 100 | pendingUserInputCount: 0, |
| ... | ... | @@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 153 | 155 | archivedAt: null, |
| 154 | 156 | settledOverride: "settled", |
| 155 | 157 | settledAt: "2026-03-25T00:00:00.000Z", |
| 158 | snoozedUntil: "2026-03-26T09:00:00.000Z", | |
| 159 | snoozedAt: "2026-03-25T00:00:00.000Z", | |
| 156 | 160 | latestUserMessageAt: null, |
| 157 | 161 | pendingApprovalCount: 0, |
| 158 | 162 | pendingUserInputCount: 0, |
| ... | ... | @@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 169 | 173 | } |
| 170 | 174 | assert.strictEqual(row.settledOverride, "settled"); |
| 171 | 175 | assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); |
| 176 | assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); | |
| 177 | assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); | |
| 172 | 178 | |
| 173 | // Un-settle to the keep-active pin and confirm the flip persists. | |
| 179 | // Un-settle to the keep-active pin and wake the snooze; confirm the | |
| 180 | // flips persist. | |
| 174 | 181 | yield* threads.upsert({ |
| 175 | 182 | ...row, |
| 176 | 183 | settledOverride: "active", |
| 177 | 184 | settledAt: null, |
| 185 | snoozedUntil: null, | |
| 186 | snoozedAt: null, | |
| 178 | 187 | }); |
| 179 | 188 | const repersisted = yield* threads.getById({ |
| 180 | 189 | threadId: ThreadId.make("thread-settled"), |
| ... | ... | @@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 182 | 191 | const updated = Option.getOrNull(repersisted); |
| 183 | 192 | assert.strictEqual(updated?.settledOverride, "active"); |
| 184 | 193 | assert.strictEqual(updated?.settledAt, null); |
| 194 | assert.strictEqual(updated?.snoozedUntil, null); | |
| 195 | assert.strictEqual(updated?.snoozedAt, null); | |
| 185 | 196 | }), |
| 186 | 197 | ); |
| 187 | 198 | }); |
apps/server/src/persistence/Layers/ProjectionThreads.ts+10| ... | ... | @@ -45,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 45 | 45 | archived_at, |
| 46 | 46 | settled_override, |
| 47 | 47 | settled_at, |
| 48 | snoozed_until, | |
| 49 | snoozed_at, | |
| 48 | 50 | latest_user_message_at, |
| 49 | 51 | pending_approval_count, |
| 50 | 52 | pending_user_input_count, |
| ... | ... | @@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 66 | 68 | ${row.archivedAt}, |
| 67 | 69 | ${row.settledOverride}, |
| 68 | 70 | ${row.settledAt}, |
| 71 | ${row.snoozedUntil}, | |
| 72 | ${row.snoozedAt}, | |
| 69 | 73 | ${row.latestUserMessageAt}, |
| 70 | 74 | ${row.pendingApprovalCount}, |
| 71 | 75 | ${row.pendingUserInputCount}, |
| ... | ... | @@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 87 | 91 | archived_at = excluded.archived_at, |
| 88 | 92 | settled_override = excluded.settled_override, |
| 89 | 93 | settled_at = excluded.settled_at, |
| 94 | snoozed_until = excluded.snoozed_until, | |
| 95 | snoozed_at = excluded.snoozed_at, | |
| 90 | 96 | latest_user_message_at = excluded.latest_user_message_at, |
| 91 | 97 | pending_approval_count = excluded.pending_approval_count, |
| 92 | 98 | pending_user_input_count = excluded.pending_user_input_count, |
| ... | ... | @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 115 | 121 | archived_at AS "archivedAt", |
| 116 | 122 | settled_override AS "settledOverride", |
| 117 | 123 | settled_at AS "settledAt", |
| 124 | snoozed_until AS "snoozedUntil", | |
| 125 | snoozed_at AS "snoozedAt", | |
| 118 | 126 | latest_user_message_at AS "latestUserMessageAt", |
| 119 | 127 | pending_approval_count AS "pendingApprovalCount", |
| 120 | 128 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 145 | 153 | archived_at AS "archivedAt", |
| 146 | 154 | settled_override AS "settledOverride", |
| 147 | 155 | settled_at AS "settledAt", |
| 156 | snoozed_until AS "snoozedUntil", | |
| 157 | snoozed_at AS "snoozedAt", | |
| 148 | 158 | latest_user_message_at AS "latestUserMessageAt", |
| 149 | 159 | pending_approval_count AS "pendingApprovalCount", |
| 150 | 160 | pending_user_input_count AS "pendingUserInputCount", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. |
| 46 | 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; |
| 47 | 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; | |
| 49 | 50 | |
| 50 | 51 | /** |
| 51 | 52 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -91,6 +92,7 @@ export const migrationEntries = [ |
| 91 | 92 | [31, "AuthAuthorizationScopes", Migration0031], |
| 92 | 93 | [32, "AuthPairingProofKeyThumbprint", Migration0032], |
| 93 | 94 | [33, "ProjectionThreadsSettled", Migration0033], |
| 95 | [34, "ProjectionThreadsSnoozed", Migration0034], | |
| 94 | 96 | ] as const; |
| 95 | 97 | |
| 96 | 98 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | const columns = yield* sql<{ readonly name: string }>` | |
| 7 | PRAGMA table_info(projection_threads) | |
| 8 | `; | |
| 9 | ||
| 10 | if (!columns.some((column) => column.name === "snoozed_until")) { | |
| 11 | yield* sql` | |
| 12 | ALTER TABLE projection_threads | |
| 13 | ADD COLUMN snoozed_until TEXT | |
| 14 | `; | |
| 15 | } | |
| 16 | ||
| 17 | if (!columns.some((column) => column.name === "snoozed_at")) { | |
| 18 | yield* sql` | |
| 19 | ALTER TABLE projection_threads | |
| 20 | ADD COLUMN snoozed_at TEXT | |
| 21 | `; | |
| 22 | } | |
| 23 | }); |
apps/server/src/persistence/Services/ProjectionThreads.ts+2| ... | ... | @@ -38,6 +38,8 @@ export const ProjectionThread = Schema.Struct({ |
| 38 | 38 | archivedAt: Schema.NullOr(IsoDateTime), |
| 39 | 39 | settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), |
| 40 | 40 | settledAt: Schema.NullOr(IsoDateTime), |
| 41 | snoozedUntil: Schema.NullOr(IsoDateTime), | |
| 42 | snoozedAt: Schema.NullOr(IsoDateTime), | |
| 41 | 43 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| 42 | 44 | pendingApprovalCount: NonNegativeInt, |
| 43 | 45 | pendingUserInputCount: NonNegativeInt, |
apps/web/src/components/ChatView.tsx+81-16| ... | ... | @@ -25,7 +25,7 @@ import { |
| 25 | 25 | connectionStatusTitle, |
| 26 | 26 | type EnvironmentConnectionPresentation, |
| 27 | 27 | } from "@t3tools/client-runtime/connection"; |
| 28 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | |
| 28 | import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | |
| 29 | 29 | import { |
| 30 | 30 | parseScopedThreadKey, |
| 31 | 31 | scopedThreadKey, |
| ... | ... | @@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" |
| 140 | 140 | import PlanSidebar from "./PlanSidebar"; |
| 141 | 141 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 142 | 142 | import { |
| 143 | AlarmClockIcon, | |
| 143 | 144 | CheckCircle2Icon, |
| 144 | 145 | ChevronDownIcon, |
| 145 | 146 | GitBranchIcon, |
| ... | ... | @@ -3844,7 +3845,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 3844 | 3845 | hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, |
| 3845 | 3846 | }); |
| 3846 | 3847 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| 3848 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; | |
| 3847 | 3849 | const nowMinute = useNowMinute(); |
| 3850 | const activeThreadSnoozed = | |
| 3851 | activeThreadShell !== null && | |
| 3852 | supportsSnooze && | |
| 3853 | effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); | |
| 3854 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | |
| 3855 | useEffect(() => { | |
| 3856 | void snoozeWakeTick; | |
| 3857 | if (!activeThreadSnoozed) return; | |
| 3858 | const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); | |
| 3859 | if (!Number.isFinite(wakeAtMs)) return; | |
| 3860 | const id = window.setTimeout( | |
| 3861 | () => bumpSnoozeWakeTick((tick) => tick + 1), | |
| 3862 | Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), | |
| 3863 | ); | |
| 3864 | return () => window.clearTimeout(id); | |
| 3865 | }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); | |
| 3848 | 3866 | const activeThreadSettled = useMemo(() => { |
| 3849 | 3867 | if (activeThreadShell === null || !supportsSettlement) return false; |
| 3850 | 3868 | return effectiveSettled(activeThreadShell, { |
| ... | ... | @@ -3890,6 +3908,34 @@ function ChatViewContent(props: ChatViewProps) { |
| 3890 | 3908 | setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); |
| 3891 | 3909 | } |
| 3892 | 3910 | }, [activeThreadRef, unsettleThreadMutation]); |
| 3911 | const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { | |
| 3912 | reportFailure: false, | |
| 3913 | }); | |
| 3914 | const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null); | |
| 3915 | const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; | |
| 3916 | const handleUnsnoozeActiveThread = useCallback(async () => { | |
| 3917 | if (!activeThreadRef) return; | |
| 3918 | const threadKey = scopedThreadKey(activeThreadRef); | |
| 3919 | setUnsnoozingThreadKey(threadKey); | |
| 3920 | try { | |
| 3921 | const result = await unsnoozeThreadMutation({ | |
| 3922 | environmentId: activeThreadRef.environmentId, | |
| 3923 | input: { threadId: activeThreadRef.threadId, reason: "user" }, | |
| 3924 | }); | |
| 3925 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 3926 | const error = squashAtomCommandFailure(result); | |
| 3927 | toastManager.add( | |
| 3928 | stackedThreadToast({ | |
| 3929 | type: "error", | |
| 3930 | title: "Failed to wake thread", | |
| 3931 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 3932 | }), | |
| 3933 | ); | |
| 3934 | } | |
| 3935 | } finally { | |
| 3936 | setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); | |
| 3937 | } | |
| 3938 | }, [activeThreadRef, unsnoozeThreadMutation]); | |
| 3893 | 3939 | const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); |
| 3894 | 3940 | const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); |
| 3895 | 3941 | // Once revealed for a given mismatch, the banner stays mounted until the |
| ... | ... | @@ -3999,29 +4045,48 @@ function ChatViewContent(props: ChatViewProps) { |
| 3999 | 4045 | ]); |
| 4000 | 4046 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| 4001 | 4047 | // ordering is priority: system banners, then the branch-mismatch notice, |
| 4002 | // and the informational settled banner last — it must never cover another. | |
| 4003 | const settledComposerBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | |
| 4004 | if (!activeThreadSettled) { | |
| 4048 | // and the informational parked-thread banner last — it must never cover another. | |
| 4049 | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | |
| 4050 | if (!activeThreadSnoozed && !activeThreadSettled) { | |
| 4005 | 4051 | return null; |
| 4006 | 4052 | } |
| 4053 | const isSnoozed = activeThreadSnoozed; | |
| 4007 | 4054 | return { |
| 4008 | id: `thread-settled:${activeThread?.id ?? "unknown"}`, | |
| 4055 | id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, | |
| 4009 | 4056 | variant: "info", |
| 4010 | icon: <CheckCircle2Icon />, | |
| 4011 | title: "This thread is settled", | |
| 4012 | description: "Sending a message moves it back to Active in the sidebar.", | |
| 4057 | icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />, | |
| 4058 | title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, | |
| 4059 | description: isSnoozed | |
| 4060 | ? "Sending a message wakes it and moves it back to Active in the sidebar." | |
| 4061 | : "Sending a message moves it back to Active in the sidebar.", | |
| 4013 | 4062 | actions: ( |
| 4014 | 4063 | <Button |
| 4015 | 4064 | size="xs" |
| 4016 | 4065 | variant="outline" |
| 4017 | disabled={isUnsettling} | |
| 4018 | onClick={() => void handleUnsettleActiveThread()} | |
| 4066 | disabled={isSnoozed ? isUnsnoozing : isUnsettling} | |
| 4067 | onClick={() => | |
| 4068 | void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread()) | |
| 4069 | } | |
| 4019 | 4070 | > |
| 4020 | {isUnsettling ? "Un-settling..." : "Un-settle"} | |
| 4071 | {isSnoozed | |
| 4072 | ? isUnsnoozing | |
| 4073 | ? "Waking..." | |
| 4074 | : "Wake now" | |
| 4075 | : isUnsettling | |
| 4076 | ? "Un-settling..." | |
| 4077 | : "Un-settle"} | |
| 4021 | 4078 | </Button> |
| 4022 | 4079 | ), |
| 4023 | 4080 | }; |
| 4024 | }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); | |
| 4081 | }, [ | |
| 4082 | activeThread?.id, | |
| 4083 | activeThreadSettled, | |
| 4084 | activeThreadSnoozed, | |
| 4085 | handleUnsnoozeActiveThread, | |
| 4086 | handleUnsettleActiveThread, | |
| 4087 | isUnsnoozing, | |
| 4088 | isUnsettling, | |
| 4089 | ]); | |
| 4025 | 4090 | const handleRestoreThreadBranch = useCallback(() => { |
| 4026 | 4091 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 4027 | 4092 | setBranchRestoreConfirmOpen(true); |
| ... | ... | @@ -4030,9 +4095,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 4030 | 4095 | void handleSwitchCheckoutToThread(); |
| 4031 | 4096 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 4032 | 4097 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 4033 | const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; | |
| 4098 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; | |
| 4034 | 4099 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 4035 | return [...systemComposerBannerItems, ...settledItems]; | |
| 4100 | return [...systemComposerBannerItems, ...parkedThreadItems]; | |
| 4036 | 4101 | } |
| 4037 | 4102 | return [ |
| 4038 | 4103 | ...systemComposerBannerItems, |
| ... | ... | @@ -4075,14 +4140,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 4075 | 4140 | setBranchMismatchDismissTick((tick) => tick + 1); |
| 4076 | 4141 | }, |
| 4077 | 4142 | }, |
| 4078 | ...settledItems, | |
| 4143 | ...parkedThreadItems, | |
| 4079 | 4144 | ]; |
| 4080 | 4145 | }, [ |
| 4081 | 4146 | activeBranchMismatchKey, |
| 4082 | 4147 | handleRestoreThreadBranch, |
| 4083 | 4148 | isRestoringThreadBranch, |
| 4084 | 4149 | localCheckoutBranchMismatch, |
| 4085 | settledComposerBannerItem, | |
| 4150 | parkedThreadBannerItem, | |
| 4086 | 4151 | showBranchMismatchBanner, |
| 4087 | 4152 | systemComposerBannerItems, |
| 4088 | 4153 | ]); |
apps/web/src/components/Sidebar.snooze.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; | |
| 4 | ||
| 5 | // Local-time constructor so preset math is timezone-stable in tests. | |
| 6 | function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { | |
| 7 | return new Date(year, month - 1, day, hour, minute, 0, 0); | |
| 8 | } | |
| 9 | ||
| 10 | describe("resolveSnoozePresets", () => { | |
| 11 | it("offers hour, evening, tomorrow, next week in the morning", () => { | |
| 12 | // Wednesday 2026-04-08 10:00 local. | |
| 13 | const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); | |
| 14 | expect(presets.map((preset) => preset.id)).toEqual([ | |
| 15 | "hour", | |
| 16 | "evening", | |
| 17 | "tomorrow", | |
| 18 | "next-week", | |
| 19 | ]); | |
| 20 | const evening = presets.find((preset) => preset.id === "evening"); | |
| 21 | expect(new Date(evening!.snoozedUntil).getHours()).toBe(18); | |
| 22 | const tomorrow = presets.find((preset) => preset.id === "tomorrow"); | |
| 23 | const tomorrowDate = new Date(tomorrow!.snoozedUntil); | |
| 24 | expect(tomorrowDate.getDate()).toBe(9); | |
| 25 | expect(tomorrowDate.getHours()).toBe(9); | |
| 26 | const nextWeek = presets.find((preset) => preset.id === "next-week"); | |
| 27 | const nextWeekDate = new Date(nextWeek!.snoozedUntil); | |
| 28 | expect(nextWeekDate.getDay()).toBe(1); | |
| 29 | expect(nextWeekDate.getDate()).toBe(13); | |
| 30 | }); | |
| 31 | ||
| 32 | it("whenLabel complements the label instead of repeating it", () => { | |
| 33 | const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); | |
| 34 | for (const preset of presets) { | |
| 35 | // Day words live in the label column; the time column is time-only | |
| 36 | // (plus a weekday for next week, which names a different day). | |
| 37 | expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow"); | |
| 38 | } | |
| 39 | const tomorrow = presets.find((preset) => preset.id === "tomorrow"); | |
| 40 | expect(tomorrow!.whenLabel).toMatch(/9/); | |
| 41 | const nextWeek = presets.find((preset) => preset.id === "next-week"); | |
| 42 | expect(nextWeek!.whenLabel).toMatch(/Mon/); | |
| 43 | }); | |
| 44 | ||
| 45 | it("drops the evening preset once evening is near or past", () => { | |
| 46 | expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ | |
| 47 | "hour", | |
| 48 | "tomorrow", | |
| 49 | "next-week", | |
| 50 | ]); | |
| 51 | expect(resolveSnoozePresets(localDate(2026, 4, 8, 21)).map((preset) => preset.id)).toEqual([ | |
| 52 | "hour", | |
| 53 | "tomorrow", | |
| 54 | "next-week", | |
| 55 | ]); | |
| 56 | }); | |
| 57 | ||
| 58 | it("puts next week a full week out when today is Monday", () => { | |
| 59 | // Monday 2026-04-06. | |
| 60 | const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10)); | |
| 61 | const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); | |
| 62 | expect(nextWeek.getDay()).toBe(1); | |
| 63 | expect(nextWeek.getDate()).toBe(13); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("snoozeWakeLabel", () => { | |
| 68 | const now = localDate(2026, 4, 8, 10); | |
| 69 | ||
| 70 | it("formats minutes, hours, and days, rounding up", () => { | |
| 71 | expect(snoozeWakeLabel(new Date(now.getTime() + 30 * 60_000).toISOString(), now)).toBe("30m"); | |
| 72 | expect(snoozeWakeLabel(new Date(now.getTime() + 90 * 60_000).toISOString(), now)).toBe("2h"); | |
| 73 | expect(snoozeWakeLabel(new Date(now.getTime() + 26 * 3_600_000).toISOString(), now)).toBe("2d"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("reports now for past and malformed wake times", () => { | |
| 77 | expect(snoozeWakeLabel(new Date(now.getTime() - 1000).toISOString(), now)).toBe("now"); | |
| 78 | expect(snoozeWakeLabel("not-a-date", now)).toBe("now"); | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | describe("snoozeWakeDescription", () => { | |
| 83 | const now = localDate(2026, 4, 8, 10); | |
| 84 | ||
| 85 | it("uses bare time today, 'tomorrow' next day, weekday within the week", () => { | |
| 86 | expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now)).not.toContain( | |
| 87 | "tomorrow", | |
| 88 | ); | |
| 89 | expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now)).toContain( | |
| 90 | "tomorrow", | |
| 91 | ); | |
| 92 | expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now)).toMatch(/Mon/); | |
| 93 | }); | |
| 94 | }); |
apps/web/src/components/Sidebar.snooze.ts created+127| ... | ... | @@ -0,0 +1,127 @@ |
| 1 | /** | |
| 2 | * Snooze preset resolution for the sidebar snooze menu. Pure functions so | |
| 3 | * the preset math (evening/tomorrow/next-week boundaries) is unit-testable | |
| 4 | * without a DOM. | |
| 5 | * | |
| 6 | * Presets deliberately skew short: agent-thread rhythms are hours (a CI | |
| 7 | * run, a teammate review, the next work session), not days. | |
| 8 | */ | |
| 9 | import { parseTimestampDate } from "../timestampFormat"; | |
| 10 | ||
| 11 | type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; | |
| 12 | ||
| 13 | export interface SnoozePreset { | |
| 14 | readonly id: SnoozePresetId; | |
| 15 | readonly label: string; | |
| 16 | /** Menu-row time column. Complements the label instead of repeating it: | |
| 17 | "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ | |
| 18 | readonly whenLabel: string; | |
| 19 | /** ISO wake time. */ | |
| 20 | readonly snoozedUntil: string; | |
| 21 | } | |
| 22 | ||
| 23 | function timeOfDayLabel(date: Date): string { | |
| 24 | return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); | |
| 25 | } | |
| 26 | ||
| 27 | const EVENING_HOUR = 18; | |
| 28 | const MORNING_HOUR = 9; | |
| 29 | const HOUR_MS = 60 * 60 * 1_000; | |
| 30 | const DAY_MS = 24 * HOUR_MS; | |
| 31 | ||
| 32 | function atHour(base: Date, hour: number): Date { | |
| 33 | const next = new Date(base); | |
| 34 | next.setHours(hour, 0, 0, 0); | |
| 35 | return next; | |
| 36 | } | |
| 37 | ||
| 38 | // Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets | |
| 39 | // land on the wrong local day across DST transitions (a spring-forward day | |
| 40 | // is 23 hours, so 23:30 + 24h skips the whole next day). | |
| 41 | function addDays(base: Date, days: number): Date { | |
| 42 | const next = new Date(base); | |
| 43 | next.setDate(next.getDate() + days); | |
| 44 | return next; | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Presets for "snooze until", computed against local time. "This evening" | |
| 49 | * only appears while it is still meaningfully before evening; after that | |
| 50 | * the list starts at "Tomorrow". | |
| 51 | */ | |
| 52 | export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> { | |
| 53 | const inAnHour = new Date(now.getTime() + HOUR_MS); | |
| 54 | const presets: SnoozePreset[] = [ | |
| 55 | { | |
| 56 | id: "hour", | |
| 57 | label: "In 1 hour", | |
| 58 | whenLabel: timeOfDayLabel(inAnHour), | |
| 59 | snoozedUntil: inAnHour.toISOString(), | |
| 60 | }, | |
| 61 | ]; | |
| 62 | ||
| 63 | const evening = atHour(now, EVENING_HOUR); | |
| 64 | // Suppress the evening preset once it is within an hour (or past): it | |
| 65 | // would duplicate "In 1 hour" or point at the past. | |
| 66 | if (evening.getTime() - now.getTime() > HOUR_MS) { | |
| 67 | presets.push({ | |
| 68 | id: "evening", | |
| 69 | label: "This evening", | |
| 70 | whenLabel: timeOfDayLabel(evening), | |
| 71 | snoozedUntil: evening.toISOString(), | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); | |
| 76 | presets.push({ | |
| 77 | id: "tomorrow", | |
| 78 | label: "Tomorrow", | |
| 79 | whenLabel: timeOfDayLabel(tomorrow), | |
| 80 | snoozedUntil: tomorrow.toISOString(), | |
| 81 | }); | |
| 82 | ||
| 83 | // Next Monday 9:00 (a week out when today is Monday). | |
| 84 | const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; | |
| 85 | const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); | |
| 86 | presets.push({ | |
| 87 | id: "next-week", | |
| 88 | label: "Next week", | |
| 89 | whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, | |
| 90 | snoozedUntil: nextWeek.toISOString(), | |
| 91 | }); | |
| 92 | ||
| 93 | return presets; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes | |
| 98 | * round up so a snooze never reads "0m" while still hidden. | |
| 99 | */ | |
| 100 | export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { | |
| 101 | const wake = parseTimestampDate(snoozedUntil); | |
| 102 | if (wake === null) return "now"; | |
| 103 | const remainingMs = wake.getTime() - now.getTime(); | |
| 104 | if (remainingMs <= 0) return "now"; | |
| 105 | if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; | |
| 106 | if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; | |
| 107 | return `${Math.ceil(remainingMs / DAY_MS)}d`; | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", | |
| 112 | * "17:30" (today). | |
| 113 | */ | |
| 114 | export function snoozeWakeDescription(snoozedUntil: string, now: Date): string { | |
| 115 | const wake = parseTimestampDate(snoozedUntil); | |
| 116 | if (wake === null) return ""; | |
| 117 | const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); | |
| 118 | const startOfToday = new Date(now); | |
| 119 | startOfToday.setHours(0, 0, 0, 0); | |
| 120 | const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); | |
| 121 | if (dayDelta === 0) return time; | |
| 122 | if (dayDelta === 1) return `tomorrow ${time}`; | |
| 123 | const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); | |
| 124 | if (dayDelta < 7) return `${weekday} ${time}`; | |
| 125 | const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); | |
| 126 | return `${date}, ${time}`; | |
| 127 | } |
apps/web/src/components/SidebarV2.tsx+636-147| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 | import { autoAnimate } from "@formkit/auto-animate"; |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | |
| 3 | import { | |
| 4 | canSnooze, | |
| 5 | effectiveSettled, | |
| 6 | effectiveSnoozed, | |
| 7 | threadWokeAt, | |
| 8 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 4 | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 5 | 10 | import { |
| 6 | 11 | scopeProjectRef, |
| ... | ... | @@ -9,11 +14,15 @@ import { |
| 9 | 14 | } from "@t3tools/client-runtime/environment"; |
| 10 | 15 | import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 11 | 16 | import { |
| 17 | AlarmClockIcon, | |
| 18 | AlarmClockOffIcon, | |
| 12 | 19 | CheckIcon, |
| 13 | 20 | ChevronDownIcon, |
| 21 | ChevronRightIcon, | |
| 14 | 22 | CircleAlertIcon, |
| 15 | 23 | CircleCheckIcon, |
| 16 | 24 | CircleDashedIcon, |
| 25 | ClockIcon, | |
| 17 | 26 | CopyIcon, |
| 18 | 27 | FolderIcon, |
| 19 | 28 | FolderPlusIcon, |
| ... | ... | @@ -36,6 +45,7 @@ import { |
| 36 | 45 | useState, |
| 37 | 46 | type KeyboardEvent as ReactKeyboardEvent, |
| 38 | 47 | type MouseEvent as ReactMouseEvent, |
| 48 | type ReactNode, | |
| 39 | 49 | } from "react"; |
| 40 | 50 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 41 | 51 | |
| ... | ... | @@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects"; |
| 88 | 98 | import { useEnvironmentQuery } from "../state/query"; |
| 89 | 99 | import { useAtomCommand } from "../state/use-atom-command"; |
| 90 | 100 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 91 | import { formatRelativeTimeLabel } from "../timestampFormat"; | |
| 101 | import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; | |
| 92 | 102 | import type { SidebarThreadSummary } from "../types"; |
| 93 | 103 | import { cn } from "~/lib/utils"; |
| 94 | 104 | import { |
| 95 | 105 | formatWorkingDurationLabel, |
| 106 | firstValidTimestampMs, | |
| 96 | 107 | hasUnseenCompletion, |
| 97 | 108 | isTrailingDoubleClick, |
| 98 | 109 | orderItemsByPreferredIds, |
| ... | ... | @@ -107,6 +118,12 @@ import { |
| 107 | 118 | } from "./Sidebar.logic"; |
| 108 | 119 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 109 | 120 | import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; |
| 121 | import { | |
| 122 | resolveSnoozePresets, | |
| 123 | snoozeWakeDescription, | |
| 124 | snoozeWakeLabel, | |
| 125 | type SnoozePreset, | |
| 126 | } from "./Sidebar.snooze"; | |
| 110 | 127 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 111 | 128 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 112 | 129 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| ... | ... | @@ -130,6 +147,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u |
| 130 | 147 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; |
| 131 | 148 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; |
| 132 | 149 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 150 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 133 | 151 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| 134 | 152 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 135 | 153 | |
| ... | ... | @@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({ |
| 275 | 293 | ); |
| 276 | 294 | } |
| 277 | 295 | |
| 296 | /** | |
| 297 | * Hover entry point for snooze: a clock button opening the preset menu. | |
| 298 | * Controlled by the row (which also uses the open state to pin its hover | |
| 299 | * actions while the menu is up). | |
| 300 | */ | |
| 301 | function SnoozePopoverButton(props: { | |
| 302 | open: boolean; | |
| 303 | onOpenChange: (open: boolean) => void; | |
| 304 | onSnooze: (preset: SnoozePreset) => void; | |
| 305 | }) { | |
| 306 | const { open, onOpenChange, onSnooze } = props; | |
| 307 | // Presets resolve at open time so "In 1 hour" is relative to the click, | |
| 308 | // not to when the row mounted. | |
| 309 | const presets = useMemo(() => (open ? resolveSnoozePresets(new Date()) : []), [open]); | |
| 310 | return ( | |
| 311 | <Popover open={open} onOpenChange={onOpenChange}> | |
| 312 | <PopoverTrigger | |
| 313 | render={ | |
| 314 | <button | |
| 315 | type="button" | |
| 316 | aria-label="Snooze thread" | |
| 317 | onClick={(event) => event.stopPropagation()} | |
| 318 | onDoubleClick={(event) => event.stopPropagation()} | |
| 319 | className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" | |
| 320 | /> | |
| 321 | } | |
| 322 | > | |
| 323 | <ClockIcon className="size-3" /> | |
| 324 | </PopoverTrigger> | |
| 325 | <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1"> | |
| 326 | {presets.map((preset) => ( | |
| 327 | <button | |
| 328 | key={preset.id} | |
| 329 | type="button" | |
| 330 | onClick={(event) => { | |
| 331 | event.stopPropagation(); | |
| 332 | onOpenChange(false); | |
| 333 | onSnooze(preset); | |
| 334 | }} | |
| 335 | className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground" | |
| 336 | > | |
| 337 | <span className="flex-1">{preset.label}</span> | |
| 338 | <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums"> | |
| 339 | {preset.whenLabel} | |
| 340 | </span> | |
| 341 | </button> | |
| 342 | ))} | |
| 343 | </PopoverPopup> | |
| 344 | </Popover> | |
| 345 | ); | |
| 346 | } | |
| 347 | ||
| 278 | 348 | const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 279 | 349 | thread: SidebarThreadSummary; |
| 280 | 350 | variant: "card" | "slim"; |
| 281 | 351 | // Slim rows are either settled (action: un-settle) or merely quiet |
| 282 | 352 | // (seen Ready threads — action: settle). |
| 283 | variantAction: "settle" | "unsettle"; | |
| 353 | variantAction: "settle" | "unsettle" | "unsnooze"; | |
| 284 | 354 | // False on environments whose server predates thread.settle/unsettle: |
| 285 | 355 | // the lifecycle affordances hide entirely rather than fail on click. |
| 286 | 356 | settlementSupported: boolean; |
| 357 | // Same contract for thread.snooze/unsnooze. | |
| 358 | snoozeSupported: boolean; | |
| 359 | // Compact wake countdown ("2h") for rows in the snoozed shelf. | |
| 360 | snoozeWakeLabelText: string | null; | |
| 361 | // When a snooze ended (timer or early wake); drives the Woke pill until | |
| 362 | // the user visits the thread. | |
| 363 | wokeAt: string | null; | |
| 287 | 364 | isActive: boolean; |
| 288 | 365 | jumpLabel: string | null; |
| 289 | 366 | currentEnvironmentId: string | null; |
| ... | ... | @@ -302,6 +379,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 302 | 379 | onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; |
| 303 | 380 | onSettle: (threadRef: ScopedThreadRef) => void; |
| 304 | 381 | onUnsettle: (threadRef: ScopedThreadRef) => void; |
| 382 | onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; | |
| 383 | onUnsnooze: (threadRef: ScopedThreadRef) => void; | |
| 305 | 384 | onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; |
| 306 | 385 | }) { |
| 307 | 386 | const { |
| ... | ... | @@ -312,10 +391,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 312 | 391 | onContextMenu, |
| 313 | 392 | onRenameTitleChange, |
| 314 | 393 | onSettle, |
| 394 | onSnooze, | |
| 315 | 395 | onStartRename, |
| 316 | 396 | onThreadActivate, |
| 317 | 397 | onThreadClick, |
| 318 | 398 | onUnsettle, |
| 399 | onUnsnooze, | |
| 319 | 400 | renamingTitle, |
| 320 | 401 | thread, |
| 321 | 402 | variant, |
| ... | ... | @@ -334,15 +415,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 334 | 415 | // flag must not light up every historical thread as unread. |
| 335 | 416 | const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); |
| 336 | 417 | const status = resolveSidebarV2Status(thread); |
| 418 | // A woken thread reappears at its original position (the sort is | |
| 419 | // deliberately static), so the pill has to carry the weight. Snoozing is | |
| 420 | // an explicit act, so unlike Done, a never-visited woke thread still | |
| 421 | // shows the pill; visiting clears it. An unparseable visit timestamp | |
| 422 | // counts as never-visited — corrupt local data must not eat the wake | |
| 423 | // signal. | |
| 424 | const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); | |
| 425 | const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); | |
| 426 | const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); | |
| 337 | 427 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 338 | 428 | // there is nothing for the user to do yet, so prominence is reserved for |
| 339 | // rows that need a human — done (unread), read-but-unsettled, and failed. | |
| 340 | // The status label keeps its hue, so waiting rows stay findable. In-flight | |
| 341 | // rows recede the same as read-ready ones (inbox-zero: working threads | |
| 342 | // aren't your problem yet) — only the colored status label stands out. | |
| 429 | // rows that need a human — done (unread), read-but-unsettled, failed, and | |
| 430 | // freshly woken. The status label keeps its hue, so waiting rows stay | |
| 431 | // findable. In-flight rows recede the same as read-ready ones (inbox-zero: | |
| 432 | // working threads aren't your problem yet) — only the colored status label | |
| 433 | // stands out. | |
| 343 | 434 | const isInFlight = status === "working" || status === "approval" || status === "input"; |
| 344 | 435 | const shouldRecede = |
| 345 | (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; | |
| 436 | (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; | |
| 346 | 437 | // Status hues follow the system-wide convention set by sidebar v1 and the |
| 347 | 438 | // mobile Live Activity/widgets (amber approval, indigo input, sky working) |
| 348 | 439 | // so a thread reads the same color everywhere it surfaces. |
| ... | ... | @@ -372,13 +463,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 372 | 463 | icon: null, |
| 373 | 464 | className: "text-red-700 dark:text-red-300", |
| 374 | 465 | } |
| 375 | : isUnread | |
| 466 | : isWoke | |
| 376 | 467 | ? { |
| 377 | label: "Done", | |
| 378 | icon: "done" as const, | |
| 379 | className: "text-emerald-700 dark:text-emerald-300", | |
| 468 | label: "Woke", | |
| 469 | icon: "woke" as const, | |
| 470 | className: "text-amber-700 dark:text-amber-300", | |
| 380 | 471 | } |
| 381 | : null; | |
| 472 | : isUnread | |
| 473 | ? { | |
| 474 | label: "Done", | |
| 475 | icon: "done" as const, | |
| 476 | className: "text-emerald-700 dark:text-emerald-300", | |
| 477 | } | |
| 478 | : null; | |
| 382 | 479 | |
| 383 | 480 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 384 | 481 | const gitStatus = useEnvironmentQuery( |
| ... | ... | @@ -507,6 +604,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 507 | 604 | }, |
| 508 | 605 | [onUnsettle, threadRef], |
| 509 | 606 | ); |
| 607 | const handleUnsnoozeClick = useCallback( | |
| 608 | (event: ReactMouseEvent) => { | |
| 609 | event.preventDefault(); | |
| 610 | event.stopPropagation(); | |
| 611 | onUnsnooze(threadRef); | |
| 612 | }, | |
| 613 | [onUnsnooze, threadRef], | |
| 614 | ); | |
| 615 | const handleSnoozePreset = useCallback( | |
| 616 | (preset: SnoozePreset) => { | |
| 617 | onSnooze(threadRef, preset); | |
| 618 | }, | |
| 619 | [onSnooze, threadRef], | |
| 620 | ); | |
| 621 | // While the snooze popover is open the pointer leaves the row, which | |
| 622 | // would fade the hover actions out from under the open menu; pin them. | |
| 623 | const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); | |
| 624 | // Snooze is offered only where it can succeed: capability-gated and never | |
| 625 | // on blocked-on-you work or queued turns (the server rejects both). | |
| 626 | const showSnoozeButton = | |
| 627 | props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); | |
| 628 | // If the thread becomes blocked while the popover is open, the button | |
| 629 | // unmounts without firing onOpenChange(false). Deriving the flag keeps a | |
| 630 | // stale true from permanently hiding the status label / pinning the | |
| 631 | // hover actions, and the effect clears the raw state so the popover | |
| 632 | // doesn't resurrect if the button later remounts. | |
| 633 | const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; | |
| 634 | useEffect(() => { | |
| 635 | if (!showSnoozeButton) setSnoozeMenuOpen(false); | |
| 636 | }, [showSnoozeButton]); | |
| 510 | 637 | const handlePrClick = useCallback( |
| 511 | 638 | (event: ReactMouseEvent<HTMLElement>) => { |
| 512 | 639 | if (pr?.url) openPrLink(event, pr.url); |
| ... | ... | @@ -555,7 +682,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 555 | 682 | variant === "card" |
| 556 | 683 | ? cn( |
| 557 | 684 | "truncate", |
| 558 | isUnread | |
| 685 | isUnread || isWoke | |
| 559 | 686 | ? "text-foreground" |
| 560 | 687 | : shouldRecede |
| 561 | 688 | ? "text-muted-foreground/80" |
| ... | ... | @@ -565,7 +692,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 565 | 692 | ) |
| 566 | 693 | : cn( |
| 567 | 694 | "truncate group-hover/v2-row:text-foreground", |
| 568 | props.isActive | |
| 695 | props.isActive || isWoke | |
| 569 | 696 | ? "text-foreground" |
| 570 | 697 | : isUnread |
| 571 | 698 | ? "text-muted-foreground" |
| ... | ... | @@ -640,13 +767,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 640 | 767 | {prBadge} |
| 641 | 768 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 642 | 769 | <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0"> |
| 643 | <span className="text-xs"> | |
| 644 | {variantAction === "unsettle" | |
| 645 | ? settledTimeLabel(thread) | |
| 646 | : threadTimeLabel(thread)} | |
| 647 | </span> | |
| 770 | {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( | |
| 771 | // Snoozed rows show when they come BACK, not when they were | |
| 772 | // last touched — the return ticket is the row's whole story. | |
| 773 | <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400"> | |
| 774 | {props.snoozeWakeLabelText} | |
| 775 | </span> | |
| 776 | ) : isWoke ? ( | |
| 777 | // A wake can land straight in the settled tail (e.g. PR | |
| 778 | // merged while snoozed); the signal must survive the trip. | |
| 779 | <span | |
| 780 | role="status" | |
| 781 | aria-label="Woke from snooze" | |
| 782 | className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300" | |
| 783 | > | |
| 784 | <AlarmClockIcon aria-hidden className="size-3" /> | |
| 785 | Woke | |
| 786 | </span> | |
| 787 | ) : ( | |
| 788 | <span className="text-xs"> | |
| 789 | {variantAction === "unsettle" | |
| 790 | ? settledTimeLabel(thread) | |
| 791 | : threadTimeLabel(thread)} | |
| 792 | </span> | |
| 793 | )} | |
| 648 | 794 | </span> |
| 649 | {!props.settlementSupported ? null : variantAction === "unsettle" ? ( | |
| 795 | {variantAction === "unsnooze" ? ( | |
| 796 | !props.snoozeSupported ? null : ( | |
| 797 | <button | |
| 798 | type="button" | |
| 799 | aria-label="Wake thread now" | |
| 800 | onClick={handleUnsnoozeClick} | |
| 801 | className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" | |
| 802 | > | |
| 803 | <AlarmClockOffIcon className="size-3" /> | |
| 804 | </button> | |
| 805 | ) | |
| 806 | ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( | |
| 650 | 807 | <button |
| 651 | 808 | type="button" |
| 652 | 809 | aria-label="Un-settle thread" |
| ... | ... | @@ -716,7 +873,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 716 | 873 | <span className="flex-1" /> |
| 717 | 874 | )} |
| 718 | 875 | <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs"> |
| 719 | <span className="tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0"> | |
| 876 | <span | |
| 877 | className={cn( | |
| 878 | "tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0", | |
| 879 | snoozeMenuOpen && "opacity-0", | |
| 880 | )} | |
| 881 | > | |
| 720 | 882 | {topStatus ? ( |
| 721 | 883 | <span |
| 722 | 884 | className={cn( |
| ... | ... | @@ -728,6 +890,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 728 | 890 | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> |
| 729 | 891 | ) : topStatus.icon === "done" ? ( |
| 730 | 892 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> |
| 893 | ) : topStatus.icon === "woke" ? ( | |
| 894 | <AlarmClockIcon aria-hidden className="size-4 shrink-0" /> | |
| 731 | 895 | ) : null} |
| 732 | 896 | {/* The label alone is the live region: a role="status" |
| 733 | 897 | wrapper around the ticking duration would make |
| ... | ... | @@ -743,16 +907,32 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 743 | 907 | threadTimeLabel(thread) |
| 744 | 908 | )} |
| 745 | 909 | </span> |
| 746 | {props.settlementSupported ? ( | |
| 747 | <button | |
| 748 | type="button" | |
| 749 | aria-label="Settle thread" | |
| 750 | onClick={handleSettleClick} | |
| 751 | className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" | |
| 910 | {props.settlementSupported || showSnoozeButton ? ( | |
| 911 | <span | |
| 912 | className={cn( | |
| 913 | "absolute inset-y-0 right-0 flex items-stretch gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/v2-row:opacity-100", | |
| 914 | snoozeMenuOpen && "opacity-100", | |
| 915 | )} | |
| 752 | 916 | > |
| 753 | <CheckIcon className="size-3" /> | |
| 754 | Settle | |
| 755 | </button> | |
| 917 | {showSnoozeButton ? ( | |
| 918 | <SnoozePopoverButton | |
| 919 | open={snoozeMenuOpen} | |
| 920 | onOpenChange={setSnoozeMenuOpen} | |
| 921 | onSnooze={handleSnoozePreset} | |
| 922 | /> | |
| 923 | ) : null} | |
| 924 | {props.settlementSupported ? ( | |
| 925 | <button | |
| 926 | type="button" | |
| 927 | aria-label="Settle thread" | |
| 928 | onClick={handleSettleClick} | |
| 929 | className="inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground" | |
| 930 | > | |
| 931 | <CheckIcon className="size-3" /> | |
| 932 | Settle | |
| 933 | </button> | |
| 934 | ) : null} | |
| 935 | </span> | |
| 756 | 936 | ) : null} |
| 757 | 937 | </span> |
| 758 | 938 | </div> |
| ... | ... | @@ -819,7 +999,8 @@ export default function SidebarV2() { |
| 819 | 999 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 820 | 1000 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 821 | 1001 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 822 | const { settleThread, unsettleThread, deleteThread } = useThreadActions(); | |
| 1002 | const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = | |
| 1003 | useThreadActions(); | |
| 823 | 1004 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 824 | 1005 | reportFailure: false, |
| 825 | 1006 | }); |
| ... | ... | @@ -954,6 +1135,12 @@ export default function SidebarV2() { |
| 954 | 1135 | // now is quantized to the minute so effectiveSettled memoization doesn't |
| 955 | 1136 | // churn on every render; auto-settle thresholds are day-granular anyway. |
| 956 | 1137 | const nowMinute = useNowMinute(); |
| 1138 | // Snooze wake times are second-precise, so classifying with the quantized | |
| 1139 | // minute would hold a woken thread on the shelf for up to a minute. The | |
| 1140 | // tick is a plain counter bumped exactly at the next wake boundary (armed | |
| 1141 | // below, after the partition knows the boundary); the partition reads a | |
| 1142 | // fresh clock whenever it recomputes. | |
| 1143 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | |
| 957 | 1144 | |
| 958 | 1145 | // PR states stream in per-row (rows own the VCS subscriptions); a merged or |
| 959 | 1146 | // closed PR auto-settles its thread on the next partition. |
| ... | ... | @@ -1165,8 +1352,14 @@ export default function SidebarV2() { |
| 1165 | 1352 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 1166 | 1353 | // archive keeps its original "remove from sidebar" meaning. |
| 1167 | 1354 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| 1168 | const { activeThreads, settledThreads } = useMemo(() => { | |
| 1355 | const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { | |
| 1169 | 1356 | const now = `${nowMinute}:00.000Z`; |
| 1357 | // Snooze classification uses a REAL clock, not the quantized minute: | |
| 1358 | // wake times are second-precise and a woken thread must not linger on | |
| 1359 | // the shelf for the rest of the minute. snoozeWakeTick re-runs this | |
| 1360 | // memo exactly at the next wake boundary. | |
| 1361 | void snoozeWakeTick; | |
| 1362 | const preciseNow = new Date().toISOString(); | |
| 1170 | 1363 | const visible = threads.filter( |
| 1171 | 1364 | (thread) => |
| 1172 | 1365 | thread.archivedAt === null && |
| ... | ... | @@ -1174,6 +1367,7 @@ export default function SidebarV2() { |
| 1174 | 1367 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), |
| 1175 | 1368 | ); |
| 1176 | 1369 | const active: EnvironmentThreadShell[] = []; |
| 1370 | const snoozed: EnvironmentThreadShell[] = []; | |
| 1177 | 1371 | const settled: EnvironmentThreadShell[] = []; |
| 1178 | 1372 | for (const thread of visible) { |
| 1179 | 1373 | // Threads on servers without the settlement capability (old server, |
| ... | ... | @@ -1182,9 +1376,16 @@ export default function SidebarV2() { |
| 1182 | 1376 | // strand rows in a tail with no working affordances. |
| 1183 | 1377 | const supportsSettlement = |
| 1184 | 1378 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; |
| 1379 | const supportsSnooze = | |
| 1380 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | |
| 1185 | 1381 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 1186 | 1382 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; |
| 1187 | if ( | |
| 1383 | // Snooze outranks settled classification: an explicitly snoozed thread | |
| 1384 | // belongs to the shelf even if it would also auto-settle (the shelf's | |
| 1385 | // wake time is a stronger statement about when it matters again). | |
| 1386 | if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { | |
| 1387 | snoozed.push(thread); | |
| 1388 | } else if ( | |
| 1188 | 1389 | supportsSettlement && |
| 1189 | 1390 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) |
| 1190 | 1391 | ) { |
| ... | ... | @@ -1195,7 +1396,14 @@ export default function SidebarV2() { |
| 1195 | 1396 | } |
| 1196 | 1397 | return { |
| 1197 | 1398 | activeThreads: sortThreadsForSidebarV2(active), |
| 1399 | // Soonest wake first: "what comes back next" is the shelf's question. | |
| 1400 | snoozedThreads: snoozed.toSorted( | |
| 1401 | (left, right) => | |
| 1402 | firstValidTimestampMs(left.snoozedUntil ?? null) - | |
| 1403 | firstValidTimestampMs(right.snoozedUntil ?? null), | |
| 1404 | ), | |
| 1198 | 1405 | settledThreads: sortSettledThreadsForSidebarV2(settled), |
| 1406 | snoozeNow: preciseNow, | |
| 1199 | 1407 | }; |
| 1200 | 1408 | }, [ |
| 1201 | 1409 | autoSettleAfterDays, |
| ... | ... | @@ -1203,9 +1411,28 @@ export default function SidebarV2() { |
| 1203 | 1411 | nowMinute, |
| 1204 | 1412 | scopedProjectKeys, |
| 1205 | 1413 | serverConfigs, |
| 1414 | snoozeWakeTick, | |
| 1206 | 1415 | threads, |
| 1207 | 1416 | ]); |
| 1208 | 1417 | |
| 1418 | // Arm a timeout for the earliest upcoming wake so the shelf empties the | |
| 1419 | // moment a snooze expires instead of on the next minute tick. Sorted | |
| 1420 | // soonest-first, so entry 0 is the boundary. | |
| 1421 | useEffect(() => { | |
| 1422 | const nextWakeAtMs = | |
| 1423 | snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null | |
| 1424 | ? Date.parse(snoozedThreads[0].snoozedUntil) | |
| 1425 | : Number.NaN; | |
| 1426 | if (Number.isNaN(nextWakeAtMs)) return; | |
| 1427 | // setTimeout delays are signed 32-bit: anything larger overflows and | |
| 1428 | // fires immediately, turning a far-future wake (event-condition snoozes | |
| 1429 | // synced from elsewhere) into a tight re-arm loop. Clamped, the timer | |
| 1430 | // just re-arms every ~24.8 days until the wake is in range. | |
| 1431 | const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); | |
| 1432 | const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); | |
| 1433 | return () => window.clearTimeout(id); | |
| 1434 | }, [snoozedThreads]); | |
| 1435 | ||
| 1209 | 1436 | // The settled tail renders in pages: history shouldn't dominate the |
| 1210 | 1437 | // sidebar, and the common lookups are recent. Expansion resets when the |
| 1211 | 1438 | // filter context changes so a scope/search flip never inherits a deep |
| ... | ... | @@ -1239,10 +1466,40 @@ export default function SidebarV2() { |
| 1239 | 1466 | () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), |
| 1240 | 1467 | [], |
| 1241 | 1468 | ); |
| 1469 | const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); | |
| 1470 | const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); | |
| 1471 | const renderedSettledThreads = useMemo(() => { | |
| 1472 | if (settledShelfExpanded) return visibleSettledThreads; | |
| 1473 | if (routeThreadKey === null) return []; | |
| 1474 | const routeThread = visibleSettledThreads.find( | |
| 1475 | (thread) => | |
| 1476 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, | |
| 1477 | ); | |
| 1478 | return routeThread === undefined ? [] : [routeThread]; | |
| 1479 | }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); | |
| 1480 | ||
| 1481 | // The snoozed shelf is collapsed by default: out of the way, never gone. | |
| 1482 | // Collapsed threads don't render (and so don't participate in jump | |
| 1483 | // shortcuts or multi-select), matching the settled tail's paging model. | |
| 1484 | const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); | |
| 1485 | const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); | |
| 1486 | const visibleSnoozedThreads = useMemo(() => { | |
| 1487 | if (snoozedShelfExpanded) return snoozedThreads; | |
| 1488 | // The open thread must never vanish behind the collapsed shelf: a | |
| 1489 | // snoozed thread reached by route (deep link, open before snoozing | |
| 1490 | // elsewhere) keeps its row — with highlight and wake affordance — same | |
| 1491 | // exception the settled tail's "Show more" makes. | |
| 1492 | if (routeThreadKey === null) return []; | |
| 1493 | const routeThread = snoozedThreads.find( | |
| 1494 | (thread) => | |
| 1495 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, | |
| 1496 | ); | |
| 1497 | return routeThread === undefined ? [] : [routeThread]; | |
| 1498 | }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); | |
| 1242 | 1499 | |
| 1243 | 1500 | const orderedThreads = useMemo( |
| 1244 | () => [...activeThreads, ...visibleSettledThreads], | |
| 1245 | [activeThreads, visibleSettledThreads], | |
| 1501 | () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], | |
| 1502 | [activeThreads, visibleSnoozedThreads, renderedSettledThreads], | |
| 1246 | 1503 | ); |
| 1247 | 1504 | const orderedThreadKeys = useMemo( |
| 1248 | 1505 | () => |
| ... | ... | @@ -1287,6 +1544,17 @@ export default function SidebarV2() { |
| 1287 | 1544 | ); |
| 1288 | 1545 | const settledThreadKeysRef = useRef(settledThreadKeys); |
| 1289 | 1546 | settledThreadKeysRef.current = settledThreadKeys; |
| 1547 | const snoozedThreadKeys = useMemo( | |
| 1548 | () => | |
| 1549 | new Set( | |
| 1550 | snoozedThreads.map((thread) => | |
| 1551 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 1552 | ), | |
| 1553 | ), | |
| 1554 | [snoozedThreads], | |
| 1555 | ); | |
| 1556 | const snoozedThreadKeysRef = useRef(snoozedThreadKeys); | |
| 1557 | snoozedThreadKeysRef.current = snoozedThreadKeys; | |
| 1290 | 1558 | |
| 1291 | 1559 | const jumpLabelByKey = useMemo(() => { |
| 1292 | 1560 | const mapping = new Map<string, string>(); |
| ... | ... | @@ -1382,6 +1650,36 @@ export default function SidebarV2() { |
| 1382 | 1650 | // A settle per thread at a time: double clicks and repeated menu picks |
| 1383 | 1651 | // must not dispatch a second settle that fails and toasts a false error. |
| 1384 | 1652 | const settlingThreadKeysRef = useRef(new Set<string>()); |
| 1653 | // Parking the thread you're looking at (settle or snooze) moves you | |
| 1654 | // forward: the next remaining card (never a settled or snoozed row, never | |
| 1655 | // one leaving in the same batch), or a fresh draft in this project when it | |
| 1656 | // was the last active one. Callers snapshot the plan BEFORE the command | |
| 1657 | // mutates the partition; background parks never navigate (null plan). | |
| 1658 | const planForwardNavigation = useCallback( | |
| 1659 | (threadKey: string, coParkingKeys?: ReadonlySet<string>): (() => void) | null => { | |
| 1660 | if (routeThreadKeyRef.current !== threadKey) return null; | |
| 1661 | const shell = threadByKeyRef.current.get(threadKey); | |
| 1662 | const orderedKeys = orderedThreadKeysRef.current; | |
| 1663 | const settledKeys = settledThreadKeysRef.current; | |
| 1664 | const snoozedKeys = snoozedThreadKeysRef.current; | |
| 1665 | const currentIndex = orderedKeys.indexOf(threadKey); | |
| 1666 | const nextCardKey = | |
| 1667 | currentIndex === -1 | |
| 1668 | ? null | |
| 1669 | : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( | |
| 1670 | (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), | |
| 1671 | ) ?? null); | |
| 1672 | const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; | |
| 1673 | return nextThread | |
| 1674 | ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) | |
| 1675 | : shell | |
| 1676 | ? () => | |
| 1677 | void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) | |
| 1678 | : () => void router.navigate({ to: "/" }); | |
| 1679 | }, | |
| 1680 | [navigateToThread, router], | |
| 1681 | ); | |
| 1682 | ||
| 1385 | 1683 | const attemptSettle = useCallback( |
| 1386 | 1684 | (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => { |
| 1387 | 1685 | void (async () => { |
| ... | ... | @@ -1389,34 +1687,7 @@ export default function SidebarV2() { |
| 1389 | 1687 | if (settlingThreadKeysRef.current.has(threadKey)) return; |
| 1390 | 1688 | settlingThreadKeysRef.current.add(threadKey); |
| 1391 | 1689 | try { |
| 1392 | // Settling the thread you're looking at moves you forward: the next | |
| 1393 | // remaining card (never a settled row, never one settling in the | |
| 1394 | // same batch), or a fresh draft in this project when it was the | |
| 1395 | // last active one. Snapshot the target before the settle mutates | |
| 1396 | // the partition. Background settles never navigate. | |
| 1397 | const shell = threadByKeyRef.current.get(threadKey); | |
| 1398 | let navigateAfterSettle: (() => void) | null = null; | |
| 1399 | if (routeThreadKey === threadKey) { | |
| 1400 | const orderedKeys = orderedThreadKeysRef.current; | |
| 1401 | const settledKeys = settledThreadKeysRef.current; | |
| 1402 | const currentIndex = orderedKeys.indexOf(threadKey); | |
| 1403 | const nextCardKey = | |
| 1404 | currentIndex === -1 | |
| 1405 | ? null | |
| 1406 | : ([ | |
| 1407 | ...orderedKeys.slice(currentIndex + 1), | |
| 1408 | ...orderedKeys.slice(0, currentIndex), | |
| 1409 | ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); | |
| 1410 | const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; | |
| 1411 | navigateAfterSettle = nextThread | |
| 1412 | ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) | |
| 1413 | : shell | |
| 1414 | ? () => | |
| 1415 | void handleNewThreadRef.current( | |
| 1416 | scopeProjectRef(shell.environmentId, shell.projectId), | |
| 1417 | ) | |
| 1418 | : () => void router.navigate({ to: "/" }); | |
| 1419 | } | |
| 1690 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); | |
| 1420 | 1691 | const result = await settleThread(threadRef); |
| 1421 | 1692 | if (result._tag === "Failure") { |
| 1422 | 1693 | // Never navigate away from a thread that did not settle. |
| ... | ... | @@ -1442,7 +1713,7 @@ export default function SidebarV2() { |
| 1442 | 1713 | } |
| 1443 | 1714 | })(); |
| 1444 | 1715 | }, |
| 1445 | [navigateToThread, routeThreadKey, router, settleThread], | |
| 1716 | [planForwardNavigation, settleThread], | |
| 1446 | 1717 | ); |
| 1447 | 1718 | const attemptUnsettle = useCallback( |
| 1448 | 1719 | (threadRef: ScopedThreadRef) => { |
| ... | ... | @@ -1462,6 +1733,80 @@ export default function SidebarV2() { |
| 1462 | 1733 | }, |
| 1463 | 1734 | [unsettleThread], |
| 1464 | 1735 | ); |
| 1736 | const attemptUnsnooze = useCallback( | |
| 1737 | (threadRef: ScopedThreadRef) => { | |
| 1738 | void (async () => { | |
| 1739 | const result = await unsnoozeThread(threadRef); | |
| 1740 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 1741 | const error = squashAtomCommandFailure(result); | |
| 1742 | toastManager.add( | |
| 1743 | stackedThreadToast({ | |
| 1744 | type: "error", | |
| 1745 | title: "Failed to wake thread", | |
| 1746 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1747 | }), | |
| 1748 | ); | |
| 1749 | } | |
| 1750 | })(); | |
| 1751 | }, | |
| 1752 | [unsnoozeThread], | |
| 1753 | ); | |
| 1754 | // One snooze per thread at a time — same double-dispatch guard as settle. | |
| 1755 | const snoozingThreadKeysRef = useRef(new Set<string>()); | |
| 1756 | const attemptSnooze = useCallback( | |
| 1757 | ( | |
| 1758 | threadRef: ScopedThreadRef, | |
| 1759 | preset: SnoozePreset, | |
| 1760 | opts: { coSnoozingKeys?: ReadonlySet<string> } = {}, | |
| 1761 | ) => { | |
| 1762 | void (async () => { | |
| 1763 | const threadKey = scopedThreadKey(threadRef); | |
| 1764 | if (snoozingThreadKeysRef.current.has(threadKey)) return; | |
| 1765 | snoozingThreadKeysRef.current.add(threadKey); | |
| 1766 | try { | |
| 1767 | // Snoozing the open thread moves you forward, same as settle — | |
| 1768 | // both park the thread you're done with for now. | |
| 1769 | const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); | |
| 1770 | const result = await snoozeThread(threadRef, preset.snoozedUntil); | |
| 1771 | if (result._tag === "Failure") { | |
| 1772 | // Never navigate away from a thread that did not snooze. | |
| 1773 | if (!isAtomCommandInterrupted(result)) { | |
| 1774 | const error = squashAtomCommandFailure(result); | |
| 1775 | toastManager.add( | |
| 1776 | stackedThreadToast({ | |
| 1777 | type: "error", | |
| 1778 | title: "Failed to snooze thread", | |
| 1779 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1780 | }), | |
| 1781 | ); | |
| 1782 | } | |
| 1783 | return; | |
| 1784 | } | |
| 1785 | // Snooze hides the row, so the toast is the only confirmation — | |
| 1786 | // and the Undo is the escape hatch for a mis-click. | |
| 1787 | toastManager.add( | |
| 1788 | stackedThreadToast({ | |
| 1789 | type: "success", | |
| 1790 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, | |
| 1791 | timeout: 5_000, | |
| 1792 | actionProps: { | |
| 1793 | children: "Undo", | |
| 1794 | onClick: () => attemptUnsnooze(threadRef), | |
| 1795 | }, | |
| 1796 | }), | |
| 1797 | ); | |
| 1798 | // Only move forward if the user is still on the snoozed thread — | |
| 1799 | // a navigation made during the await wins over ours. | |
| 1800 | if (routeThreadKeyRef.current === threadKey) { | |
| 1801 | navigateAfterSnooze?.(); | |
| 1802 | } | |
| 1803 | } finally { | |
| 1804 | snoozingThreadKeysRef.current.delete(threadKey); | |
| 1805 | } | |
| 1806 | })(); | |
| 1807 | }, | |
| 1808 | [attemptUnsnooze, planForwardNavigation, snoozeThread], | |
| 1809 | ); | |
| 1465 | 1810 | |
| 1466 | 1811 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
| 1467 | 1812 | const handleMultiSelectContextMenu = useCallback( |
| ... | ... | @@ -1477,10 +1822,35 @@ export default function SidebarV2() { |
| 1477 | 1822 | ); |
| 1478 | 1823 | if (threadKeys.length === 0) return; |
| 1479 | 1824 | const count = threadKeys.length; |
| 1825 | // Snooze (N) is offered when every selected thread can actually take | |
| 1826 | // it — a mixed selection with blocked-on-you work would half-apply. | |
| 1827 | const selectionNow = new Date().toISOString(); | |
| 1828 | const snoozableThreads = threadKeys.flatMap((threadKey) => { | |
| 1829 | const thread = threadByKeyRef.current.get(threadKey); | |
| 1830 | return thread ? [thread] : []; | |
| 1831 | }); | |
| 1832 | const canSnoozeSelection = snoozableThreads.every( | |
| 1833 | (thread) => | |
| 1834 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && | |
| 1835 | canSnooze(thread, { now: selectionNow }), | |
| 1836 | ); | |
| 1837 | const snoozePresets = resolveSnoozePresets(new Date()); | |
| 1480 | 1838 | const clicked = await settlePromise(() => |
| 1481 | 1839 | api.contextMenu.show( |
| 1482 | 1840 | [ |
| 1483 | 1841 | { id: "settle", label: `Settle (${count})` }, |
| 1842 | ...(canSnoozeSelection | |
| 1843 | ? [ | |
| 1844 | { | |
| 1845 | id: "snooze", | |
| 1846 | label: `Snooze (${count})`, | |
| 1847 | children: snoozePresets.map((preset) => ({ | |
| 1848 | id: `snooze:${preset.id}`, | |
| 1849 | label: `${preset.label} (${preset.whenLabel})`, | |
| 1850 | })), | |
| 1851 | }, | |
| 1852 | ] | |
| 1853 | : []), | |
| 1484 | 1854 | { id: "mark-unread", label: `Mark unread (${count})` }, |
| 1485 | 1855 | { id: "delete", label: `Delete (${count})`, destructive: true }, |
| 1486 | 1856 | ], |
| ... | ... | @@ -1488,6 +1858,23 @@ export default function SidebarV2() { |
| 1488 | 1858 | ), |
| 1489 | 1859 | ); |
| 1490 | 1860 | if (clicked._tag === "Failure") return; |
| 1861 | if (clicked.value?.startsWith("snooze:")) { | |
| 1862 | const preset = snoozePresets.find( | |
| 1863 | (candidate) => `snooze:${candidate.id}` === clicked.value, | |
| 1864 | ); | |
| 1865 | if (preset) { | |
| 1866 | // Post-snooze navigation must skip threads snoozing in this same | |
| 1867 | // batch — they are all leaving the card block together. | |
| 1868 | const coSnoozingKeys = new Set(threadKeys); | |
| 1869 | for (const thread of snoozableThreads) { | |
| 1870 | attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { | |
| 1871 | coSnoozingKeys, | |
| 1872 | }); | |
| 1873 | } | |
| 1874 | clearSelection(); | |
| 1875 | } | |
| 1876 | return; | |
| 1877 | } | |
| 1491 | 1878 | if (clicked.value === "settle") { |
| 1492 | 1879 | // Post-settle navigation must skip threads settling in this same |
| 1493 | 1880 | // batch — they are all leaving the card block together. Rows that |
| ... | ... | @@ -1552,11 +1939,13 @@ export default function SidebarV2() { |
| 1552 | 1939 | }, |
| 1553 | 1940 | [ |
| 1554 | 1941 | attemptSettle, |
| 1942 | attemptSnooze, | |
| 1555 | 1943 | clearSelection, |
| 1556 | 1944 | confirmThreadDelete, |
| 1557 | 1945 | deleteThread, |
| 1558 | 1946 | markThreadUnread, |
| 1559 | 1947 | removeFromSelection, |
| 1948 | serverConfigs, | |
| 1560 | 1949 | ], |
| 1561 | 1950 | ); |
| 1562 | 1951 | |
| ... | ... | @@ -1580,7 +1969,12 @@ export default function SidebarV2() { |
| 1580 | 1969 | const supportsSettlement = |
| 1581 | 1970 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === |
| 1582 | 1971 | true; |
| 1972 | const supportsSnooze = | |
| 1973 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | |
| 1583 | 1974 | const isSettled = settledThreadKeysRef.current.has(threadKey); |
| 1975 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); | |
| 1976 | // Presets resolve at menu-open time (same as the popover). | |
| 1977 | const snoozePresets = resolveSnoozePresets(new Date()); | |
| 1584 | 1978 | const clicked = await settlePromise(() => |
| 1585 | 1979 | api.contextMenu.show( |
| 1586 | 1980 | [ |
| ... | ... | @@ -1599,6 +1993,21 @@ export default function SidebarV2() { |
| 1599 | 1993 | : { id: "settle", label: "Settle thread" }, |
| 1600 | 1994 | ] |
| 1601 | 1995 | : []), |
| 1996 | ...(supportsSnooze | |
| 1997 | ? [ | |
| 1998 | isSnoozed | |
| 1999 | ? { id: "unsnooze", label: "Wake thread" } | |
| 2000 | : { | |
| 2001 | id: "snooze", | |
| 2002 | label: "Snooze", | |
| 2003 | disabled: !canSnooze(thread, { now: new Date().toISOString() }), | |
| 2004 | children: snoozePresets.map((preset) => ({ | |
| 2005 | id: `snooze:${preset.id}`, | |
| 2006 | label: `${preset.label} (${preset.whenLabel})`, | |
| 2007 | })), | |
| 2008 | }, | |
| 2009 | ] | |
| 2010 | : []), | |
| 1602 | 2011 | { id: "rename", label: "Rename thread" }, |
| 1603 | 2012 | { id: "mark-unread", label: "Mark unread" }, |
| 1604 | 2013 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, |
| ... | ... | @@ -1607,6 +2016,13 @@ export default function SidebarV2() { |
| 1607 | 2016 | ), |
| 1608 | 2017 | ); |
| 1609 | 2018 | if (clicked._tag === "Failure") return; |
| 2019 | if (clicked.value?.startsWith("snooze:")) { | |
| 2020 | const preset = snoozePresets.find( | |
| 2021 | (candidate) => `snooze:${candidate.id}` === clicked.value, | |
| 2022 | ); | |
| 2023 | if (preset) attemptSnooze(threadRef, preset); | |
| 2024 | return; | |
| 2025 | } | |
| 1610 | 2026 | switch (clicked.value) { |
| 1611 | 2027 | case "new-thread-on-branch": { |
| 1612 | 2028 | // Explicit branch carry-over: reuse the thread's worktree when it |
| ... | ... | @@ -1637,6 +2053,9 @@ export default function SidebarV2() { |
| 1637 | 2053 | case "unsettle": |
| 1638 | 2054 | attemptUnsettle(threadRef); |
| 1639 | 2055 | return; |
| 2056 | case "unsnooze": | |
| 2057 | attemptUnsnooze(threadRef); | |
| 2058 | return; | |
| 1640 | 2059 | case "rename": |
| 1641 | 2060 | startThreadRename(threadRef, thread.title); |
| 1642 | 2061 | return; |
| ... | ... | @@ -1676,7 +2095,9 @@ export default function SidebarV2() { |
| 1676 | 2095 | }, |
| 1677 | 2096 | [ |
| 1678 | 2097 | attemptSettle, |
| 2098 | attemptSnooze, | |
| 1679 | 2099 | attemptUnsettle, |
| 2100 | attemptUnsnooze, | |
| 1680 | 2101 | confirmThreadDelete, |
| 1681 | 2102 | deleteThread, |
| 1682 | 2103 | handleMultiSelectContextMenu, |
| ... | ... | @@ -1930,7 +2351,7 @@ export default function SidebarV2() { |
| 1930 | 2351 | </div> |
| 1931 | 2352 | </SidebarGroup> |
| 1932 | 2353 | ) : null} |
| 1933 | <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1"> | |
| 2354 | <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1 [scrollbar-gutter:stable]"> | |
| 1934 | 2355 | <TooltipProvider |
| 1935 | 2356 | key="sidebar-thread-tooltips-150" |
| 1936 | 2357 | delay={150} |
| ... | ... | @@ -1938,88 +2359,156 @@ export default function SidebarV2() { |
| 1938 | 2359 | timeout={400} |
| 1939 | 2360 | > |
| 1940 | 2361 | <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px"> |
| 1941 | {orderedThreads.flatMap((thread, threadIndex) => { | |
| 1942 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | |
| 1943 | const isSettledRow = settledThreadKeys.has(threadKey); | |
| 1944 | // Settled is the ONLY thing that collapses a row: every | |
| 1945 | // not-settled thread is a full card. Density comes from users | |
| 1946 | // (or the auto rules) actually settling work, not from the | |
| 1947 | // sidebar second-guessing what still matters. | |
| 1948 | const isCard = !isSettledRow; | |
| 1949 | const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; | |
| 1950 | const previousWasCard = | |
| 1951 | previousThread != null && | |
| 1952 | !settledThreadKeys.has( | |
| 1953 | scopedThreadKey( | |
| 1954 | scopeThreadRef(previousThread.environmentId, previousThread.id), | |
| 1955 | ), | |
| 2362 | {(() => { | |
| 2363 | const renderThreadRow = ( | |
| 2364 | thread: EnvironmentThreadShell, | |
| 2365 | section: "active" | "snoozed" | "settled", | |
| 2366 | ) => { | |
| 2367 | const threadKey = scopedThreadKey( | |
| 2368 | scopeThreadRef(thread.environmentId, thread.id), | |
| 1956 | 2369 | ); |
| 1957 | const showSettledGap = !isCard && previousWasCard; | |
| 1958 | const row = ( | |
| 1959 | <SidebarV2Row | |
| 1960 | // Keyed per variant on purpose: when a thread settles, the | |
| 1961 | // card fades out in place and the slim row fades in at its | |
| 1962 | // settled position instead of one element FLIP-sliding | |
| 1963 | // through every row in between (rows here are translucent, | |
| 1964 | // so a crossing row reads as text painted over text). | |
| 1965 | key={`${threadKey}:${isCard ? "card" : "slim"}`} | |
| 1966 | thread={thread} | |
| 1967 | variant={isCard ? "card" : "slim"} | |
| 1968 | // Every settled row can un-settle: explicit settles clear | |
| 1969 | // the override, auto-settled rows get pinned active. | |
| 1970 | variantAction={isSettledRow ? "unsettle" : "settle"} | |
| 1971 | settlementSupported={ | |
| 1972 | serverConfigs.get(thread.environmentId)?.environment.capabilities | |
| 1973 | .threadSettlement === true | |
| 1974 | } | |
| 1975 | isActive={routeThreadKey === threadKey} | |
| 1976 | jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} | |
| 1977 | currentEnvironmentId={primaryEnvironmentId} | |
| 1978 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} | |
| 1979 | projectCwd={ | |
| 1980 | projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null | |
| 1981 | } | |
| 1982 | projectTitle={ | |
| 1983 | projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? | |
| 1984 | null | |
| 1985 | } | |
| 1986 | providerEntryByInstanceId={providerEntryByInstanceId} | |
| 1987 | onThreadClick={handleThreadClick} | |
| 1988 | onThreadActivate={navigateToThread} | |
| 1989 | onStartRename={startThreadRename} | |
| 1990 | onRenameTitleChange={setRenamingTitle} | |
| 1991 | onCommitRename={commitThreadRename} | |
| 1992 | onCancelRename={cancelThreadRename} | |
| 1993 | isRenaming={renamingThreadKey === threadKey} | |
| 1994 | renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} | |
| 1995 | onContextMenu={handleThreadContextMenu} | |
| 1996 | onSettle={attemptSettle} | |
| 1997 | onUnsettle={attemptUnsettle} | |
| 1998 | onChangeRequestState={handleChangeRequestState} | |
| 1999 | /> | |
| 2370 | // Settled and snoozed are the ONLY things that collapse a | |
| 2371 | // row: every other thread is a full card. Density comes | |
| 2372 | // from users (or the auto rules) actually parking work, | |
| 2373 | // not from the sidebar second-guessing what still matters. | |
| 2374 | const isCard = section === "active"; | |
| 2375 | const rowVariant = isCard ? "card" : "slim"; | |
| 2376 | return ( | |
| 2377 | <SidebarV2Row | |
| 2378 | // Keyed per variant on purpose: when a thread settles, | |
| 2379 | // the card fades out in place and the slim row fades | |
| 2380 | // in at its settled position instead of one element | |
| 2381 | // FLIP-sliding through every row in between (rows here | |
| 2382 | // are translucent, so a crossing row reads as text | |
| 2383 | // painted over text). | |
| 2384 | key={`${threadKey}:${rowVariant}`} | |
| 2385 | thread={thread} | |
| 2386 | variant={rowVariant} | |
| 2387 | // Snoozed rows wake; settled rows un-settle (explicit | |
| 2388 | // settles clear the override, auto-settled rows get | |
| 2389 | // pinned active); cards settle. | |
| 2390 | variantAction={ | |
| 2391 | section === "snoozed" | |
| 2392 | ? "unsnooze" | |
| 2393 | : section === "settled" | |
| 2394 | ? "unsettle" | |
| 2395 | : "settle" | |
| 2396 | } | |
| 2397 | settlementSupported={ | |
| 2398 | serverConfigs.get(thread.environmentId)?.environment.capabilities | |
| 2399 | .threadSettlement === true | |
| 2400 | } | |
| 2401 | snoozeSupported={ | |
| 2402 | serverConfigs.get(thread.environmentId)?.environment.capabilities | |
| 2403 | .threadSnooze === true | |
| 2404 | } | |
| 2405 | snoozeWakeLabelText={ | |
| 2406 | section === "snoozed" && thread.snoozedUntil != null | |
| 2407 | ? snoozeWakeLabel(thread.snoozedUntil, new Date()) | |
| 2408 | : null | |
| 2409 | } | |
| 2410 | // All sections: a woken thread can classify straight | |
| 2411 | // into the settled tail (PR merged while snoozed), and | |
| 2412 | // the wake signal must survive the trip. Still-snoozed | |
| 2413 | // rows resolve to null on their own. | |
| 2414 | wokeAt={threadWokeAt(thread, { now: snoozeNow })} | |
| 2415 | isActive={routeThreadKey === threadKey} | |
| 2416 | jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} | |
| 2417 | currentEnvironmentId={primaryEnvironmentId} | |
| 2418 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} | |
| 2419 | projectCwd={ | |
| 2420 | projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null | |
| 2421 | } | |
| 2422 | projectTitle={ | |
| 2423 | projectDisplayNameByKey.get( | |
| 2424 | `${thread.environmentId}:${thread.projectId}`, | |
| 2425 | ) ?? null | |
| 2426 | } | |
| 2427 | providerEntryByInstanceId={providerEntryByInstanceId} | |
| 2428 | onThreadClick={handleThreadClick} | |
| 2429 | onThreadActivate={navigateToThread} | |
| 2430 | onStartRename={startThreadRename} | |
| 2431 | onRenameTitleChange={setRenamingTitle} | |
| 2432 | onCommitRename={commitThreadRename} | |
| 2433 | onCancelRename={cancelThreadRename} | |
| 2434 | isRenaming={renamingThreadKey === threadKey} | |
| 2435 | renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} | |
| 2436 | onContextMenu={handleThreadContextMenu} | |
| 2437 | onSettle={attemptSettle} | |
| 2438 | onUnsettle={attemptUnsettle} | |
| 2439 | onSnooze={attemptSnooze} | |
| 2440 | onUnsnooze={attemptUnsnooze} | |
| 2441 | onChangeRequestState={handleChangeRequestState} | |
| 2442 | /> | |
| 2443 | ); | |
| 2444 | }; | |
| 2445 | const items: ReactNode[] = activeThreads.map((thread) => | |
| 2446 | renderThreadRow(thread, "active"), | |
| 2000 | 2447 | ); |
| 2001 | if (!showSettledGap) return [row]; | |
| 2002 | // The divider is its own keyed list item (not part of the first | |
| 2003 | // settled row): it keeps one stable DOM node at the boundary, | |
| 2004 | // so settling a thread slides it instead of teleporting it | |
| 2005 | // along with whichever row happens to be first in the tail — | |
| 2006 | // and row heights stay independent of neighbor classification. | |
| 2007 | return [ | |
| 2008 | <li | |
| 2009 | key="settled-divider" | |
| 2010 | aria-hidden | |
| 2011 | data-thread-selection-safe | |
| 2012 | className="list-none" | |
| 2013 | > | |
| 2014 | <div className="mb-1 mt-3 flex items-center gap-2 px-2.5"> | |
| 2015 | <span className="text-xs font-medium text-muted-foreground/50">Settled</span> | |
| 2016 | <span className="h-px flex-1 bg-sidebar-border/60" /> | |
| 2017 | </div> | |
| 2018 | </li>, | |
| 2019 | row, | |
| 2020 | ]; | |
| 2021 | })} | |
| 2022 | {hiddenSettledCount > 0 ? ( | |
| 2448 | // Snoozed shelf: between the inbox and Settled — out of the | |
| 2449 | // way, never gone. The header always renders while anything | |
| 2450 | // is snoozed (the count is the whole footprint when | |
| 2451 | // collapsed); rows only when expanded. Vanishes entirely at | |
| 2452 | // count 0. | |
| 2453 | if (snoozedThreads.length > 0) { | |
| 2454 | items.push( | |
| 2455 | <li key="snoozed-shelf-header" data-thread-selection-safe className="list-none"> | |
| 2456 | <button | |
| 2457 | type="button" | |
| 2458 | onClick={toggleSnoozedShelf} | |
| 2459 | aria-expanded={snoozedShelfExpanded} | |
| 2460 | data-testid="sidebar-v2-snoozed-shelf-toggle" | |
| 2461 | className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left" | |
| 2462 | > | |
| 2463 | <span className="text-xs font-medium text-blue-600 dark:text-blue-400"> | |
| 2464 | {snoozedShelfExpanded ? "Snoozed" : `Snoozed (${snoozedThreads.length})`} | |
| 2465 | </span> | |
| 2466 | <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" /> | |
| 2467 | <ChevronDownIcon | |
| 2468 | aria-hidden | |
| 2469 | className={cn( | |
| 2470 | "size-3 text-blue-600 transition-transform dark:text-blue-400", | |
| 2471 | snoozedShelfExpanded && "rotate-180", | |
| 2472 | )} | |
| 2473 | /> | |
| 2474 | </button> | |
| 2475 | </li>, | |
| 2476 | ); | |
| 2477 | for (const thread of visibleSnoozedThreads) { | |
| 2478 | items.push(renderThreadRow(thread, "snoozed")); | |
| 2479 | } | |
| 2480 | } | |
| 2481 | if (settledThreads.length > 0) { | |
| 2482 | items.push( | |
| 2483 | <li key="settled-shelf-header" data-thread-selection-safe className="list-none"> | |
| 2484 | <button | |
| 2485 | type="button" | |
| 2486 | onClick={toggleSettledShelf} | |
| 2487 | aria-expanded={settledShelfExpanded} | |
| 2488 | data-testid="sidebar-v2-settled-shelf-toggle" | |
| 2489 | className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left" | |
| 2490 | > | |
| 2491 | <span className="text-xs font-medium text-muted-foreground/50"> | |
| 2492 | {settledShelfExpanded ? "Settled" : `Settled (${settledThreads.length})`} | |
| 2493 | </span> | |
| 2494 | <span className="h-px flex-1 bg-sidebar-border/60" /> | |
| 2495 | <ChevronDownIcon | |
| 2496 | aria-hidden | |
| 2497 | className={cn( | |
| 2498 | "size-3 text-muted-foreground/50 transition-transform", | |
| 2499 | settledShelfExpanded && "rotate-180", | |
| 2500 | )} | |
| 2501 | /> | |
| 2502 | </button> | |
| 2503 | </li>, | |
| 2504 | ); | |
| 2505 | } | |
| 2506 | for (const thread of renderedSettledThreads) { | |
| 2507 | items.push(renderThreadRow(thread, "settled")); | |
| 2508 | } | |
| 2509 | return items; | |
| 2510 | })()} | |
| 2511 | {settledShelfExpanded && hiddenSettledCount > 0 ? ( | |
| 2023 | 2512 | <li className="list-none"> |
| 2024 | 2513 | <button |
| 2025 | 2514 | type="button" |
| ... | ... | @@ -2035,7 +2524,7 @@ export default function SidebarV2() { |
| 2035 | 2524 | ) : null} |
| 2036 | 2525 | </ul> |
| 2037 | 2526 | </TooltipProvider> |
| 2038 | {orderedThreads.length === 0 ? ( | |
| 2527 | {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? ( | |
| 2039 | 2528 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> |
| 2040 | 2529 | {projects.length === 0 ? ( |
| 2041 | 2530 | <> |
apps/web/src/hooks/useThreadActions.ts+91-1| ... | ... | @@ -4,7 +4,7 @@ import { |
| 4 | 4 | scopeThreadRef, |
| 5 | 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | 6 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; | |
| 7 | import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; | |
| 8 | 8 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 9 | 9 | import * as Cause from "effect/Cause"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -22,6 +22,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat |
| 22 | 22 | import { readLocalApi } from "../localApi"; |
| 23 | 23 | import { |
| 24 | 24 | readEnvironmentSupportsSettlement, |
| 25 | readEnvironmentSupportsSnooze, | |
| 25 | 26 | readEnvironmentThreadRefs, |
| 26 | 27 | readProject, |
| 27 | 28 | readThreadShell, |
| ... | ... | @@ -69,6 +70,30 @@ export class ThreadSettleBlockedError extends Schema.TaggedErrorClass<ThreadSett |
| 69 | 70 | } |
| 70 | 71 | } |
| 71 | 72 | |
| 73 | export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass<ThreadSnoozeUnsupportedError>()( | |
| 74 | "ThreadSnoozeUnsupportedError", | |
| 75 | { | |
| 76 | environmentId: EnvironmentId, | |
| 77 | threadId: ThreadId, | |
| 78 | }, | |
| 79 | ) { | |
| 80 | override get message(): string { | |
| 81 | return "This environment's server does not support snoozing yet. Update the server to use Snooze."; | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoozeBlockedError>()( | |
| 86 | "ThreadSnoozeBlockedError", | |
| 87 | { | |
| 88 | environmentId: EnvironmentId, | |
| 89 | threadId: ThreadId, | |
| 90 | }, | |
| 91 | ) { | |
| 92 | override get message(): string { | |
| 93 | return "This thread is waiting on you. Respond to the pending request before snoozing it."; | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 72 | 97 | export function useThreadActions() { |
| 73 | 98 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 74 | 99 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | ... | @@ -86,6 +111,12 @@ export function useThreadActions() { |
| 86 | 111 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 87 | 112 | reportFailure: false, |
| 88 | 113 | }); |
| 114 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { | |
| 115 | reportFailure: false, | |
| 116 | }); | |
| 117 | const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { | |
| 118 | reportFailure: false, | |
| 119 | }); | |
| 89 | 120 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); |
| 90 | 121 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { |
| 91 | 122 | reportFailure: false, |
| ... | ... | @@ -441,6 +472,61 @@ export function useThreadActions() { |
| 441 | 472 | [unsettleThreadMutation], |
| 442 | 473 | ); |
| 443 | 474 | |
| 475 | const snoozeThread = useCallback( | |
| 476 | async (target: ScopedThreadRef, snoozedUntil: string) => { | |
| 477 | // Version skew: never send the command to a server that predates it. | |
| 478 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { | |
| 479 | return AsyncResult.failure( | |
| 480 | Cause.fail( | |
| 481 | new ThreadSnoozeUnsupportedError({ | |
| 482 | environmentId: target.environmentId, | |
| 483 | threadId: target.threadId, | |
| 484 | }), | |
| 485 | ), | |
| 486 | ); | |
| 487 | } | |
| 488 | const resolved = resolveThreadTarget(target); | |
| 489 | // Blocked-on-you work and queued turns can't be snoozed away — | |
| 490 | // client-side twin of the server invariants so the UI rejects before | |
| 491 | // a round trip. | |
| 492 | if (resolved && !canSnooze(resolved.thread, { now: new Date().toISOString() })) { | |
| 493 | return AsyncResult.failure( | |
| 494 | Cause.fail( | |
| 495 | new ThreadSnoozeBlockedError({ | |
| 496 | environmentId: resolved.threadRef.environmentId, | |
| 497 | threadId: resolved.threadRef.threadId, | |
| 498 | }), | |
| 499 | ), | |
| 500 | ); | |
| 501 | } | |
| 502 | return snoozeThreadMutation({ | |
| 503 | environmentId: target.environmentId, | |
| 504 | input: { threadId: target.threadId, snoozedUntil }, | |
| 505 | }); | |
| 506 | }, | |
| 507 | [resolveThreadTarget, snoozeThreadMutation], | |
| 508 | ); | |
| 509 | ||
| 510 | const unsnoozeThread = useCallback( | |
| 511 | async (target: ScopedThreadRef) => { | |
| 512 | if (!readEnvironmentSupportsSnooze(target.environmentId)) { | |
| 513 | return AsyncResult.failure( | |
| 514 | Cause.fail( | |
| 515 | new ThreadSnoozeUnsupportedError({ | |
| 516 | environmentId: target.environmentId, | |
| 517 | threadId: target.threadId, | |
| 518 | }), | |
| 519 | ), | |
| 520 | ); | |
| 521 | } | |
| 522 | return unsnoozeThreadMutation({ | |
| 523 | environmentId: target.environmentId, | |
| 524 | input: { threadId: target.threadId, reason: "user" }, | |
| 525 | }); | |
| 526 | }, | |
| 527 | [unsnoozeThreadMutation], | |
| 528 | ); | |
| 529 | ||
| 444 | 530 | const confirmAndDeleteThread = useCallback( |
| 445 | 531 | async (target: ScopedThreadRef) => { |
| 446 | 532 | const localApi = readLocalApi(); |
| ... | ... | @@ -477,14 +563,18 @@ export function useThreadActions() { |
| 477 | 563 | confirmAndDeleteThread, |
| 478 | 564 | settleThread, |
| 479 | 565 | unsettleThread, |
| 566 | snoozeThread, | |
| 567 | unsnoozeThread, | |
| 480 | 568 | }), |
| 481 | 569 | [ |
| 482 | 570 | archiveThread, |
| 483 | 571 | confirmAndDeleteThread, |
| 484 | 572 | deleteThread, |
| 485 | 573 | settleThread, |
| 574 | snoozeThread, | |
| 486 | 575 | unarchiveThread, |
| 487 | 576 | unsettleThread, |
| 577 | unsnoozeThread, | |
| 488 | 578 | ], |
| 489 | 579 | ); |
| 490 | 580 | } |
apps/web/src/state/entities.ts+9| ... | ... | @@ -207,6 +207,15 @@ export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): |
| 207 | 207 | ); |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | /** Whether the environment's server understands thread.snooze/unsnooze. | |
| 211 | Same version-skew contract as settlement. */ | |
| 212 | export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean { | |
| 213 | return ( | |
| 214 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 215 | .threadSnooze === true | |
| 216 | ); | |
| 217 | } | |
| 218 | ||
| 210 | 219 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { |
| 211 | 220 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); |
| 212 | 221 | } |
apps/web/src/timestampFormat.ts+1-1| ... | ... | @@ -40,7 +40,7 @@ function getTimestampFormatter( |
| 40 | 40 | return formatter; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | function parseTimestampDate(isoDate: string): Date | null { | |
| 43 | export function parseTimestampDate(isoDate: string): Date | null { | |
| 44 | 44 | const date = new Date(isoDate); |
| 45 | 45 | return Number.isNaN(date.getTime()) ? null : date; |
| 46 | 46 | } |
packages/client-runtime/src/operations/commands.ts+22| ... | ... | @@ -37,6 +37,8 @@ export type ArchiveThreadInput = CommandInput<"thread.archive">; |
| 37 | 37 | export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; |
| 38 | 38 | export type SettleThreadInput = CommandInput<"thread.settle">; |
| 39 | 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; |
| 40 | export type SnoozeThreadInput = CommandInput<"thread.snooze">; | |
| 41 | export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; | |
| 40 | 42 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 41 | 43 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 42 | 44 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| ... | ... | @@ -175,6 +177,26 @@ export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Eff |
| 175 | 177 | }); |
| 176 | 178 | }); |
| 177 | 179 | |
| 180 | export const snoozeThread: (input: SnoozeThreadInput) => CommandEffect = Effect.fn( | |
| 181 | "EnvironmentCommands.snoozeThread", | |
| 182 | )(function* (input) { | |
| 183 | return yield* dispatch({ | |
| 184 | ...input, | |
| 185 | type: "thread.snooze", | |
| 186 | commandId: yield* commandId(input), | |
| 187 | }); | |
| 188 | }); | |
| 189 | ||
| 190 | export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Effect.fn( | |
| 191 | "EnvironmentCommands.unsnoozeThread", | |
| 192 | )(function* (input) { | |
| 193 | return yield* dispatch({ | |
| 194 | ...input, | |
| 195 | type: "thread.unsnooze", | |
| 196 | commandId: yield* commandId(input), | |
| 197 | }); | |
| 198 | }); | |
| 199 | ||
| 178 | 200 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( |
| 179 | 201 | "EnvironmentCommands.updateThreadMetadata", |
| 180 | 202 | )(function* (input) { |
packages/client-runtime/src/state/threadCommands.ts+18| ... | ... | @@ -13,10 +13,12 @@ import { |
| 13 | 13 | type SetThreadInteractionModeInput, |
| 14 | 14 | type SetThreadRuntimeModeInput, |
| 15 | 15 | type SettleThreadInput, |
| 16 | type SnoozeThreadInput, | |
| 16 | 17 | type StartThreadTurnInput, |
| 17 | 18 | type StopThreadSessionInput, |
| 18 | 19 | type UnarchiveThreadInput, |
| 19 | 20 | type UnsettleThreadInput, |
| 21 | type UnsnoozeThreadInput, | |
| 20 | 22 | type UpdateThreadMetadataInput, |
| 21 | 23 | archiveThread, |
| 22 | 24 | createThread, |
| ... | ... | @@ -28,10 +30,12 @@ import { |
| 28 | 30 | setThreadInteractionMode, |
| 29 | 31 | setThreadRuntimeMode, |
| 30 | 32 | settleThread, |
| 33 | snoozeThread, | |
| 31 | 34 | startThreadTurn, |
| 32 | 35 | stopThreadSession, |
| 33 | 36 | unarchiveThread, |
| 34 | 37 | unsettleThread, |
| 38 | unsnoozeThread, | |
| 35 | 39 | updateThreadMetadata, |
| 36 | 40 | } from "../operations/commands.ts"; |
| 37 | 41 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| ... | ... | @@ -47,10 +51,12 @@ export type { |
| 47 | 51 | SetThreadInteractionModeInput, |
| 48 | 52 | SetThreadRuntimeModeInput, |
| 49 | 53 | SettleThreadInput, |
| 54 | SnoozeThreadInput, | |
| 50 | 55 | StartThreadTurnInput, |
| 51 | 56 | StopThreadSessionInput, |
| 52 | 57 | UnarchiveThreadInput, |
| 53 | 58 | UnsettleThreadInput, |
| 59 | UnsnoozeThreadInput, | |
| 54 | 60 | UpdateThreadMetadataInput, |
| 55 | 61 | } from "../operations/commands.ts"; |
| 56 | 62 | |
| ... | ... | @@ -100,6 +106,18 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 100 | 106 | scheduler, |
| 101 | 107 | concurrency, |
| 102 | 108 | }), |
| 109 | snooze: createEnvironmentCommand(runtime, { | |
| 110 | label: "environment-data:commands:thread:snooze", | |
| 111 | execute: (input: SnoozeThreadInput) => snoozeThread(input), | |
| 112 | scheduler, | |
| 113 | concurrency, | |
| 114 | }), | |
| 115 | unsnooze: createEnvironmentCommand(runtime, { | |
| 116 | label: "environment-data:commands:thread:unsnooze", | |
| 117 | execute: (input: UnsnoozeThreadInput) => unsnoozeThread(input), | |
| 118 | scheduler, | |
| 119 | concurrency, | |
| 120 | }), | |
| 103 | 121 | updateMetadata: createEnvironmentCommand(runtime, { |
| 104 | 122 | label: "environment-data:commands:thread:update-metadata", |
| 105 | 123 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), |
packages/client-runtime/src/state/threadDetail.ts+2| ... | ... | @@ -59,6 +59,8 @@ export function mergeEnvironmentThread( |
| 59 | 59 | archivedAt: shell.archivedAt, |
| 60 | 60 | settledOverride: shell.settledOverride, |
| 61 | 61 | settledAt: shell.settledAt, |
| 62 | snoozedUntil: shell.snoozedUntil, | |
| 63 | snoozedAt: shell.snoozedAt, | |
| 62 | 64 | session: shell.session, |
| 63 | 65 | }; |
| 64 | 66 | } |
packages/client-runtime/src/state/threadReducer.ts+24| ... | ... | @@ -74,6 +74,8 @@ export function applyThreadDetailEvent( |
| 74 | 74 | archivedAt: null, |
| 75 | 75 | settledOverride: null, |
| 76 | 76 | settledAt: null, |
| 77 | snoozedUntil: null, | |
| 78 | snoozedAt: null, | |
| 77 | 79 | deletedAt: null, |
| 78 | 80 | messages: [], |
| 79 | 81 | proposedPlans: [], |
| ... | ... | @@ -124,6 +126,28 @@ export function applyThreadDetailEvent( |
| 124 | 126 | }, |
| 125 | 127 | }; |
| 126 | 128 | |
| 129 | case "thread.snoozed": | |
| 130 | return { | |
| 131 | kind: "updated", | |
| 132 | thread: { | |
| 133 | ...thread, | |
| 134 | snoozedUntil: event.payload.snoozedUntil, | |
| 135 | snoozedAt: event.payload.snoozedAt, | |
| 136 | updatedAt: event.payload.updatedAt, | |
| 137 | }, | |
| 138 | }; | |
| 139 | ||
| 140 | case "thread.unsnoozed": | |
| 141 | return { | |
| 142 | kind: "updated", | |
| 143 | thread: { | |
| 144 | ...thread, | |
| 145 | snoozedUntil: null, | |
| 146 | snoozedAt: null, | |
| 147 | updatedAt: event.payload.updatedAt, | |
| 148 | }, | |
| 149 | }; | |
| 150 | ||
| 127 | 151 | // ── Thread metadata ───────────────────────────────────────────── |
| 128 | 152 | case "thread.meta-updated": |
| 129 | 153 | return { |
packages/client-runtime/src/state/threadSettled.ts+124| ... | ... | @@ -90,6 +90,130 @@ export function canSettle( |
| 90 | 90 | return true; |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | /** | |
| 94 | * The snooze lifecycle fields plus everything needed to detect a raised | |
| 95 | * hand. Snooze is an overlay on the active state: a snoozed thread stays | |
| 96 | * "active" in the data model and is only suppressed from the inbox until | |
| 97 | * its wake time passes or the thread demands attention. | |
| 98 | */ | |
| 99 | export type ThreadSnoozeShell = Pick< | |
| 100 | OrchestrationThreadShell, | |
| 101 | | "snoozedUntil" | |
| 102 | | "snoozedAt" | |
| 103 | | "hasPendingApprovals" | |
| 104 | | "hasPendingUserInput" | |
| 105 | | "session" | |
| 106 | | "latestTurn" | |
| 107 | >; | |
| 108 | ||
| 109 | /** | |
| 110 | * A snoozed thread "raises its hand" when something happens that outranks | |
| 111 | * the user's snooze: the agent is blocked on them (approval / user input), | |
| 112 | * the session failed, or a run completed after the snooze was set — the | |
| 113 | * v1 taste of event-based snooze ("something happened" wakes early). | |
| 114 | * Raising a hand never clears the server-side snooze fields; it only stops | |
| 115 | * the thread from CLASSIFYING as snoozed, exactly like blocked work and | |
| 116 | * effectiveSettled. | |
| 117 | */ | |
| 118 | export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { | |
| 119 | if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; | |
| 120 | // Only a FRESH failure raises the hand: a thread snoozed while already | |
| 121 | // failed stays snoozed — that snooze was the user saying "I saw it, not | |
| 122 | // now". session.updatedAt stamps the status edge, so an error newer than | |
| 123 | // the snooze is new information. | |
| 124 | if ( | |
| 125 | shell.session?.status === "error" && | |
| 126 | (shell.snoozedAt == null || Date.parse(shell.session.updatedAt) > Date.parse(shell.snoozedAt)) | |
| 127 | ) { | |
| 128 | return true; | |
| 129 | } | |
| 130 | if ( | |
| 131 | shell.snoozedAt != null && | |
| 132 | shell.latestTurn?.state === "completed" && | |
| 133 | shell.latestTurn.completedAt != null && | |
| 134 | Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt) | |
| 135 | ) { | |
| 136 | return true; | |
| 137 | } | |
| 138 | return false; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * A thread may be snoozed unless the agent is blocked on the user: hiding a | |
| 143 | * pending approval or user-input request defeats the request, and a queued | |
| 144 | * turn start (a message no turn has adopted yet) is invisible pending work | |
| 145 | * the same way it is for settle. A running session IS snoozable — snooze | |
| 146 | * only affects visibility, never the agent. Client-side twin of the server | |
| 147 | * invariants so the UI can reject before a round trip. | |
| 148 | */ | |
| 149 | export function canSnooze( | |
| 150 | shell: Pick< | |
| 151 | OrchestrationThreadShell, | |
| 152 | "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" | |
| 153 | >, | |
| 154 | options: { readonly now: string }, | |
| 155 | ): boolean { | |
| 156 | if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; | |
| 157 | if (hasQueuedTurnStart(shell, options)) return false; | |
| 158 | return true; | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Snoozed resolution: hidden from the inbox while the wake time is in the | |
| 163 | * future and the thread has not raised its hand. Timer wakes are derived — | |
| 164 | * no server event fires when snoozedUntil passes; the stale fields simply | |
| 165 | * stop classifying as snoozed (and feed the woke indicator until the user | |
| 166 | * visits or re-engages). | |
| 167 | */ | |
| 168 | export function effectiveSnoozed( | |
| 169 | shell: ThreadSnoozeShell, | |
| 170 | options: { readonly now: string }, | |
| 171 | ): boolean { | |
| 172 | if (shell.snoozedUntil == null) return false; | |
| 173 | const wakeAtMs = Date.parse(shell.snoozedUntil); | |
| 174 | // Malformed data never hides a thread. | |
| 175 | if (Number.isNaN(wakeAtMs)) return false; | |
| 176 | if (wakeAtMs <= Date.parse(options.now)) return false; | |
| 177 | return !threadRaisedHandWhileSnoozed(shell); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * When a previously-snoozed thread woke, or null if it never snoozed / is | |
| 182 | * still snoozed. Used for the "Woke" indicator: the thread reappears in its | |
| 183 | * original sort position (the inbox sort is deliberately static), so the | |
| 184 | * wake signal has to carry the weight. Compare against the client's | |
| 185 | * lastVisitedAt — visiting clears the indicator like it clears unread. | |
| 186 | * | |
| 187 | * Timer wakes report the wake time itself; raised-hand wakes report the | |
| 188 | * triggering timestamp so a visit BEFORE the early wake doesn't suppress | |
| 189 | * the indicator. | |
| 190 | */ | |
| 191 | export function threadWokeAt( | |
| 192 | shell: ThreadSnoozeShell, | |
| 193 | options: { readonly now: string }, | |
| 194 | ): string | null { | |
| 195 | if (shell.snoozedUntil == null) return null; | |
| 196 | const wakeAtMs = Date.parse(shell.snoozedUntil); | |
| 197 | if (Number.isNaN(wakeAtMs)) return null; | |
| 198 | // An early hand-raise wake stays authoritative even after the scheduled | |
| 199 | // wake time passes: reporting snoozedUntil then would resurface a Woke | |
| 200 | // indicator the user already cleared by visiting (snoozedUntil is newer | |
| 201 | // than that visit's lastVisitedAt). | |
| 202 | if (threadRaisedHandWhileSnoozed(shell)) { | |
| 203 | if ( | |
| 204 | shell.snoozedAt != null && | |
| 205 | shell.latestTurn?.state === "completed" && | |
| 206 | shell.latestTurn.completedAt != null && | |
| 207 | Date.parse(shell.latestTurn.completedAt) > Date.parse(shell.snoozedAt) | |
| 208 | ) { | |
| 209 | return shell.latestTurn.completedAt; | |
| 210 | } | |
| 211 | return shell.session?.updatedAt ?? shell.snoozedAt ?? null; | |
| 212 | } | |
| 213 | // No raised hand: woke iff the timer elapsed (still-snoozed → null). | |
| 214 | return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; | |
| 215 | } | |
| 216 | ||
| 93 | 217 | /** |
| 94 | 218 | * A merged/closed change request settles its thread only once the thread has |
| 95 | 219 | * been idle this long. Without the idle guard the merge signal is permanent: |
packages/client-runtime/src/state/threadSnoozed.test.ts created+237| ... | ... | @@ -0,0 +1,237 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { TurnId } from "@t3tools/contracts"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | canSnooze, | |
| 7 | effectiveSnoozed, | |
| 8 | threadRaisedHandWhileSnoozed, | |
| 9 | threadWokeAt, | |
| 10 | type ThreadSnoozeShell, | |
| 11 | } from "./threadSettled.ts"; | |
| 12 | ||
| 13 | const NOW = "2026-04-10T12:00:00.000Z"; | |
| 14 | const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; | |
| 15 | const FUTURE_WAKE = "2026-04-11T09:00:00.000Z"; | |
| 16 | const PAST_WAKE = "2026-04-10T10:00:00.000Z"; | |
| 17 | ||
| 18 | function makeShell(input: { | |
| 19 | readonly snoozedUntil?: string | null; | |
| 20 | readonly snoozedAt?: string | null; | |
| 21 | readonly sessionStatus?: "starting" | "running" | "ready" | "error"; | |
| 22 | readonly pending?: "approval" | "user-input"; | |
| 23 | readonly turnCompletedAt?: string | null; | |
| 24 | }): ThreadSnoozeShell { | |
| 25 | const threadId = ThreadId.make("thread-1"); | |
| 26 | return { | |
| 27 | snoozedUntil: input.snoozedUntil ?? null, | |
| 28 | snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), | |
| 29 | hasPendingApprovals: input.pending === "approval", | |
| 30 | hasPendingUserInput: input.pending === "user-input", | |
| 31 | session: | |
| 32 | input.sessionStatus === undefined | |
| 33 | ? null | |
| 34 | : { | |
| 35 | threadId, | |
| 36 | status: input.sessionStatus, | |
| 37 | providerName: "Codex", | |
| 38 | runtimeMode: "full-access", | |
| 39 | activeTurnId: null, | |
| 40 | lastError: input.sessionStatus === "error" ? "boom" : null, | |
| 41 | updatedAt: "2026-04-10T11:00:00.000Z", | |
| 42 | }, | |
| 43 | latestTurn: | |
| 44 | input.turnCompletedAt === undefined | |
| 45 | ? null | |
| 46 | : { | |
| 47 | turnId: TurnId.make("turn-1"), | |
| 48 | state: "completed", | |
| 49 | requestedAt: SNOOZED_AT, | |
| 50 | startedAt: null, | |
| 51 | completedAt: input.turnCompletedAt, | |
| 52 | assistantMessageId: null, | |
| 53 | }, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | describe("effectiveSnoozed", () => { | |
| 58 | it("hides a thread whose wake time is in the future", () => { | |
| 59 | expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true); | |
| 60 | }); | |
| 61 | ||
| 62 | it("stops classifying as snoozed once the wake time passes (timer wake, no event)", () => { | |
| 63 | expect(effectiveSnoozed(makeShell({ snoozedUntil: PAST_WAKE }), { now: NOW })).toBe(false); | |
| 64 | }); | |
| 65 | ||
| 66 | it("never snoozes a thread with no snooze state", () => { | |
| 67 | expect(effectiveSnoozed(makeShell({}), { now: NOW })).toBe(false); | |
| 68 | }); | |
| 69 | ||
| 70 | it("never hides on malformed wake data", () => { | |
| 71 | expect(effectiveSnoozed(makeShell({ snoozedUntil: "not-a-date" }), { now: NOW })).toBe(false); | |
| 72 | }); | |
| 73 | ||
| 74 | it("wakes early when the agent is blocked on the user", () => { | |
| 75 | expect( | |
| 76 | effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "approval" }), { | |
| 77 | now: NOW, | |
| 78 | }), | |
| 79 | ).toBe(false); | |
| 80 | expect( | |
| 81 | effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input" }), { | |
| 82 | now: NOW, | |
| 83 | }), | |
| 84 | ).toBe(false); | |
| 85 | }); | |
| 86 | ||
| 87 | it("wakes early on a failure that happened after the snooze", () => { | |
| 88 | // makeShell stamps session.updatedAt at 11:00, after SNOOZED_AT (9:00). | |
| 89 | expect( | |
| 90 | effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }), { | |
| 91 | now: NOW, | |
| 92 | }), | |
| 93 | ).toBe(false); | |
| 94 | }); | |
| 95 | ||
| 96 | it("stays snoozed when the failure predates the snooze — the user saw it", () => { | |
| 97 | expect( | |
| 98 | effectiveSnoozed( | |
| 99 | makeShell({ | |
| 100 | snoozedUntil: FUTURE_WAKE, | |
| 101 | sessionStatus: "error", | |
| 102 | // Snoozed AFTER the error's status edge. | |
| 103 | snoozedAt: "2026-04-10T11:30:00.000Z", | |
| 104 | }), | |
| 105 | { now: NOW }, | |
| 106 | ), | |
| 107 | ).toBe(true); | |
| 108 | }); | |
| 109 | ||
| 110 | it("stays snoozed while the session keeps working — snooze never pauses the agent", () => { | |
| 111 | expect( | |
| 112 | effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "running" }), { | |
| 113 | now: NOW, | |
| 114 | }), | |
| 115 | ).toBe(true); | |
| 116 | }); | |
| 117 | ||
| 118 | it("wakes early when a run completes after the snooze was set", () => { | |
| 119 | expect( | |
| 120 | effectiveSnoozed( | |
| 121 | makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }), | |
| 122 | { now: NOW }, | |
| 123 | ), | |
| 124 | ).toBe(false); | |
| 125 | }); | |
| 126 | ||
| 127 | it("ignores runs that completed before the snooze — the user saw that result", () => { | |
| 128 | expect( | |
| 129 | effectiveSnoozed( | |
| 130 | makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T08:00:00.000Z" }), | |
| 131 | { now: NOW }, | |
| 132 | ), | |
| 133 | ).toBe(true); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | describe("threadRaisedHandWhileSnoozed", () => { | |
| 138 | it("is false for a quiet snoozed thread", () => { | |
| 139 | expect(threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }))).toBe(false); | |
| 140 | }); | |
| 141 | ||
| 142 | it("is true for approvals, input, and failures", () => { | |
| 143 | expect( | |
| 144 | threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "approval" })), | |
| 145 | ).toBe(true); | |
| 146 | expect( | |
| 147 | threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input" })), | |
| 148 | ).toBe(true); | |
| 149 | expect( | |
| 150 | threadRaisedHandWhileSnoozed( | |
| 151 | makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }), | |
| 152 | ), | |
| 153 | ).toBe(true); | |
| 154 | }); | |
| 155 | }); | |
| 156 | ||
| 157 | describe("canSnooze", () => { | |
| 158 | it("allows snoozing quiet and working threads alike", () => { | |
| 159 | expect(canSnooze({ ...makeShell({}), latestUserMessageAt: null }, { now: NOW })).toBe(true); | |
| 160 | expect( | |
| 161 | canSnooze( | |
| 162 | { ...makeShell({ sessionStatus: "running" }), latestUserMessageAt: null }, | |
| 163 | { now: NOW }, | |
| 164 | ), | |
| 165 | ).toBe(true); | |
| 166 | }); | |
| 167 | ||
| 168 | it("refuses blocked-on-you work", () => { | |
| 169 | expect( | |
| 170 | canSnooze({ ...makeShell({ pending: "approval" }), latestUserMessageAt: null }, { now: NOW }), | |
| 171 | ).toBe(false); | |
| 172 | expect( | |
| 173 | canSnooze( | |
| 174 | { ...makeShell({ pending: "user-input" }), latestUserMessageAt: null }, | |
| 175 | { now: NOW }, | |
| 176 | ), | |
| 177 | ).toBe(false); | |
| 178 | }); | |
| 179 | ||
| 180 | it("refuses a queued turn start — same invisible-pending-work rule as settle", () => { | |
| 181 | // Fresh user message, no turn has adopted it, within the grace window. | |
| 182 | expect( | |
| 183 | canSnooze( | |
| 184 | { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:59:30.000Z" }, | |
| 185 | { now: NOW }, | |
| 186 | ), | |
| 187 | ).toBe(false); | |
| 188 | // Outside the grace window the message is stale data, not queued work. | |
| 189 | expect( | |
| 190 | canSnooze( | |
| 191 | { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:00:00.000Z" }, | |
| 192 | { now: NOW }, | |
| 193 | ), | |
| 194 | ).toBe(true); | |
| 195 | }); | |
| 196 | }); | |
| 197 | ||
| 198 | describe("threadWokeAt", () => { | |
| 199 | it("is null for never-snoozed and still-snoozed threads", () => { | |
| 200 | expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null); | |
| 201 | expect(threadWokeAt(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(null); | |
| 202 | }); | |
| 203 | ||
| 204 | it("reports the wake time for a timer wake", () => { | |
| 205 | expect(threadWokeAt(makeShell({ snoozedUntil: PAST_WAKE }), { now: NOW })).toBe(PAST_WAKE); | |
| 206 | }); | |
| 207 | ||
| 208 | it("reports the completion time for an early run-completed wake", () => { | |
| 209 | expect( | |
| 210 | threadWokeAt( | |
| 211 | makeShell({ snoozedUntil: FUTURE_WAKE, turnCompletedAt: "2026-04-10T10:30:00.000Z" }), | |
| 212 | { now: NOW }, | |
| 213 | ), | |
| 214 | ).toBe("2026-04-10T10:30:00.000Z"); | |
| 215 | }); | |
| 216 | ||
| 217 | it("falls back to session activity for blocked/failed early wakes", () => { | |
| 218 | expect( | |
| 219 | threadWokeAt(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }), { | |
| 220 | now: NOW, | |
| 221 | }), | |
| 222 | ).toBe("2026-04-10T11:00:00.000Z"); | |
| 223 | }); | |
| 224 | ||
| 225 | it("keeps the early wake authoritative after the scheduled time passes", () => { | |
| 226 | // Woke early at 10:30 via run-completed; the scheduled wake (PAST_WAKE | |
| 227 | // 10:00 relative to a later now) has ALSO passed. Reporting the | |
| 228 | // scheduled time would resurface a Woke pill the user already cleared | |
| 229 | // by visiting between the early wake and now. | |
| 230 | expect( | |
| 231 | threadWokeAt( | |
| 232 | makeShell({ snoozedUntil: PAST_WAKE, turnCompletedAt: "2026-04-10T09:30:00.000Z" }), | |
| 233 | { now: NOW }, | |
| 234 | ), | |
| 235 | ).toBe("2026-04-10T09:30:00.000Z"); | |
| 236 | }); | |
| 237 | }); |
packages/contracts/src/environment.ts+3| ... | ... | @@ -44,6 +44,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 44 | 44 | pre-settlement servers, so clients treat missing as unsupported and |
| 45 | 45 | never send the commands under version skew. */ |
| 46 | 46 | threadSettlement: Schema.optionalKey(Schema.Boolean), |
| 47 | /** Server understands thread.snooze / thread.unsnooze commands. Same | |
| 48 | version-skew contract as threadSettlement. */ | |
| 49 | threadSnooze: Schema.optionalKey(Schema.Boolean), | |
| 47 | 50 | /** The update path clients should offer for this server. Absent on |
| 48 | 51 | servers that must be relaunched manually (dev checkouts, Windows |
| 49 | 52 | foreground runs, pre-update servers). */ |
packages/contracts/src/orchestration.ts+62| ... | ... | @@ -361,6 +361,12 @@ export const OrchestrationThread = Schema.Struct({ |
| 361 | 361 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 362 | 362 | ), |
| 363 | 363 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 364 | // Snooze is an overlay on the active lifecycle, not a fourth destination: | |
| 365 | // a snoozed thread stays "active" in the model and is only suppressed from | |
| 366 | // the inbox until snoozedUntil passes (or the thread raises its hand). | |
| 367 | // Optional so payloads from pre-snooze servers still decode. | |
| 368 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), | |
| 369 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | |
| 364 | 370 | deletedAt: Schema.NullOr(IsoDateTime), |
| 365 | 371 | messages: Schema.Array(OrchestrationMessage), |
| 366 | 372 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( |
| ... | ... | @@ -411,6 +417,8 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 411 | 417 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 412 | 418 | ), |
| 413 | 419 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 420 | snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), | |
| 421 | snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), | |
| 414 | 422 | session: Schema.NullOr(OrchestrationSession), |
| 415 | 423 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| 416 | 424 | hasPendingApprovals: Schema.Boolean, |
| ... | ... | @@ -583,6 +591,27 @@ const ThreadUnsettleCommand = Schema.Struct({ |
| 583 | 591 | reason: Schema.Literal("user"), |
| 584 | 592 | }); |
| 585 | 593 | |
| 594 | const ThreadSnoozeCommand = Schema.Struct({ | |
| 595 | type: Schema.Literal("thread.snooze"), | |
| 596 | commandId: CommandId, | |
| 597 | threadId: ThreadId, | |
| 598 | // The wake time. Event-based wake conditions (PR merged, review posted) | |
| 599 | // will arrive as an optional condition field alongside this; time-based | |
| 600 | // snooze is just the first kind of condition. | |
| 601 | snoozedUntil: IsoDateTime, | |
| 602 | }); | |
| 603 | ||
| 604 | const ThreadUnsnoozeCommand = Schema.Struct({ | |
| 605 | type: Schema.Literal("thread.unsnooze"), | |
| 606 | commandId: CommandId, | |
| 607 | threadId: ThreadId, | |
| 608 | // Commands only carry "user": activity wakes are decided server-side (the | |
| 609 | // decider emits thread.unsnoozed(reason: "activity") directly), and timer | |
| 610 | // wakes need no event at all — clients derive visibility from snoozedUntil, | |
| 611 | // so a passed wake time simply stops classifying as snoozed. | |
| 612 | reason: Schema.Literal("user"), | |
| 613 | }); | |
| 614 | ||
| 586 | 615 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 587 | 616 | type: Schema.Literal("thread.meta.update"), |
| 588 | 617 | commandId: CommandId, |
| ... | ... | @@ -727,6 +756,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 727 | 756 | ThreadUnarchiveCommand, |
| 728 | 757 | ThreadSettleCommand, |
| 729 | 758 | ThreadUnsettleCommand, |
| 759 | ThreadSnoozeCommand, | |
| 760 | ThreadUnsnoozeCommand, | |
| 730 | 761 | ThreadMetaUpdateCommand, |
| 731 | 762 | ThreadRuntimeModeSetCommand, |
| 732 | 763 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -750,6 +781,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 750 | 781 | ThreadUnarchiveCommand, |
| 751 | 782 | ThreadSettleCommand, |
| 752 | 783 | ThreadUnsettleCommand, |
| 784 | ThreadSnoozeCommand, | |
| 785 | ThreadUnsnoozeCommand, | |
| 753 | 786 | ThreadMetaUpdateCommand, |
| 754 | 787 | ThreadRuntimeModeSetCommand, |
| 755 | 788 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -854,6 +887,8 @@ export const OrchestrationEventType = Schema.Literals([ |
| 854 | 887 | "thread.unarchived", |
| 855 | 888 | "thread.settled", |
| 856 | 889 | "thread.unsettled", |
| 890 | "thread.snoozed", | |
| 891 | "thread.unsnoozed", | |
| 857 | 892 | "thread.meta-updated", |
| 858 | 893 | "thread.runtime-mode-set", |
| 859 | 894 | "thread.interaction-mode-set", |
| ... | ... | @@ -945,6 +980,23 @@ export const ThreadUnsettledPayload = Schema.Struct({ |
| 945 | 980 | updatedAt: IsoDateTime, |
| 946 | 981 | }); |
| 947 | 982 | |
| 983 | export const ThreadSnoozedPayload = Schema.Struct({ | |
| 984 | threadId: ThreadId, | |
| 985 | snoozedUntil: IsoDateTime, | |
| 986 | snoozedAt: IsoDateTime, | |
| 987 | updatedAt: IsoDateTime, | |
| 988 | }); | |
| 989 | ||
| 990 | export const ThreadUnsnoozedPayload = Schema.Struct({ | |
| 991 | threadId: ThreadId, | |
| 992 | // user: explicit "wake now". activity: real work arrived (user message / | |
| 993 | // session coming alive) and the decider cleared the snooze — mirrors | |
| 994 | // thread.unsettled's activity resets. Timer wakes emit no event: clients | |
| 995 | // derive them from snoozedUntil passing. | |
| 996 | reason: Schema.Literals(["user", "activity"]), | |
| 997 | updatedAt: IsoDateTime, | |
| 998 | }); | |
| 999 | ||
| 948 | 1000 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 949 | 1001 | threadId: ThreadId, |
| 950 | 1002 | title: Schema.optional(TrimmedNonEmptyString), |
| ... | ... | @@ -1122,6 +1174,16 @@ export const OrchestrationEvent = Schema.Union([ |
| 1122 | 1174 | type: Schema.Literal("thread.unsettled"), |
| 1123 | 1175 | payload: ThreadUnsettledPayload, |
| 1124 | 1176 | }), |
| 1177 | Schema.Struct({ | |
| 1178 | ...EventBaseFields, | |
| 1179 | type: Schema.Literal("thread.snoozed"), | |
| 1180 | payload: ThreadSnoozedPayload, | |
| 1181 | }), | |
| 1182 | Schema.Struct({ | |
| 1183 | ...EventBaseFields, | |
| 1184 | type: Schema.Literal("thread.unsnoozed"), | |
| 1185 | payload: ThreadUnsnoozedPayload, | |
| 1186 | }), | |
| 1125 | 1187 | Schema.Struct({ |
| 1126 | 1188 | ...EventBaseFields, |
| 1127 | 1189 | type: Schema.Literal("thread.meta-updated"), |