| 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,6 +472,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 472 | // boundary is actually crossed while the app stays open (mirrors web); | 472 | // boundary is actually crossed while the app stays open (mirrors web); |
| 473 | // without a clock dependency the partition memoizes a frozen "now". | 473 | // without a clock dependency the partition memoizes a frozen "now". |
| 474 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); | 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 | useEffect(() => { | 479 | useEffect(() => { |
| 476 | if (!threadListV2Enabled) return; | 480 | if (!threadListV2Enabled) return; |
| 477 | // Refresh immediately on enable: the mount-time value can be hours old | 481 | // Refresh immediately on enable: the mount-time value can be hours old |
| ... | @@ -493,8 +497,18 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -493,8 +497,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 493 | } | 497 | } |
| 494 | return supported; | 498 | return supported; |
| 495 | }, [serverConfigs]); | 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 | const threadListV2Layout = useMemo(() => { | 509 | const threadListV2Layout = useMemo(() => { |
| 497 | if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; | 510 | if (!threadListV2Enabled) |
| 511 | return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; | ||
| 498 | // Settled threads are live shells; archived threads keep their original | 512 | // Settled threads are live shells; archived threads keep their original |
| 499 | // "hidden from lists" meaning. | 513 | // "hidden from lists" meaning. |
| 500 | return buildThreadListV2Items({ | 514 | return buildThreadListV2Items({ |
| ... | @@ -504,20 +518,38 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -504,20 +518,38 @@ export function HomeScreen(props: HomeScreenProps) { |
| 504 | searchQuery: props.searchQuery, | 518 | searchQuery: props.searchQuery, |
| 505 | changeRequestStateByKey, | 519 | changeRequestStateByKey, |
| 506 | settlementEnvironmentIds, | 520 | settlementEnvironmentIds, |
| 521 | snoozeEnvironmentIds, | ||
| 507 | settledLimit: settledVisibleCount, | 522 | settledLimit: settledVisibleCount, |
| 508 | now: `${nowMinute}:00.000Z`, | 523 | now: `${nowMinute}:00.000Z`, |
| 524 | snoozeNow: new Date().toISOString(), | ||
| 509 | }); | 525 | }); |
| 510 | }, [ | 526 | }, [ |
| 511 | changeRequestStateByKey, | 527 | changeRequestStateByKey, |
| 512 | nowMinute, | 528 | nowMinute, |
| 529 | snoozeWakeTick, | ||
| 513 | settledVisibleCount, | 530 | settledVisibleCount, |
| 514 | settlementEnvironmentIds, | 531 | settlementEnvironmentIds, |
| 532 | snoozeEnvironmentIds, | ||
| 515 | props.searchQuery, | 533 | props.searchQuery, |
| 516 | props.selectedEnvironmentId, | 534 | props.selectedEnvironmentId, |
| 517 | props.threads, | 535 | props.threads, |
| 518 | threadListV2Enabled, | 536 | threadListV2Enabled, |
| 519 | v2ScopedProjectGroup, | 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 | const threadListV2Items = threadListV2Layout.items; | 553 | const threadListV2Items = threadListV2Layout.items; |
| 522 | 554 | ||
| 523 | const renderV2Item = useCallback( | 555 | const renderV2Item = useCallback( |
| ... | @@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) { |
| 813 | // Self-contained: v1's listEmpty keys off projectGroups, which ignores the | 845 | // Self-contained: v1's listEmpty keys off projectGroups, which ignores the |
| 814 | // v2 project scope, so it can be null (results elsewhere) while this list | 846 | // v2 project scope, so it can be null (results elsewhere) while this list |
| 815 | // is empty. Search outranks the scope — "No results" names the actionable | 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 | 848 | // fact when a query is active. Snoozed threads outrank the rest: "No |
| 817 | // list showing them isn't empty in the user's eyes. | 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 | const v2ListEmpty = | 853 | const v2ListEmpty = |
| 819 | v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( | 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 | ) : v2ScopedProjectGroup !== null ? ( | 873 | ) : v2ScopedProjectGroup !== null ? ( |
| 822 | <EmptyState | 874 | <EmptyState |
| 823 | title={`No threads in ${v2ScopedProjectGroup.title}`} | 875 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+50-5| ... | @@ -399,6 +399,10 @@ function ThreadNavigationSidebarPane( | ... | @@ -399,6 +399,10 @@ function ThreadNavigationSidebarPane( |
| 399 | // crossed while the pane stays open; without a clock dependency the | 399 | // crossed while the pane stays open; without a clock dependency the |
| 400 | // partition memoizes a frozen "now". | 400 | // partition memoizes a frozen "now". |
| 401 | const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); | 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 | useEffect(() => { | 406 | useEffect(() => { |
| 403 | if (!threadListV2Enabled) return; | 407 | if (!threadListV2Enabled) return; |
| 404 | // Refresh immediately on enable: the mount-time value can be hours old | 408 | // Refresh immediately on enable: the mount-time value can be hours old |
| ... | @@ -420,8 +424,18 @@ function ThreadNavigationSidebarPane( | ... | @@ -420,8 +424,18 @@ function ThreadNavigationSidebarPane( |
| 420 | } | 424 | } |
| 421 | return supported; | 425 | return supported; |
| 422 | }, [serverConfigs]); | 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 | const threadListV2Layout = useMemo(() => { | 436 | const threadListV2Layout = useMemo(() => { |
| 424 | if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; | 437 | if (!threadListV2Enabled) |
| 438 | return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; | ||
| 425 | return buildThreadListV2Items({ | 439 | return buildThreadListV2Items({ |
| 426 | threads: threads.filter((thread) => thread.archivedAt === null), | 440 | threads: threads.filter((thread) => thread.archivedAt === null), |
| 427 | environmentId: options.selectedEnvironmentId, | 441 | environmentId: options.selectedEnvironmentId, |
| ... | @@ -429,20 +443,38 @@ function ThreadNavigationSidebarPane( | ... | @@ -429,20 +443,38 @@ function ThreadNavigationSidebarPane( |
| 429 | searchQuery: props.searchQuery, | 443 | searchQuery: props.searchQuery, |
| 430 | changeRequestStateByKey, | 444 | changeRequestStateByKey, |
| 431 | settlementEnvironmentIds, | 445 | settlementEnvironmentIds, |
| 446 | snoozeEnvironmentIds, | ||
| 432 | settledLimit: settledVisibleCount, | 447 | settledLimit: settledVisibleCount, |
| 433 | now: `${nowMinute}:00.000Z`, | 448 | now: `${nowMinute}:00.000Z`, |
| 449 | snoozeNow: new Date().toISOString(), | ||
| 434 | }); | 450 | }); |
| 435 | }, [ | 451 | }, [ |
| 436 | changeRequestStateByKey, | 452 | changeRequestStateByKey, |
| 437 | nowMinute, | 453 | nowMinute, |
| 454 | snoozeWakeTick, | ||
| 438 | options.selectedEnvironmentId, | 455 | options.selectedEnvironmentId, |
| 439 | props.searchQuery, | 456 | props.searchQuery, |
| 440 | settledVisibleCount, | 457 | settledVisibleCount, |
| 441 | settlementEnvironmentIds, | 458 | settlementEnvironmentIds, |
| 459 | snoozeEnvironmentIds, | ||
| 442 | threadListV2Enabled, | 460 | threadListV2Enabled, |
| 443 | threads, | 461 | threads, |
| 444 | selectedProjectScope, | 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 | const listItems = useMemo<readonly SidebarListItem[]>(() => { | 478 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 447 | if (!threadListV2Enabled) return listLayout.items; | 479 | if (!threadListV2Enabled) return listLayout.items; |
| 448 | // Queued offline tasks render above the thread rows (mirrors the | 480 | // Queued offline tasks render above the thread rows (mirrors the |
| ... | @@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane( | ... | @@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane( |
| 930 | }), | 962 | }), |
| 931 | [filterIcon, filterMenu, props.onOpenSettings], | 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 | const listEmpty = ( | 968 | const listEmpty = ( |
| 934 | <Text className="px-2 py-4 text-sm text-foreground-muted"> | 969 | <Text className="px-2 py-4 text-sm text-foreground-muted"> |
| 935 | {catalogState.isLoadingConnections | 970 | {catalogState.isLoadingConnections |
| 936 | ? "Loading threads…" | 971 | ? "Loading threads…" |
| 937 | : props.searchQuery.trim().length > 0 | 972 | : props.searchQuery.trim().length > 0 |
| 938 | ? "No matching threads" | 973 | ? snoozedCount > 0 |
| 939 | : selectedProjectScope !== null | 974 | ? // Snoozed matches passed this same search filter — "No |
| 940 | ? `No threads in ${selectedProjectScope.title}` | 975 | // matching threads" would misreport them as nonexistent. |
| 941 | : "No threads yet"} | 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 | </Text> | 987 | </Text> |
| 943 | ); | 988 | ); |
| 944 | 989 |
apps/mobile/src/features/threads/threadListV2.test.ts+79| ... | @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => { | ... | @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => { |
| 77 | }); | 77 | }); |
| 78 | 78 | ||
| 79 | describe("buildThreadListV2Items", () => { | 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 | it("partitions settled threads into a slim tail with one divider", () => { | 159 | it("partitions settled threads into a slim tail with one divider", () => { |
| 81 | const { items } = buildThreadListV2Items({ | 160 | const { items } = buildThreadListV2Items({ |
| 82 | threads: [ | 161 | threads: [ |
apps/mobile/src/features/threads/threadListV2.ts+40-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 4 | 4 | ||
| ... | @@ -84,6 +84,13 @@ export interface ThreadListV2Layout { | ... | @@ -84,6 +84,13 @@ export interface ThreadListV2Layout { |
| 84 | readonly items: ThreadListV2Item[]; | 84 | readonly items: ThreadListV2Item[]; |
| 85 | /** Settled threads beyond the render limit (behind "Show more"). */ | 85 | /** Settled threads beyond the render limit (behind "Show more"). */ |
| 86 | readonly hiddenSettledCount: number; | 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,13 +113,22 @@ export function buildThreadListV2Items(input: { |
| 106 | other environments never classify as settled — the user could neither | 113 | other environments never classify as settled — the user could neither |
| 107 | un-settle nor pin them. Absent = no gating (tests). */ | 114 | un-settle nor pin them. Absent = no gating (tests). */ |
| 108 | readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; | 115 | readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 116 | /** Environments whose server supports thread.snooze/unsnooze. Same | ||
| 117 | contract as settlementEnvironmentIds. */ | ||
| 118 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | ||
| 109 | readonly autoSettleAfterDays?: number; | 119 | readonly autoSettleAfterDays?: number; |
| 110 | /** Max settled rows to render; the rest are counted, not built. */ | 120 | /** Max settled rows to render; the rest are counted, not built. */ |
| 111 | readonly settledLimit?: number; | 121 | readonly settledLimit?: number; |
| 112 | /** Injectable for tests; defaults to now. */ | 122 | /** Injectable for tests; defaults to now. */ |
| 113 | readonly now?: string; | 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 | }): ThreadListV2Layout { | 129 | }): ThreadListV2Layout { |
| 115 | const now = input.now ?? new Date().toISOString(); | 130 | const now = input.now ?? new Date().toISOString(); |
| 131 | const snoozeNow = input.snoozeNow ?? now; | ||
| 116 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; | 132 | const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; |
| 117 | const query = input.searchQuery.trim().toLocaleLowerCase(); | 133 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 118 | const projectKeys = input.projectRefs | 134 | const projectKeys = input.projectRefs |
| ... | @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: { | ... | @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: { |
| 121 | 137 | ||
| 122 | const active: EnvironmentThreadShell[] = []; | 138 | const active: EnvironmentThreadShell[] = []; |
| 123 | const settled: EnvironmentThreadShell[] = []; | 139 | const settled: EnvironmentThreadShell[] = []; |
| 140 | let snoozedCount = 0; | ||
| 141 | let nextSnoozeWakeAt: string | null = null; | ||
| 124 | for (const thread of input.threads) { | 142 | for (const thread of input.threads) { |
| 125 | // Callers pass live (unarchived) shells; settled threads are among them | 143 | // Callers pass live (unarchived) shells; settled threads are among them |
| 126 | // and partition into the tail via effectiveSettled. | 144 | // and partition into the tail via effectiveSettled. |
| ... | @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: { | ... | @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: { |
| 130 | } | 148 | } |
| 131 | if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; | 149 | if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; |
| 132 | const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; | 150 | const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; |
| 151 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; | ||
| 133 | const changeRequestState = | 152 | const changeRequestState = |
| 134 | input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; | 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 | if ( | 168 | if ( |
| 136 | supportsSettlement && | 169 | supportsSettlement && |
| 137 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | 170 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) |
| ... | @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: { | ... | @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: { |
| 168 | if (last) { | 201 | if (last) { |
| 169 | items[items.length - 1] = { ...last, isLast: true }; | 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,6 +60,8 @@ function threadDetailToShell( |
| 60 | archivedAt: thread.archivedAt, | 60 | archivedAt: thread.archivedAt, |
| 61 | settledOverride: thread.settledOverride, | 61 | settledOverride: thread.settledOverride, |
| 62 | settledAt: thread.settledAt, | 62 | settledAt: thread.settledAt, |
| 63 | snoozedUntil: thread.snoozedUntil ?? null, | ||
| 64 | snoozedAt: thread.snoozedAt ?? null, | ||
| 63 | session: thread.session, | 65 | session: thread.session, |
| 64 | latestUserMessageAt: latestUserMessageAt(thread), | 66 | latestUserMessageAt: latestUserMessageAt(thread), |
| 65 | hasPendingApprovals: false, | 67 | hasPendingApprovals: false, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { | ... | @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { |
| 141 | repositoryIdentity: true, | 141 | repositoryIdentity: true, |
| 142 | connectionProbe: true, | 142 | connectionProbe: true, |
| 143 | threadSettlement: true, | 143 | threadSettlement: true, |
| 144 | threadSnooze: true, | ||
| 144 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), | 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,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 609 | archivedAt: null, | 609 | archivedAt: null, |
| 610 | settledOverride: null, | 610 | settledOverride: null, |
| 611 | settledAt: null, | 611 | settledAt: null, |
| 612 | snoozedUntil: null, | ||
| 613 | snoozedAt: null, | ||
| 612 | latestUserMessageAt: null, | 614 | latestUserMessageAt: null, |
| 613 | pendingApprovalCount: 0, | 615 | pendingApprovalCount: 0, |
| 614 | pendingUserInputCount: 0, | 616 | pendingUserInputCount: 0, |
| ... | @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 679 | return; | 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 | case "thread.meta-updated": { | 716 | case "thread.meta-updated": { |
| 683 | const existingRow = yield* projectionThreadRepository.getById({ | 717 | const existingRow = yield* projectionThreadRepository.getById({ |
| 684 | threadId: event.payload.threadId, | 718 | threadId: event.payload.threadId, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4| ... | @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 310 | archivedAt: null, | 310 | archivedAt: null, |
| 311 | settledOverride: null, | 311 | settledOverride: null, |
| 312 | settledAt: null, | 312 | settledAt: null, |
| 313 | snoozedUntil: null, | ||
| 314 | snoozedAt: null, | ||
| 313 | deletedAt: null, | 315 | deletedAt: null, |
| 314 | messages: [ | 316 | messages: [ |
| 315 | { | 317 | { |
| ... | @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 422 | archivedAt: null, | 424 | archivedAt: null, |
| 423 | settledOverride: null, | 425 | settledOverride: null, |
| 424 | settledAt: null, | 426 | settledAt: null, |
| 427 | snoozedUntil: null, | ||
| 428 | snoozedAt: null, | ||
| 425 | session: { | 429 | session: { |
| 426 | threadId: ThreadId.make("thread-1"), | 430 | threadId: ThreadId.make("thread-1"), |
| 427 | status: "running", | 431 | status: "running", |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+20| ... | @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 336 | archived_at AS "archivedAt", | 336 | archived_at AS "archivedAt", |
| 337 | settled_override AS "settledOverride", | 337 | settled_override AS "settledOverride", |
| 338 | settled_at AS "settledAt", | 338 | settled_at AS "settledAt", |
| 339 | snoozed_until AS "snoozedUntil", | ||
| 340 | snoozed_at AS "snoozedAt", | ||
| 339 | latest_user_message_at AS "latestUserMessageAt", | 341 | latest_user_message_at AS "latestUserMessageAt", |
| 340 | pending_approval_count AS "pendingApprovalCount", | 342 | pending_approval_count AS "pendingApprovalCount", |
| 341 | pending_user_input_count AS "pendingUserInputCount", | 343 | pending_user_input_count AS "pendingUserInputCount", |
| ... | @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 366 | archived_at AS "archivedAt", | 368 | archived_at AS "archivedAt", |
| 367 | settled_override AS "settledOverride", | 369 | settled_override AS "settledOverride", |
| 368 | settled_at AS "settledAt", | 370 | settled_at AS "settledAt", |
| 371 | snoozed_until AS "snoozedUntil", | ||
| 372 | snoozed_at AS "snoozedAt", | ||
| 369 | latest_user_message_at AS "latestUserMessageAt", | 373 | latest_user_message_at AS "latestUserMessageAt", |
| 370 | pending_approval_count AS "pendingApprovalCount", | 374 | pending_approval_count AS "pendingApprovalCount", |
| 371 | pending_user_input_count AS "pendingUserInputCount", | 375 | pending_user_input_count AS "pendingUserInputCount", |
| ... | @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 398 | archived_at AS "archivedAt", | 402 | archived_at AS "archivedAt", |
| 399 | settled_override AS "settledOverride", | 403 | settled_override AS "settledOverride", |
| 400 | settled_at AS "settledAt", | 404 | settled_at AS "settledAt", |
| 405 | snoozed_until AS "snoozedUntil", | ||
| 406 | snoozed_at AS "snoozedAt", | ||
| 401 | latest_user_message_at AS "latestUserMessageAt", | 407 | latest_user_message_at AS "latestUserMessageAt", |
| 402 | pending_approval_count AS "pendingApprovalCount", | 408 | pending_approval_count AS "pendingApprovalCount", |
| 403 | pending_user_input_count AS "pendingUserInputCount", | 409 | pending_user_input_count AS "pendingUserInputCount", |
| ... | @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 762 | archived_at AS "archivedAt", | 768 | archived_at AS "archivedAt", |
| 763 | settled_override AS "settledOverride", | 769 | settled_override AS "settledOverride", |
| 764 | settled_at AS "settledAt", | 770 | settled_at AS "settledAt", |
| 771 | snoozed_until AS "snoozedUntil", | ||
| 772 | snoozed_at AS "snoozedAt", | ||
| 765 | latest_user_message_at AS "latestUserMessageAt", | 773 | latest_user_message_at AS "latestUserMessageAt", |
| 766 | pending_approval_count AS "pendingApprovalCount", | 774 | pending_approval_count AS "pendingApprovalCount", |
| 767 | pending_user_input_count AS "pendingUserInputCount", | 775 | pending_user_input_count AS "pendingUserInputCount", |
| ... | @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1196 | archivedAt: row.archivedAt, | 1204 | archivedAt: row.archivedAt, |
| 1197 | settledOverride: row.settledOverride, | 1205 | settledOverride: row.settledOverride, |
| 1198 | settledAt: row.settledAt, | 1206 | settledAt: row.settledAt, |
| 1207 | snoozedUntil: row.snoozedUntil, | ||
| 1208 | snoozedAt: row.snoozedAt, | ||
| 1199 | deletedAt: row.deletedAt, | 1209 | deletedAt: row.deletedAt, |
| 1200 | messages: messagesByThread.get(row.threadId) ?? [], | 1210 | messages: messagesByThread.get(row.threadId) ?? [], |
| 1201 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], | 1211 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
| ... | @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1396 | archivedAt: row.archivedAt, | 1406 | archivedAt: row.archivedAt, |
| 1397 | settledOverride: row.settledOverride, | 1407 | settledOverride: row.settledOverride, |
| 1398 | settledAt: row.settledAt, | 1408 | settledAt: row.settledAt, |
| 1409 | snoozedUntil: row.snoozedUntil, | ||
| 1410 | snoozedAt: row.snoozedAt, | ||
| 1399 | deletedAt: row.deletedAt, | 1411 | deletedAt: row.deletedAt, |
| 1400 | messages: [], | 1412 | messages: [], |
| 1401 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], | 1413 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
| ... | @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1527 | archivedAt: row.archivedAt, | 1539 | archivedAt: row.archivedAt, |
| 1528 | settledOverride: row.settledOverride, | 1540 | settledOverride: row.settledOverride, |
| 1529 | settledAt: row.settledAt, | 1541 | settledAt: row.settledAt, |
| 1542 | snoozedUntil: row.snoozedUntil, | ||
| 1543 | snoozedAt: row.snoozedAt, | ||
| 1530 | session: sessionByThread.get(row.threadId) ?? null, | 1544 | session: sessionByThread.get(row.threadId) ?? null, |
| 1531 | latestUserMessageAt: row.latestUserMessageAt, | 1545 | latestUserMessageAt: row.latestUserMessageAt, |
| 1532 | hasPendingApprovals: row.pendingApprovalCount > 0, | 1546 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| ... | @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1663 | archivedAt: row.archivedAt, | 1677 | archivedAt: row.archivedAt, |
| 1664 | settledOverride: row.settledOverride, | 1678 | settledOverride: row.settledOverride, |
| 1665 | settledAt: row.settledAt, | 1679 | settledAt: row.settledAt, |
| 1680 | snoozedUntil: row.snoozedUntil, | ||
| 1681 | snoozedAt: row.snoozedAt, | ||
| 1666 | session: sessionByThread.get(row.threadId) ?? null, | 1682 | session: sessionByThread.get(row.threadId) ?? null, |
| 1667 | latestUserMessageAt: row.latestUserMessageAt, | 1683 | latestUserMessageAt: row.latestUserMessageAt, |
| 1668 | hasPendingApprovals: row.pendingApprovalCount > 0, | 1684 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| ... | @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1905 | archivedAt: threadRow.value.archivedAt, | 1921 | archivedAt: threadRow.value.archivedAt, |
| 1906 | settledOverride: threadRow.value.settledOverride, | 1922 | settledOverride: threadRow.value.settledOverride, |
| 1907 | settledAt: threadRow.value.settledAt, | 1923 | settledAt: threadRow.value.settledAt, |
| 1924 | snoozedUntil: threadRow.value.snoozedUntil, | ||
| 1925 | snoozedAt: threadRow.value.snoozedAt, | ||
| 1908 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, | 1926 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, |
| 1909 | latestUserMessageAt: threadRow.value.latestUserMessageAt, | 1927 | latestUserMessageAt: threadRow.value.latestUserMessageAt, |
| 1910 | hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, | 1928 | hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, |
| ... | @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 2001 | archivedAt: threadRow.value.archivedAt, | 2019 | archivedAt: threadRow.value.archivedAt, |
| 2002 | settledOverride: threadRow.value.settledOverride, | 2020 | settledOverride: threadRow.value.settledOverride, |
| 2003 | settledAt: threadRow.value.settledAt, | 2021 | settledAt: threadRow.value.settledAt, |
| 2022 | snoozedUntil: threadRow.value.snoozedUntil, | ||
| 2023 | snoozedAt: threadRow.value.snoozedAt, | ||
| 2004 | deletedAt: null, | 2024 | deletedAt: null, |
| 2005 | messages: messageRows.map((row) => { | 2025 | messages: messageRows.map((row) => { |
| 2006 | const message = { | 2026 | const message = { |
apps/server/src/orchestration/Schemas.ts+4| ... | @@ -11,6 +11,8 @@ import { | ... | @@ -11,6 +11,8 @@ import { |
| 11 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, | 11 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, |
| 12 | ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, | 12 | ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, |
| 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, | 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, |
| 14 | ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, | ||
| 15 | ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, | ||
| 14 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, | 16 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 15 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, | 17 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 16 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, | 18 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet | ... | @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet |
| 38 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; | 40 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 39 | export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; | 41 | export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; |
| 40 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; | 42 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; |
| 43 | export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; | ||
| 44 | export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; | ||
| 41 | 45 | ||
| 42 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; | 46 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 43 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; | 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,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 | function hasOpenBlockingRequest(thread: { | 63 | function hasOpenBlockingRequest(thread: { |
| 57 | readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; | 64 | readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; |
| 58 | }): boolean { | 65 | }): boolean { |
| ... | @@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: { | ... | @@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: { |
| 79 | return openRequestIds.size > 0; | 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 | function withEventBase( | 145 | function withEventBase( |
| 83 | input: Pick<OrchestrationCommand, "commandId"> & { | 146 | input: Pick<OrchestrationCommand, "commandId"> & { |
| 84 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; | 147 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| ... | @@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 411 | ); | 474 | ); |
| 412 | } | 475 | } |
| 413 | const occurredAt = yield* nowIso; | 476 | const occurredAt = yield* nowIso; |
| 414 | // A queued turn start — a user message no turn has picked up yet — is | 477 | // Settling inside the adoption window would hide just-requested work. |
| 415 | // work in flight even though session is still null (turn.start emits | 478 | if (threadHasQueuedTurnStart(thread, occurredAt)) { |
| 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) { | ||
| 454 | return yield* Effect.fail( | 479 | return yield* Effect.fail( |
| 455 | new OrchestrationCommandInvariantError({ | 480 | new OrchestrationCommandInvariantError({ |
| 456 | commandType: command.type, | 481 | commandType: command.type, |
| ... | @@ -508,6 +533,103 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -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 | case "thread.meta.update": { | 633 | case "thread.meta.update": { |
| 512 | const thread = yield* requireThread({ | 634 | const thread = yield* requireThread({ |
| 513 | readModel, | 635 | readModel, |
| ... | @@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 663 | // Real activity resets ANY override: it wakes an explicitly settled | 785 | // Real activity resets ANY override: it wakes an explicitly settled |
| 664 | // thread, and it clears a keep-active pin back to neutral so the | 786 | // thread, and it clears a keep-active pin back to neutral so the |
| 665 | // thread can auto-settle again after this burst of work goes stale. | 787 | // thread can auto-settle again after this burst of work goes stale. |
| 666 | if (targetThread.settledOverride === null) { | 788 | // A snooze clears the same way — sending a message to a snoozed |
| 667 | return [userMessageEvent, turnStartRequestedEvent]; | 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"> = { | 807 | if (targetThread.snoozedUntil != null) { |
| 670 | ...(yield* withEventBase({ | 808 | lifecycleResetEvents.push({ |
| 671 | aggregateKind: "thread", | 809 | ...(yield* withEventBase({ |
| 672 | aggregateId: command.threadId, | 810 | aggregateKind: "thread", |
| 673 | occurredAt: command.createdAt, | 811 | aggregateId: command.threadId, |
| 674 | commandId: command.commandId, | 812 | occurredAt: command.createdAt, |
| 675 | })), | 813 | commandId: command.commandId, |
| 676 | type: "thread.unsettled", | 814 | })), |
| 677 | payload: { | 815 | type: "thread.unsnoozed", |
| 678 | threadId: command.threadId, | 816 | payload: { |
| 679 | reason: "activity", | 817 | threadId: command.threadId, |
| 680 | updatedAt: command.createdAt, | 818 | reason: "activity", |
| 681 | }, | 819 | updatedAt: command.createdAt, |
| 682 | }; | 820 | }, |
| 683 | return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; | 821 | }); |
| 822 | } | ||
| 823 | return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; | ||
| 684 | } | 824 | } |
| 685 | 825 | ||
| 686 | case "thread.turn.interrupt": { | 826 | case "thread.turn.interrupt": { |
| ... | @@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 822 | }; | 962 | }; |
| 823 | // Only a session coming alive is activity worth waking a settled thread | 963 | // Only a session coming alive is activity worth waking a settled thread |
| 824 | // for — status writes like ready/stopped/error arrive after the fact and | 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 | const isSessionActivity = | 971 | const isSessionActivity = |
| 827 | command.session.status === "starting" || command.session.status === "running"; | 972 | command.session.status === "starting" || command.session.status === "running"; |
| 828 | // Real activity resets ANY override (settled wakes, active unpins). | 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,6 +91,8 @@ describe("orchestration projector", () => { |
| 91 | archivedAt: null, | 91 | archivedAt: null, |
| 92 | settledOverride: null, | 92 | settledOverride: null, |
| 93 | settledAt: null, | 93 | settledAt: null, |
| 94 | snoozedUntil: null, | ||
| 95 | snoozedAt: null, | ||
| 94 | deletedAt: null, | 96 | deletedAt: null, |
| 95 | messages: [], | 97 | messages: [], |
| 96 | proposedPlans: [], | 98 | proposedPlans: [], |
apps/server/src/orchestration/projector.ts+28| ... | @@ -23,8 +23,10 @@ import { | ... | @@ -23,8 +23,10 @@ import { |
| 23 | ThreadProposedPlanUpsertedPayload, | 23 | ThreadProposedPlanUpsertedPayload, |
| 24 | ThreadRuntimeModeSetPayload, | 24 | ThreadRuntimeModeSetPayload, |
| 25 | ThreadSettledPayload, | 25 | ThreadSettledPayload, |
| 26 | ThreadSnoozedPayload, | ||
| 26 | ThreadUnarchivedPayload, | 27 | ThreadUnarchivedPayload, |
| 27 | ThreadUnsettledPayload, | 28 | ThreadUnsettledPayload, |
| 29 | ThreadUnsnoozedPayload, | ||
| 28 | ThreadRevertedPayload, | 30 | ThreadRevertedPayload, |
| 29 | ThreadSessionSetPayload, | 31 | ThreadSessionSetPayload, |
| 30 | ThreadTurnDiffCompletedPayload, | 32 | ThreadTurnDiffCompletedPayload, |
| ... | @@ -290,6 +292,8 @@ export function projectEvent( | ... | @@ -290,6 +292,8 @@ export function projectEvent( |
| 290 | archivedAt: null, | 292 | archivedAt: null, |
| 291 | settledOverride: null, | 293 | settledOverride: null, |
| 292 | settledAt: null, | 294 | settledAt: null, |
| 295 | snoozedUntil: null, | ||
| 296 | snoozedAt: null, | ||
| 293 | deletedAt: null, | 297 | deletedAt: null, |
| 294 | messages: [], | 298 | messages: [], |
| 295 | activities: [], | 299 | activities: [], |
| ... | @@ -365,6 +369,30 @@ export function projectEvent( | ... | @@ -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 | case "thread.meta-updated": | 396 | case "thread.meta-updated": |
| 369 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( | 397 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( |
| 370 | Effect.map((payload) => ({ | 398 | Effect.map((payload) => ({ |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+12-1| ... | @@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 93 | archivedAt: null, | 93 | archivedAt: null, |
| 94 | settledOverride: null, | 94 | settledOverride: null, |
| 95 | settledAt: null, | 95 | settledAt: null, |
| 96 | snoozedUntil: null, | ||
| 97 | snoozedAt: null, | ||
| 96 | latestUserMessageAt: null, | 98 | latestUserMessageAt: null, |
| 97 | pendingApprovalCount: 0, | 99 | pendingApprovalCount: 0, |
| 98 | pendingUserInputCount: 0, | 100 | pendingUserInputCount: 0, |
| ... | @@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 153 | archivedAt: null, | 155 | archivedAt: null, |
| 154 | settledOverride: "settled", | 156 | settledOverride: "settled", |
| 155 | settledAt: "2026-03-25T00:00:00.000Z", | 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 | latestUserMessageAt: null, | 160 | latestUserMessageAt: null, |
| 157 | pendingApprovalCount: 0, | 161 | pendingApprovalCount: 0, |
| 158 | pendingUserInputCount: 0, | 162 | pendingUserInputCount: 0, |
| ... | @@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 169 | } | 173 | } |
| 170 | assert.strictEqual(row.settledOverride, "settled"); | 174 | assert.strictEqual(row.settledOverride, "settled"); |
| 171 | assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); | 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 | yield* threads.upsert({ | 181 | yield* threads.upsert({ |
| 175 | ...row, | 182 | ...row, |
| 176 | settledOverride: "active", | 183 | settledOverride: "active", |
| 177 | settledAt: null, | 184 | settledAt: null, |
| 185 | snoozedUntil: null, | ||
| 186 | snoozedAt: null, | ||
| 178 | }); | 187 | }); |
| 179 | const repersisted = yield* threads.getById({ | 188 | const repersisted = yield* threads.getById({ |
| 180 | threadId: ThreadId.make("thread-settled"), | 189 | threadId: ThreadId.make("thread-settled"), |
| ... | @@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 182 | const updated = Option.getOrNull(repersisted); | 191 | const updated = Option.getOrNull(repersisted); |
| 183 | assert.strictEqual(updated?.settledOverride, "active"); | 192 | assert.strictEqual(updated?.settledOverride, "active"); |
| 184 | assert.strictEqual(updated?.settledAt, null); | 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,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 45 | archived_at, | 45 | archived_at, |
| 46 | settled_override, | 46 | settled_override, |
| 47 | settled_at, | 47 | settled_at, |
| 48 | snoozed_until, | ||
| 49 | snoozed_at, | ||
| 48 | latest_user_message_at, | 50 | latest_user_message_at, |
| 49 | pending_approval_count, | 51 | pending_approval_count, |
| 50 | pending_user_input_count, | 52 | pending_user_input_count, |
| ... | @@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 66 | ${row.archivedAt}, | 68 | ${row.archivedAt}, |
| 67 | ${row.settledOverride}, | 69 | ${row.settledOverride}, |
| 68 | ${row.settledAt}, | 70 | ${row.settledAt}, |
| 71 | ${row.snoozedUntil}, | ||
| 72 | ${row.snoozedAt}, | ||
| 69 | ${row.latestUserMessageAt}, | 73 | ${row.latestUserMessageAt}, |
| 70 | ${row.pendingApprovalCount}, | 74 | ${row.pendingApprovalCount}, |
| 71 | ${row.pendingUserInputCount}, | 75 | ${row.pendingUserInputCount}, |
| ... | @@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 87 | archived_at = excluded.archived_at, | 91 | archived_at = excluded.archived_at, |
| 88 | settled_override = excluded.settled_override, | 92 | settled_override = excluded.settled_override, |
| 89 | settled_at = excluded.settled_at, | 93 | settled_at = excluded.settled_at, |
| 94 | snoozed_until = excluded.snoozed_until, | ||
| 95 | snoozed_at = excluded.snoozed_at, | ||
| 90 | latest_user_message_at = excluded.latest_user_message_at, | 96 | latest_user_message_at = excluded.latest_user_message_at, |
| 91 | pending_approval_count = excluded.pending_approval_count, | 97 | pending_approval_count = excluded.pending_approval_count, |
| 92 | pending_user_input_count = excluded.pending_user_input_count, | 98 | pending_user_input_count = excluded.pending_user_input_count, |
| ... | @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 115 | archived_at AS "archivedAt", | 121 | archived_at AS "archivedAt", |
| 116 | settled_override AS "settledOverride", | 122 | settled_override AS "settledOverride", |
| 117 | settled_at AS "settledAt", | 123 | settled_at AS "settledAt", |
| 124 | snoozed_until AS "snoozedUntil", | ||
| 125 | snoozed_at AS "snoozedAt", | ||
| 118 | latest_user_message_at AS "latestUserMessageAt", | 126 | latest_user_message_at AS "latestUserMessageAt", |
| 119 | pending_approval_count AS "pendingApprovalCount", | 127 | pending_approval_count AS "pendingApprovalCount", |
| 120 | pending_user_input_count AS "pendingUserInputCount", | 128 | pending_user_input_count AS "pendingUserInputCount", |
| ... | @@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 145 | archived_at AS "archivedAt", | 153 | archived_at AS "archivedAt", |
| 146 | settled_override AS "settledOverride", | 154 | settled_override AS "settledOverride", |
| 147 | settled_at AS "settledAt", | 155 | settled_at AS "settledAt", |
| 156 | snoozed_until AS "snoozedUntil", | ||
| 157 | snoozed_at AS "snoozedAt", | ||
| 148 | latest_user_message_at AS "latestUserMessageAt", | 158 | latest_user_message_at AS "latestUserMessageAt", |
| 149 | pending_approval_count AS "pendingApprovalCount", | 159 | pending_approval_count AS "pendingApprovalCount", |
| 150 | pending_user_input_count AS "pendingUserInputCount", | 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,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. |
| 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; | 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; |
| 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; | 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; | ||
| 49 | 50 | ||
| 50 | /** | 51 | /** |
| 51 | * Migration loader with all migrations defined inline. | 52 | * Migration loader with all migrations defined inline. |
| ... | @@ -91,6 +92,7 @@ export const migrationEntries = [ | ... | @@ -91,6 +92,7 @@ export const migrationEntries = [ |
| 91 | [31, "AuthAuthorizationScopes", Migration0031], | 92 | [31, "AuthAuthorizationScopes", Migration0031], |
| 92 | [32, "AuthPairingProofKeyThumbprint", Migration0032], | 93 | [32, "AuthPairingProofKeyThumbprint", Migration0032], |
| 93 | [33, "ProjectionThreadsSettled", Migration0033], | 94 | [33, "ProjectionThreadsSettled", Migration0033], |
| 95 | [34, "ProjectionThreadsSnoozed", Migration0034], | ||
| 94 | ] as const; | 96 | ] as const; |
| 95 | 97 | ||
| 96 | export const makeMigrationLoader = (throughId?: number) => | 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,6 +38,8 @@ export const ProjectionThread = Schema.Struct({ |
| 38 | archivedAt: Schema.NullOr(IsoDateTime), | 38 | archivedAt: Schema.NullOr(IsoDateTime), |
| 39 | settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), | 39 | settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), |
| 40 | settledAt: Schema.NullOr(IsoDateTime), | 40 | settledAt: Schema.NullOr(IsoDateTime), |
| 41 | snoozedUntil: Schema.NullOr(IsoDateTime), | ||
| 42 | snoozedAt: Schema.NullOr(IsoDateTime), | ||
| 41 | latestUserMessageAt: Schema.NullOr(IsoDateTime), | 43 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| 42 | pendingApprovalCount: NonNegativeInt, | 44 | pendingApprovalCount: NonNegativeInt, |
| 43 | pendingUserInputCount: NonNegativeInt, | 45 | pendingUserInputCount: NonNegativeInt, |
apps/web/src/components/ChatView.tsx+81-16| ... | @@ -25,7 +25,7 @@ import { | ... | @@ -25,7 +25,7 @@ import { |
| 25 | connectionStatusTitle, | 25 | connectionStatusTitle, |
| 26 | type EnvironmentConnectionPresentation, | 26 | type EnvironmentConnectionPresentation, |
| 27 | } from "@t3tools/client-runtime/connection"; | 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 | import { | 29 | import { |
| 30 | parseScopedThreadKey, | 30 | parseScopedThreadKey, |
| 31 | scopedThreadKey, | 31 | scopedThreadKey, |
| ... | @@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" | ... | @@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" |
| 140 | import PlanSidebar from "./PlanSidebar"; | 140 | import PlanSidebar from "./PlanSidebar"; |
| 141 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 141 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 142 | import { | 142 | import { |
| 143 | AlarmClockIcon, | ||
| 143 | CheckCircle2Icon, | 144 | CheckCircle2Icon, |
| 144 | ChevronDownIcon, | 145 | ChevronDownIcon, |
| 145 | GitBranchIcon, | 146 | GitBranchIcon, |
| ... | @@ -3844,7 +3845,24 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3844,7 +3845,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 3844 | hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, | 3845 | hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, |
| 3845 | }); | 3846 | }); |
| 3846 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; | 3847 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| 3848 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; | ||
| 3847 | const nowMinute = useNowMinute(); | 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 | const activeThreadSettled = useMemo(() => { | 3866 | const activeThreadSettled = useMemo(() => { |
| 3849 | if (activeThreadShell === null || !supportsSettlement) return false; | 3867 | if (activeThreadShell === null || !supportsSettlement) return false; |
| 3850 | return effectiveSettled(activeThreadShell, { | 3868 | return effectiveSettled(activeThreadShell, { |
| ... | @@ -3890,6 +3908,34 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3890,6 +3908,34 @@ function ChatViewContent(props: ChatViewProps) { |
| 3890 | setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); | 3908 | setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); |
| 3891 | } | 3909 | } |
| 3892 | }, [activeThreadRef, unsettleThreadMutation]); | 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 | const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); | 3939 | const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); |
| 3894 | const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); | 3940 | const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); |
| 3895 | // Once revealed for a given mismatch, the banner stays mounted until the | 3941 | // Once revealed for a given mismatch, the banner stays mounted until the |
| ... | @@ -3999,29 +4045,48 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3999,29 +4045,48 @@ function ChatViewContent(props: ChatViewProps) { |
| 3999 | ]); | 4045 | ]); |
| 4000 | // The stack renders items[0] front-most and tucks the rest behind hover, so | 4046 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| 4001 | // ordering is priority: system banners, then the branch-mismatch notice, | 4047 | // ordering is priority: system banners, then the branch-mismatch notice, |
| 4002 | // and the informational settled banner last — it must never cover another. | 4048 | // and the informational parked-thread banner last — it must never cover another. |
| 4003 | const settledComposerBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | 4049 | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 4004 | if (!activeThreadSettled) { | 4050 | if (!activeThreadSnoozed && !activeThreadSettled) { |
| 4005 | return null; | 4051 | return null; |
| 4006 | } | 4052 | } |
| 4053 | const isSnoozed = activeThreadSnoozed; | ||
| 4007 | return { | 4054 | return { |
| 4008 | id: `thread-settled:${activeThread?.id ?? "unknown"}`, | 4055 | id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, |
| 4009 | variant: "info", | 4056 | variant: "info", |
| 4010 | icon: <CheckCircle2Icon />, | 4057 | icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />, |
| 4011 | title: "This thread is settled", | 4058 | title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, |
| 4012 | description: "Sending a message moves it back to Active in the sidebar.", | 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 | actions: ( | 4062 | actions: ( |
| 4014 | <Button | 4063 | <Button |
| 4015 | size="xs" | 4064 | size="xs" |
| 4016 | variant="outline" | 4065 | variant="outline" |
| 4017 | disabled={isUnsettling} | 4066 | disabled={isSnoozed ? isUnsnoozing : isUnsettling} |
| 4018 | onClick={() => void handleUnsettleActiveThread()} | 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 | </Button> | 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 | const handleRestoreThreadBranch = useCallback(() => { | 4090 | const handleRestoreThreadBranch = useCallback(() => { |
| 4026 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { | 4091 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 4027 | setBranchRestoreConfirmOpen(true); | 4092 | setBranchRestoreConfirmOpen(true); |
| ... | @@ -4030,9 +4095,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4030,9 +4095,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 4030 | void handleSwitchCheckoutToThread(); | 4095 | void handleSwitchCheckoutToThread(); |
| 4031 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); | 4096 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 4032 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 4097 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 4033 | const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; | 4098 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 4034 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { | 4099 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 4035 | return [...systemComposerBannerItems, ...settledItems]; | 4100 | return [...systemComposerBannerItems, ...parkedThreadItems]; |
| 4036 | } | 4101 | } |
| 4037 | return [ | 4102 | return [ |
| 4038 | ...systemComposerBannerItems, | 4103 | ...systemComposerBannerItems, |
| ... | @@ -4075,14 +4140,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4075,14 +4140,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 4075 | setBranchMismatchDismissTick((tick) => tick + 1); | 4140 | setBranchMismatchDismissTick((tick) => tick + 1); |
| 4076 | }, | 4141 | }, |
| 4077 | }, | 4142 | }, |
| 4078 | ...settledItems, | 4143 | ...parkedThreadItems, |
| 4079 | ]; | 4144 | ]; |
| 4080 | }, [ | 4145 | }, [ |
| 4081 | activeBranchMismatchKey, | 4146 | activeBranchMismatchKey, |
| 4082 | handleRestoreThreadBranch, | 4147 | handleRestoreThreadBranch, |
| 4083 | isRestoringThreadBranch, | 4148 | isRestoringThreadBranch, |
| 4084 | localCheckoutBranchMismatch, | 4149 | localCheckoutBranchMismatch, |
| 4085 | settledComposerBannerItem, | 4150 | parkedThreadBannerItem, |
| 4086 | showBranchMismatchBanner, | 4151 | showBranchMismatchBanner, |
| 4087 | systemComposerBannerItems, | 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,6 +1,11 @@ |
| 1 | import { autoAnimate } from "@formkit/auto-animate"; | 1 | import { autoAnimate } from "@formkit/auto-animate"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 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 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 5 | import { | 10 | import { |
| 6 | scopeProjectRef, | 11 | scopeProjectRef, |
| ... | @@ -9,11 +14,15 @@ import { | ... | @@ -9,11 +14,15 @@ import { |
| 9 | } from "@t3tools/client-runtime/environment"; | 14 | } from "@t3tools/client-runtime/environment"; |
| 10 | import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | 15 | import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 11 | import { | 16 | import { |
| 17 | AlarmClockIcon, | ||
| 18 | AlarmClockOffIcon, | ||
| 12 | CheckIcon, | 19 | CheckIcon, |
| 13 | ChevronDownIcon, | 20 | ChevronDownIcon, |
| 21 | ChevronRightIcon, | ||
| 14 | CircleAlertIcon, | 22 | CircleAlertIcon, |
| 15 | CircleCheckIcon, | 23 | CircleCheckIcon, |
| 16 | CircleDashedIcon, | 24 | CircleDashedIcon, |
| 25 | ClockIcon, | ||
| 17 | CopyIcon, | 26 | CopyIcon, |
| 18 | FolderIcon, | 27 | FolderIcon, |
| 19 | FolderPlusIcon, | 28 | FolderPlusIcon, |
| ... | @@ -36,6 +45,7 @@ import { | ... | @@ -36,6 +45,7 @@ import { |
| 36 | useState, | 45 | useState, |
| 37 | type KeyboardEvent as ReactKeyboardEvent, | 46 | type KeyboardEvent as ReactKeyboardEvent, |
| 38 | type MouseEvent as ReactMouseEvent, | 47 | type MouseEvent as ReactMouseEvent, |
| 48 | type ReactNode, | ||
| 39 | } from "react"; | 49 | } from "react"; |
| 40 | import { useParams, useRouter } from "@tanstack/react-router"; | 50 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 41 | 51 | ||
| ... | @@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects"; | ... | @@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects"; |
| 88 | import { useEnvironmentQuery } from "../state/query"; | 98 | import { useEnvironmentQuery } from "../state/query"; |
| 89 | import { useAtomCommand } from "../state/use-atom-command"; | 99 | import { useAtomCommand } from "../state/use-atom-command"; |
| 90 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; | 100 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 91 | import { formatRelativeTimeLabel } from "../timestampFormat"; | 101 | import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; |
| 92 | import type { SidebarThreadSummary } from "../types"; | 102 | import type { SidebarThreadSummary } from "../types"; |
| 93 | import { cn } from "~/lib/utils"; | 103 | import { cn } from "~/lib/utils"; |
| 94 | import { | 104 | import { |
| 95 | formatWorkingDurationLabel, | 105 | formatWorkingDurationLabel, |
| 106 | firstValidTimestampMs, | ||
| 96 | hasUnseenCompletion, | 107 | hasUnseenCompletion, |
| 97 | isTrailingDoubleClick, | 108 | isTrailingDoubleClick, |
| 98 | orderItemsByPreferredIds, | 109 | orderItemsByPreferredIds, |
| ... | @@ -107,6 +118,12 @@ import { | ... | @@ -107,6 +118,12 @@ import { |
| 107 | } from "./Sidebar.logic"; | 118 | } from "./Sidebar.logic"; |
| 108 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; | 119 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 109 | import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; | 120 | import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; |
| 121 | import { | ||
| 122 | resolveSnoozePresets, | ||
| 123 | snoozeWakeDescription, | ||
| 124 | snoozeWakeLabel, | ||
| 125 | type SnoozePreset, | ||
| 126 | } from "./Sidebar.snooze"; | ||
| 110 | import { ProjectFavicon } from "./ProjectFavicon"; | 127 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 111 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | 128 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 112 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; | 129 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| ... | @@ -130,6 +147,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u | ... | @@ -130,6 +147,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u |
| 130 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | 147 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; |
| 131 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; | 148 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; |
| 132 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | 149 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 150 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | ||
| 133 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; | 151 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| 134 | import { useComposerDraftStore } from "../composerDraftStore"; | 152 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 135 | 153 | ||
| ... | @@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({ | ... | @@ -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 | const SidebarV2Row = memo(function SidebarV2Row(props: { | 348 | const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 279 | thread: SidebarThreadSummary; | 349 | thread: SidebarThreadSummary; |
| 280 | variant: "card" | "slim"; | 350 | variant: "card" | "slim"; |
| 281 | // Slim rows are either settled (action: un-settle) or merely quiet | 351 | // Slim rows are either settled (action: un-settle) or merely quiet |
| 282 | // (seen Ready threads — action: settle). | 352 | // (seen Ready threads — action: settle). |
| 283 | variantAction: "settle" | "unsettle"; | 353 | variantAction: "settle" | "unsettle" | "unsnooze"; |
| 284 | // False on environments whose server predates thread.settle/unsettle: | 354 | // False on environments whose server predates thread.settle/unsettle: |
| 285 | // the lifecycle affordances hide entirely rather than fail on click. | 355 | // the lifecycle affordances hide entirely rather than fail on click. |
| 286 | settlementSupported: boolean; | 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 | isActive: boolean; | 364 | isActive: boolean; |
| 288 | jumpLabel: string | null; | 365 | jumpLabel: string | null; |
| 289 | currentEnvironmentId: string | null; | 366 | currentEnvironmentId: string | null; |
| ... | @@ -302,6 +379,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -302,6 +379,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 302 | onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; | 379 | onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; |
| 303 | onSettle: (threadRef: ScopedThreadRef) => void; | 380 | onSettle: (threadRef: ScopedThreadRef) => void; |
| 304 | onUnsettle: (threadRef: ScopedThreadRef) => void; | 381 | onUnsettle: (threadRef: ScopedThreadRef) => void; |
| 382 | onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; | ||
| 383 | onUnsnooze: (threadRef: ScopedThreadRef) => void; | ||
| 305 | onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; | 384 | onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; |
| 306 | }) { | 385 | }) { |
| 307 | const { | 386 | const { |
| ... | @@ -312,10 +391,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -312,10 +391,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 312 | onContextMenu, | 391 | onContextMenu, |
| 313 | onRenameTitleChange, | 392 | onRenameTitleChange, |
| 314 | onSettle, | 393 | onSettle, |
| 394 | onSnooze, | ||
| 315 | onStartRename, | 395 | onStartRename, |
| 316 | onThreadActivate, | 396 | onThreadActivate, |
| 317 | onThreadClick, | 397 | onThreadClick, |
| 318 | onUnsettle, | 398 | onUnsettle, |
| 399 | onUnsnooze, | ||
| 319 | renamingTitle, | 400 | renamingTitle, |
| 320 | thread, | 401 | thread, |
| 321 | variant, | 402 | variant, |
| ... | @@ -334,15 +415,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -334,15 +415,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 334 | // flag must not light up every historical thread as unread. | 415 | // flag must not light up every historical thread as unread. |
| 335 | const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); | 416 | const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); |
| 336 | const status = resolveSidebarV2Status(thread); | 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 | // In-flight rows (working, or waiting on approval/input) fade as a whole: | 427 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 338 | // there is nothing for the user to do yet, so prominence is reserved for | 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. | 429 | // rows that need a human — done (unread), read-but-unsettled, failed, and |
| 340 | // The status label keeps its hue, so waiting rows stay findable. In-flight | 430 | // freshly woken. The status label keeps its hue, so waiting rows stay |
| 341 | // rows recede the same as read-ready ones (inbox-zero: working threads | 431 | // findable. In-flight rows recede the same as read-ready ones (inbox-zero: |
| 342 | // aren't your problem yet) — only the colored status label stands out. | 432 | // working threads aren't your problem yet) — only the colored status label |
| 433 | // stands out. | ||
| 343 | const isInFlight = status === "working" || status === "approval" || status === "input"; | 434 | const isInFlight = status === "working" || status === "approval" || status === "input"; |
| 344 | const shouldRecede = | 435 | const shouldRecede = |
| 345 | (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; | 436 | (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; |
| 346 | // Status hues follow the system-wide convention set by sidebar v1 and the | 437 | // Status hues follow the system-wide convention set by sidebar v1 and the |
| 347 | // mobile Live Activity/widgets (amber approval, indigo input, sky working) | 438 | // mobile Live Activity/widgets (amber approval, indigo input, sky working) |
| 348 | // so a thread reads the same color everywhere it surfaces. | 439 | // so a thread reads the same color everywhere it surfaces. |
| ... | @@ -372,13 +463,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -372,13 +463,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 372 | icon: null, | 463 | icon: null, |
| 373 | className: "text-red-700 dark:text-red-300", | 464 | className: "text-red-700 dark:text-red-300", |
| 374 | } | 465 | } |
| 375 | : isUnread | 466 | : isWoke |
| 376 | ? { | 467 | ? { |
| 377 | label: "Done", | 468 | label: "Woke", |
| 378 | icon: "done" as const, | 469 | icon: "woke" as const, |
| 379 | className: "text-emerald-700 dark:text-emerald-300", | 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 | const gitCwd = thread.worktreePath ?? props.projectCwd; | 480 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 384 | const gitStatus = useEnvironmentQuery( | 481 | const gitStatus = useEnvironmentQuery( |
| ... | @@ -507,6 +604,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -507,6 +604,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 507 | }, | 604 | }, |
| 508 | [onUnsettle, threadRef], | 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 | const handlePrClick = useCallback( | 637 | const handlePrClick = useCallback( |
| 511 | (event: ReactMouseEvent<HTMLElement>) => { | 638 | (event: ReactMouseEvent<HTMLElement>) => { |
| 512 | if (pr?.url) openPrLink(event, pr.url); | 639 | if (pr?.url) openPrLink(event, pr.url); |
| ... | @@ -555,7 +682,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -555,7 +682,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 555 | variant === "card" | 682 | variant === "card" |
| 556 | ? cn( | 683 | ? cn( |
| 557 | "truncate", | 684 | "truncate", |
| 558 | isUnread | 685 | isUnread || isWoke |
| 559 | ? "text-foreground" | 686 | ? "text-foreground" |
| 560 | : shouldRecede | 687 | : shouldRecede |
| 561 | ? "text-muted-foreground/80" | 688 | ? "text-muted-foreground/80" |
| ... | @@ -565,7 +692,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -565,7 +692,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 565 | ) | 692 | ) |
| 566 | : cn( | 693 | : cn( |
| 567 | "truncate group-hover/v2-row:text-foreground", | 694 | "truncate group-hover/v2-row:text-foreground", |
| 568 | props.isActive | 695 | props.isActive || isWoke |
| 569 | ? "text-foreground" | 696 | ? "text-foreground" |
| 570 | : isUnread | 697 | : isUnread |
| 571 | ? "text-muted-foreground" | 698 | ? "text-muted-foreground" |
| ... | @@ -640,13 +767,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -640,13 +767,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 640 | {prBadge} | 767 | {prBadge} |
| 641 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> | 768 | <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end"> |
| 642 | <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0"> | 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"> | 770 | {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( |
| 644 | {variantAction === "unsettle" | 771 | // Snoozed rows show when they come BACK, not when they were |
| 645 | ? settledTimeLabel(thread) | 772 | // last touched — the return ticket is the row's whole story. |
| 646 | : threadTimeLabel(thread)} | 773 | <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400"> |
| 647 | </span> | 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 | </span> | 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 | <button | 807 | <button |
| 651 | type="button" | 808 | type="button" |
| 652 | aria-label="Un-settle thread" | 809 | aria-label="Un-settle thread" |
| ... | @@ -716,7 +873,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -716,7 +873,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 716 | <span className="flex-1" /> | 873 | <span className="flex-1" /> |
| 717 | )} | 874 | )} |
| 718 | <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs"> | 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 | {topStatus ? ( | 882 | {topStatus ? ( |
| 721 | <span | 883 | <span |
| 722 | className={cn( | 884 | className={cn( |
| ... | @@ -728,6 +890,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -728,6 +890,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 728 | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> | 890 | <CircleDashedIcon aria-hidden className="size-4 shrink-0" /> |
| 729 | ) : topStatus.icon === "done" ? ( | 891 | ) : topStatus.icon === "done" ? ( |
| 730 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> | 892 | <CircleCheckIcon aria-hidden className="size-4 shrink-0" /> |
| 893 | ) : topStatus.icon === "woke" ? ( | ||
| 894 | <AlarmClockIcon aria-hidden className="size-4 shrink-0" /> | ||
| 731 | ) : null} | 895 | ) : null} |
| 732 | {/* The label alone is the live region: a role="status" | 896 | {/* The label alone is the live region: a role="status" |
| 733 | wrapper around the ticking duration would make | 897 | wrapper around the ticking duration would make |
| ... | @@ -743,16 +907,32 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -743,16 +907,32 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 743 | threadTimeLabel(thread) | 907 | threadTimeLabel(thread) |
| 744 | )} | 908 | )} |
| 745 | </span> | 909 | </span> |
| 746 | {props.settlementSupported ? ( | 910 | {props.settlementSupported || showSnoozeButton ? ( |
| 747 | <button | 911 | <span |
| 748 | type="button" | 912 | className={cn( |
| 749 | aria-label="Settle thread" | 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", |
| 750 | onClick={handleSettleClick} | 914 | snoozeMenuOpen && "opacity-100", |
| 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" | 915 | )} |
| 752 | > | 916 | > |
| 753 | <CheckIcon className="size-3" /> | 917 | {showSnoozeButton ? ( |
| 754 | Settle | 918 | <SnoozePopoverButton |
| 755 | </button> | 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 | ) : null} | 936 | ) : null} |
| 757 | </span> | 937 | </span> |
| 758 | </div> | 938 | </div> |
| ... | @@ -819,7 +999,8 @@ export default function SidebarV2() { | ... | @@ -819,7 +999,8 @@ export default function SidebarV2() { |
| 819 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 999 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 820 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | 1000 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 821 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 1001 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 822 | const { settleThread, unsettleThread, deleteThread } = useThreadActions(); | 1002 | const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = |
| 1003 | useThreadActions(); | ||
| 823 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | 1004 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 824 | reportFailure: false, | 1005 | reportFailure: false, |
| 825 | }); | 1006 | }); |
| ... | @@ -954,6 +1135,12 @@ export default function SidebarV2() { | ... | @@ -954,6 +1135,12 @@ export default function SidebarV2() { |
| 954 | // now is quantized to the minute so effectiveSettled memoization doesn't | 1135 | // now is quantized to the minute so effectiveSettled memoization doesn't |
| 955 | // churn on every render; auto-settle thresholds are day-granular anyway. | 1136 | // churn on every render; auto-settle thresholds are day-granular anyway. |
| 956 | const nowMinute = useNowMinute(); | 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 | // PR states stream in per-row (rows own the VCS subscriptions); a merged or | 1145 | // PR states stream in per-row (rows own the VCS subscriptions); a merged or |
| 959 | // closed PR auto-settles its thread on the next partition. | 1146 | // closed PR auto-settles its thread on the next partition. |
| ... | @@ -1165,8 +1352,14 @@ export default function SidebarV2() { | ... | @@ -1165,8 +1352,14 @@ export default function SidebarV2() { |
| 1165 | // merging, no optimistic holds. Archived threads remain hidden here — | 1352 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 1166 | // archive keeps its original "remove from sidebar" meaning. | 1353 | // archive keeps its original "remove from sidebar" meaning. |
| 1167 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); | 1354 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| 1168 | const { activeThreads, settledThreads } = useMemo(() => { | 1355 | const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { |
| 1169 | const now = `${nowMinute}:00.000Z`; | 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 | const visible = threads.filter( | 1363 | const visible = threads.filter( |
| 1171 | (thread) => | 1364 | (thread) => |
| 1172 | thread.archivedAt === null && | 1365 | thread.archivedAt === null && |
| ... | @@ -1174,6 +1367,7 @@ export default function SidebarV2() { | ... | @@ -1174,6 +1367,7 @@ export default function SidebarV2() { |
| 1174 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), | 1367 | scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), |
| 1175 | ); | 1368 | ); |
| 1176 | const active: EnvironmentThreadShell[] = []; | 1369 | const active: EnvironmentThreadShell[] = []; |
| 1370 | const snoozed: EnvironmentThreadShell[] = []; | ||
| 1177 | const settled: EnvironmentThreadShell[] = []; | 1371 | const settled: EnvironmentThreadShell[] = []; |
| 1178 | for (const thread of visible) { | 1372 | for (const thread of visible) { |
| 1179 | // Threads on servers without the settlement capability (old server, | 1373 | // Threads on servers without the settlement capability (old server, |
| ... | @@ -1182,9 +1376,16 @@ export default function SidebarV2() { | ... | @@ -1182,9 +1376,16 @@ export default function SidebarV2() { |
| 1182 | // strand rows in a tail with no working affordances. | 1376 | // strand rows in a tail with no working affordances. |
| 1183 | const supportsSettlement = | 1377 | const supportsSettlement = |
| 1184 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; | 1378 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; |
| 1379 | const supportsSnooze = | ||
| 1380 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | ||
| 1185 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | 1381 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 1186 | const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; | 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 | supportsSettlement && | 1389 | supportsSettlement && |
| 1189 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) | 1390 | effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) |
| 1190 | ) { | 1391 | ) { |
| ... | @@ -1195,7 +1396,14 @@ export default function SidebarV2() { | ... | @@ -1195,7 +1396,14 @@ export default function SidebarV2() { |
| 1195 | } | 1396 | } |
| 1196 | return { | 1397 | return { |
| 1197 | activeThreads: sortThreadsForSidebarV2(active), | 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 | settledThreads: sortSettledThreadsForSidebarV2(settled), | 1405 | settledThreads: sortSettledThreadsForSidebarV2(settled), |
| 1406 | snoozeNow: preciseNow, | ||
| 1199 | }; | 1407 | }; |
| 1200 | }, [ | 1408 | }, [ |
| 1201 | autoSettleAfterDays, | 1409 | autoSettleAfterDays, |
| ... | @@ -1203,9 +1411,28 @@ export default function SidebarV2() { | ... | @@ -1203,9 +1411,28 @@ export default function SidebarV2() { |
| 1203 | nowMinute, | 1411 | nowMinute, |
| 1204 | scopedProjectKeys, | 1412 | scopedProjectKeys, |
| 1205 | serverConfigs, | 1413 | serverConfigs, |
| 1414 | snoozeWakeTick, | ||
| 1206 | threads, | 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 | // The settled tail renders in pages: history shouldn't dominate the | 1436 | // The settled tail renders in pages: history shouldn't dominate the |
| 1210 | // sidebar, and the common lookups are recent. Expansion resets when the | 1437 | // sidebar, and the common lookups are recent. Expansion resets when the |
| 1211 | // filter context changes so a scope/search flip never inherits a deep | 1438 | // filter context changes so a scope/search flip never inherits a deep |
| ... | @@ -1239,10 +1466,40 @@ export default function SidebarV2() { | ... | @@ -1239,10 +1466,40 @@ export default function SidebarV2() { |
| 1239 | () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), | 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 | const orderedThreads = useMemo( | 1500 | const orderedThreads = useMemo( |
| 1244 | () => [...activeThreads, ...visibleSettledThreads], | 1501 | () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], |
| 1245 | [activeThreads, visibleSettledThreads], | 1502 | [activeThreads, visibleSnoozedThreads, renderedSettledThreads], |
| 1246 | ); | 1503 | ); |
| 1247 | const orderedThreadKeys = useMemo( | 1504 | const orderedThreadKeys = useMemo( |
| 1248 | () => | 1505 | () => |
| ... | @@ -1287,6 +1544,17 @@ export default function SidebarV2() { | ... | @@ -1287,6 +1544,17 @@ export default function SidebarV2() { |
| 1287 | ); | 1544 | ); |
| 1288 | const settledThreadKeysRef = useRef(settledThreadKeys); | 1545 | const settledThreadKeysRef = useRef(settledThreadKeys); |
| 1289 | settledThreadKeysRef.current = settledThreadKeys; | 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 | const jumpLabelByKey = useMemo(() => { | 1559 | const jumpLabelByKey = useMemo(() => { |
| 1292 | const mapping = new Map<string, string>(); | 1560 | const mapping = new Map<string, string>(); |
| ... | @@ -1382,6 +1650,36 @@ export default function SidebarV2() { | ... | @@ -1382,6 +1650,36 @@ export default function SidebarV2() { |
| 1382 | // A settle per thread at a time: double clicks and repeated menu picks | 1650 | // A settle per thread at a time: double clicks and repeated menu picks |
| 1383 | // must not dispatch a second settle that fails and toasts a false error. | 1651 | // must not dispatch a second settle that fails and toasts a false error. |
| 1384 | const settlingThreadKeysRef = useRef(new Set<string>()); | 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 | const attemptSettle = useCallback( | 1683 | const attemptSettle = useCallback( |
| 1386 | (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => { | 1684 | (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => { |
| 1387 | void (async () => { | 1685 | void (async () => { |
| ... | @@ -1389,34 +1687,7 @@ export default function SidebarV2() { | ... | @@ -1389,34 +1687,7 @@ export default function SidebarV2() { |
| 1389 | if (settlingThreadKeysRef.current.has(threadKey)) return; | 1687 | if (settlingThreadKeysRef.current.has(threadKey)) return; |
| 1390 | settlingThreadKeysRef.current.add(threadKey); | 1688 | settlingThreadKeysRef.current.add(threadKey); |
| 1391 | try { | 1689 | try { |
| 1392 | // Settling the thread you're looking at moves you forward: the next | 1690 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); |
| 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 | } | ||
| 1420 | const result = await settleThread(threadRef); | 1691 | const result = await settleThread(threadRef); |
| 1421 | if (result._tag === "Failure") { | 1692 | if (result._tag === "Failure") { |
| 1422 | // Never navigate away from a thread that did not settle. | 1693 | // Never navigate away from a thread that did not settle. |
| ... | @@ -1442,7 +1713,7 @@ export default function SidebarV2() { | ... | @@ -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 | const attemptUnsettle = useCallback( | 1718 | const attemptUnsettle = useCallback( |
| 1448 | (threadRef: ScopedThreadRef) => { | 1719 | (threadRef: ScopedThreadRef) => { |
| ... | @@ -1462,6 +1733,80 @@ export default function SidebarV2() { | ... | @@ -1462,6 +1733,80 @@ export default function SidebarV2() { |
| 1462 | }, | 1733 | }, |
| 1463 | [unsettleThread], | 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 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | 1811 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
| 1467 | const handleMultiSelectContextMenu = useCallback( | 1812 | const handleMultiSelectContextMenu = useCallback( |
| ... | @@ -1477,10 +1822,35 @@ export default function SidebarV2() { | ... | @@ -1477,10 +1822,35 @@ export default function SidebarV2() { |
| 1477 | ); | 1822 | ); |
| 1478 | if (threadKeys.length === 0) return; | 1823 | if (threadKeys.length === 0) return; |
| 1479 | const count = threadKeys.length; | 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 | const clicked = await settlePromise(() => | 1838 | const clicked = await settlePromise(() => |
| 1481 | api.contextMenu.show( | 1839 | api.contextMenu.show( |
| 1482 | [ | 1840 | [ |
| 1483 | { id: "settle", label: `Settle (${count})` }, | 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 | { id: "mark-unread", label: `Mark unread (${count})` }, | 1854 | { id: "mark-unread", label: `Mark unread (${count})` }, |
| 1485 | { id: "delete", label: `Delete (${count})`, destructive: true }, | 1855 | { id: "delete", label: `Delete (${count})`, destructive: true }, |
| 1486 | ], | 1856 | ], |
| ... | @@ -1488,6 +1858,23 @@ export default function SidebarV2() { | ... | @@ -1488,6 +1858,23 @@ export default function SidebarV2() { |
| 1488 | ), | 1858 | ), |
| 1489 | ); | 1859 | ); |
| 1490 | if (clicked._tag === "Failure") return; | 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 | if (clicked.value === "settle") { | 1878 | if (clicked.value === "settle") { |
| 1492 | // Post-settle navigation must skip threads settling in this same | 1879 | // Post-settle navigation must skip threads settling in this same |
| 1493 | // batch — they are all leaving the card block together. Rows that | 1880 | // batch — they are all leaving the card block together. Rows that |
| ... | @@ -1552,11 +1939,13 @@ export default function SidebarV2() { | ... | @@ -1552,11 +1939,13 @@ export default function SidebarV2() { |
| 1552 | }, | 1939 | }, |
| 1553 | [ | 1940 | [ |
| 1554 | attemptSettle, | 1941 | attemptSettle, |
| 1942 | attemptSnooze, | ||
| 1555 | clearSelection, | 1943 | clearSelection, |
| 1556 | confirmThreadDelete, | 1944 | confirmThreadDelete, |
| 1557 | deleteThread, | 1945 | deleteThread, |
| 1558 | markThreadUnread, | 1946 | markThreadUnread, |
| 1559 | removeFromSelection, | 1947 | removeFromSelection, |
| 1948 | serverConfigs, | ||
| 1560 | ], | 1949 | ], |
| 1561 | ); | 1950 | ); |
| 1562 | 1951 | ||
| ... | @@ -1580,7 +1969,12 @@ export default function SidebarV2() { | ... | @@ -1580,7 +1969,12 @@ export default function SidebarV2() { |
| 1580 | const supportsSettlement = | 1969 | const supportsSettlement = |
| 1581 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === | 1970 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === |
| 1582 | true; | 1971 | true; |
| 1972 | const supportsSnooze = | ||
| 1973 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; | ||
| 1583 | const isSettled = settledThreadKeysRef.current.has(threadKey); | 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 | const clicked = await settlePromise(() => | 1978 | const clicked = await settlePromise(() => |
| 1585 | api.contextMenu.show( | 1979 | api.contextMenu.show( |
| 1586 | [ | 1980 | [ |
| ... | @@ -1599,6 +1993,21 @@ export default function SidebarV2() { | ... | @@ -1599,6 +1993,21 @@ export default function SidebarV2() { |
| 1599 | : { id: "settle", label: "Settle thread" }, | 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 | { id: "rename", label: "Rename thread" }, | 2011 | { id: "rename", label: "Rename thread" }, |
| 1603 | { id: "mark-unread", label: "Mark unread" }, | 2012 | { id: "mark-unread", label: "Mark unread" }, |
| 1604 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, | 2013 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, |
| ... | @@ -1607,6 +2016,13 @@ export default function SidebarV2() { | ... | @@ -1607,6 +2016,13 @@ export default function SidebarV2() { |
| 1607 | ), | 2016 | ), |
| 1608 | ); | 2017 | ); |
| 1609 | if (clicked._tag === "Failure") return; | 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 | switch (clicked.value) { | 2026 | switch (clicked.value) { |
| 1611 | case "new-thread-on-branch": { | 2027 | case "new-thread-on-branch": { |
| 1612 | // Explicit branch carry-over: reuse the thread's worktree when it | 2028 | // Explicit branch carry-over: reuse the thread's worktree when it |
| ... | @@ -1637,6 +2053,9 @@ export default function SidebarV2() { | ... | @@ -1637,6 +2053,9 @@ export default function SidebarV2() { |
| 1637 | case "unsettle": | 2053 | case "unsettle": |
| 1638 | attemptUnsettle(threadRef); | 2054 | attemptUnsettle(threadRef); |
| 1639 | return; | 2055 | return; |
| 2056 | case "unsnooze": | ||
| 2057 | attemptUnsnooze(threadRef); | ||
| 2058 | return; | ||
| 1640 | case "rename": | 2059 | case "rename": |
| 1641 | startThreadRename(threadRef, thread.title); | 2060 | startThreadRename(threadRef, thread.title); |
| 1642 | return; | 2061 | return; |
| ... | @@ -1676,7 +2095,9 @@ export default function SidebarV2() { | ... | @@ -1676,7 +2095,9 @@ export default function SidebarV2() { |
| 1676 | }, | 2095 | }, |
| 1677 | [ | 2096 | [ |
| 1678 | attemptSettle, | 2097 | attemptSettle, |
| 2098 | attemptSnooze, | ||
| 1679 | attemptUnsettle, | 2099 | attemptUnsettle, |
| 2100 | attemptUnsnooze, | ||
| 1680 | confirmThreadDelete, | 2101 | confirmThreadDelete, |
| 1681 | deleteThread, | 2102 | deleteThread, |
| 1682 | handleMultiSelectContextMenu, | 2103 | handleMultiSelectContextMenu, |
| ... | @@ -1930,7 +2351,7 @@ export default function SidebarV2() { | ... | @@ -1930,7 +2351,7 @@ export default function SidebarV2() { |
| 1930 | </div> | 2351 | </div> |
| 1931 | </SidebarGroup> | 2352 | </SidebarGroup> |
| 1932 | ) : null} | 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 | <TooltipProvider | 2355 | <TooltipProvider |
| 1935 | key="sidebar-thread-tooltips-150" | 2356 | key="sidebar-thread-tooltips-150" |
| 1936 | delay={150} | 2357 | delay={150} |
| ... | @@ -1938,88 +2359,156 @@ export default function SidebarV2() { | ... | @@ -1938,88 +2359,156 @@ export default function SidebarV2() { |
| 1938 | timeout={400} | 2359 | timeout={400} |
| 1939 | > | 2360 | > |
| 1940 | <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px"> | 2361 | <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px"> |
| 1941 | {orderedThreads.flatMap((thread, threadIndex) => { | 2362 | {(() => { |
| 1942 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); | 2363 | const renderThreadRow = ( |
| 1943 | const isSettledRow = settledThreadKeys.has(threadKey); | 2364 | thread: EnvironmentThreadShell, |
| 1944 | // Settled is the ONLY thing that collapses a row: every | 2365 | section: "active" | "snoozed" | "settled", |
| 1945 | // not-settled thread is a full card. Density comes from users | 2366 | ) => { |
| 1946 | // (or the auto rules) actually settling work, not from the | 2367 | const threadKey = scopedThreadKey( |
| 1947 | // sidebar second-guessing what still matters. | 2368 | scopeThreadRef(thread.environmentId, thread.id), |
| 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 | ), | ||
| 1956 | ); | 2369 | ); |
| 1957 | const showSettledGap = !isCard && previousWasCard; | 2370 | // Settled and snoozed are the ONLY things that collapse a |
| 1958 | const row = ( | 2371 | // row: every other thread is a full card. Density comes |
| 1959 | <SidebarV2Row | 2372 | // from users (or the auto rules) actually parking work, |
| 1960 | // Keyed per variant on purpose: when a thread settles, the | 2373 | // not from the sidebar second-guessing what still matters. |
| 1961 | // card fades out in place and the slim row fades in at its | 2374 | const isCard = section === "active"; |
| 1962 | // settled position instead of one element FLIP-sliding | 2375 | const rowVariant = isCard ? "card" : "slim"; |
| 1963 | // through every row in between (rows here are translucent, | 2376 | return ( |
| 1964 | // so a crossing row reads as text painted over text). | 2377 | <SidebarV2Row |
| 1965 | key={`${threadKey}:${isCard ? "card" : "slim"}`} | 2378 | // Keyed per variant on purpose: when a thread settles, |
| 1966 | thread={thread} | 2379 | // the card fades out in place and the slim row fades |
| 1967 | variant={isCard ? "card" : "slim"} | 2380 | // in at its settled position instead of one element |
| 1968 | // Every settled row can un-settle: explicit settles clear | 2381 | // FLIP-sliding through every row in between (rows here |
| 1969 | // the override, auto-settled rows get pinned active. | 2382 | // are translucent, so a crossing row reads as text |
| 1970 | variantAction={isSettledRow ? "unsettle" : "settle"} | 2383 | // painted over text). |
| 1971 | settlementSupported={ | 2384 | key={`${threadKey}:${rowVariant}`} |
| 1972 | serverConfigs.get(thread.environmentId)?.environment.capabilities | 2385 | thread={thread} |
| 1973 | .threadSettlement === true | 2386 | variant={rowVariant} |
| 1974 | } | 2387 | // Snoozed rows wake; settled rows un-settle (explicit |
| 1975 | isActive={routeThreadKey === threadKey} | 2388 | // settles clear the override, auto-settled rows get |
| 1976 | jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} | 2389 | // pinned active); cards settle. |
| 1977 | currentEnvironmentId={primaryEnvironmentId} | 2390 | variantAction={ |
| 1978 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} | 2391 | section === "snoozed" |
| 1979 | projectCwd={ | 2392 | ? "unsnooze" |
| 1980 | projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null | 2393 | : section === "settled" |
| 1981 | } | 2394 | ? "unsettle" |
| 1982 | projectTitle={ | 2395 | : "settle" |
| 1983 | projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? | 2396 | } |
| 1984 | null | 2397 | settlementSupported={ |
| 1985 | } | 2398 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 1986 | providerEntryByInstanceId={providerEntryByInstanceId} | 2399 | .threadSettlement === true |
| 1987 | onThreadClick={handleThreadClick} | 2400 | } |
| 1988 | onThreadActivate={navigateToThread} | 2401 | snoozeSupported={ |
| 1989 | onStartRename={startThreadRename} | 2402 | serverConfigs.get(thread.environmentId)?.environment.capabilities |
| 1990 | onRenameTitleChange={setRenamingTitle} | 2403 | .threadSnooze === true |
| 1991 | onCommitRename={commitThreadRename} | 2404 | } |
| 1992 | onCancelRename={cancelThreadRename} | 2405 | snoozeWakeLabelText={ |
| 1993 | isRenaming={renamingThreadKey === threadKey} | 2406 | section === "snoozed" && thread.snoozedUntil != null |
| 1994 | renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} | 2407 | ? snoozeWakeLabel(thread.snoozedUntil, new Date()) |
| 1995 | onContextMenu={handleThreadContextMenu} | 2408 | : null |
| 1996 | onSettle={attemptSettle} | 2409 | } |
| 1997 | onUnsettle={attemptUnsettle} | 2410 | // All sections: a woken thread can classify straight |
| 1998 | onChangeRequestState={handleChangeRequestState} | 2411 | // into the settled tail (PR merged while snoozed), and |
| 1999 | /> | 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]; | 2448 | // Snoozed shelf: between the inbox and Settled — out of the |
| 2002 | // The divider is its own keyed list item (not part of the first | 2449 | // way, never gone. The header always renders while anything |
| 2003 | // settled row): it keeps one stable DOM node at the boundary, | 2450 | // is snoozed (the count is the whole footprint when |
| 2004 | // so settling a thread slides it instead of teleporting it | 2451 | // collapsed); rows only when expanded. Vanishes entirely at |
| 2005 | // along with whichever row happens to be first in the tail — | 2452 | // count 0. |
| 2006 | // and row heights stay independent of neighbor classification. | 2453 | if (snoozedThreads.length > 0) { |
| 2007 | return [ | 2454 | items.push( |
| 2008 | <li | 2455 | <li key="snoozed-shelf-header" data-thread-selection-safe className="list-none"> |
| 2009 | key="settled-divider" | 2456 | <button |
| 2010 | aria-hidden | 2457 | type="button" |
| 2011 | data-thread-selection-safe | 2458 | onClick={toggleSnoozedShelf} |
| 2012 | className="list-none" | 2459 | aria-expanded={snoozedShelfExpanded} |
| 2013 | > | 2460 | data-testid="sidebar-v2-snoozed-shelf-toggle" |
| 2014 | <div className="mb-1 mt-3 flex items-center gap-2 px-2.5"> | 2461 | className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left" |
| 2015 | <span className="text-xs font-medium text-muted-foreground/50">Settled</span> | 2462 | > |
| 2016 | <span className="h-px flex-1 bg-sidebar-border/60" /> | 2463 | <span className="text-xs font-medium text-blue-600 dark:text-blue-400"> |
| 2017 | </div> | 2464 | {snoozedShelfExpanded ? "Snoozed" : `Snoozed (${snoozedThreads.length})`} |
| 2018 | </li>, | 2465 | </span> |
| 2019 | row, | 2466 | <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" /> |
| 2020 | ]; | 2467 | <ChevronDownIcon |
| 2021 | })} | 2468 | aria-hidden |
| 2022 | {hiddenSettledCount > 0 ? ( | 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 | <li className="list-none"> | 2512 | <li className="list-none"> |
| 2024 | <button | 2513 | <button |
| 2025 | type="button" | 2514 | type="button" |
| ... | @@ -2035,7 +2524,7 @@ export default function SidebarV2() { | ... | @@ -2035,7 +2524,7 @@ export default function SidebarV2() { |
| 2035 | ) : null} | 2524 | ) : null} |
| 2036 | </ul> | 2525 | </ul> |
| 2037 | </TooltipProvider> | 2526 | </TooltipProvider> |
| 2038 | {orderedThreads.length === 0 ? ( | 2527 | {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? ( |
| 2039 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> | 2528 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> |
| 2040 | {projects.length === 0 ? ( | 2529 | {projects.length === 0 ? ( |
| 2041 | <> | 2530 | <> |
apps/web/src/hooks/useThreadActions.ts+91-1| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | scopeThreadRef, | 4 | scopeThreadRef, |
| 5 | } from "@t3tools/client-runtime/environment"; | 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 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 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 8 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| ... | @@ -22,6 +22,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat | ... | @@ -22,6 +22,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat |
| 22 | import { readLocalApi } from "../localApi"; | 22 | import { readLocalApi } from "../localApi"; |
| 23 | import { | 23 | import { |
| 24 | readEnvironmentSupportsSettlement, | 24 | readEnvironmentSupportsSettlement, |
| 25 | readEnvironmentSupportsSnooze, | ||
| 25 | readEnvironmentThreadRefs, | 26 | readEnvironmentThreadRefs, |
| 26 | readProject, | 27 | readProject, |
| 27 | readThreadShell, | 28 | readThreadShell, |
| ... | @@ -69,6 +70,30 @@ export class ThreadSettleBlockedError extends Schema.TaggedErrorClass<ThreadSett | ... | @@ -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 | export function useThreadActions() { | 97 | export function useThreadActions() { |
| 73 | const closeTerminal = useAtomCommand(terminalEnvironment.close); | 98 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 74 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { | 99 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | @@ -86,6 +111,12 @@ export function useThreadActions() { | ... | @@ -86,6 +111,12 @@ export function useThreadActions() { |
| 86 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | 111 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 87 | reportFailure: false, | 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 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); | 120 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); |
| 90 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { | 121 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { |
| 91 | reportFailure: false, | 122 | reportFailure: false, |
| ... | @@ -441,6 +472,61 @@ export function useThreadActions() { | ... | @@ -441,6 +472,61 @@ export function useThreadActions() { |
| 441 | [unsettleThreadMutation], | 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 | const confirmAndDeleteThread = useCallback( | 530 | const confirmAndDeleteThread = useCallback( |
| 445 | async (target: ScopedThreadRef) => { | 531 | async (target: ScopedThreadRef) => { |
| 446 | const localApi = readLocalApi(); | 532 | const localApi = readLocalApi(); |
| ... | @@ -477,14 +563,18 @@ export function useThreadActions() { | ... | @@ -477,14 +563,18 @@ export function useThreadActions() { |
| 477 | confirmAndDeleteThread, | 563 | confirmAndDeleteThread, |
| 478 | settleThread, | 564 | settleThread, |
| 479 | unsettleThread, | 565 | unsettleThread, |
| 566 | snoozeThread, | ||
| 567 | unsnoozeThread, | ||
| 480 | }), | 568 | }), |
| 481 | [ | 569 | [ |
| 482 | archiveThread, | 570 | archiveThread, |
| 483 | confirmAndDeleteThread, | 571 | confirmAndDeleteThread, |
| 484 | deleteThread, | 572 | deleteThread, |
| 485 | settleThread, | 573 | settleThread, |
| 574 | snoozeThread, | ||
| 486 | unarchiveThread, | 575 | unarchiveThread, |
| 487 | unsettleThread, | 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,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 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { | 219 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { |
| 211 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); | 220 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); |
| 212 | } | 221 | } |
apps/web/src/timestampFormat.ts+1-1| ... | @@ -40,7 +40,7 @@ function getTimestampFormatter( | ... | @@ -40,7 +40,7 @@ function getTimestampFormatter( |
| 40 | return formatter; | 40 | return formatter; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | function parseTimestampDate(isoDate: string): Date | null { | 43 | export function parseTimestampDate(isoDate: string): Date | null { |
| 44 | const date = new Date(isoDate); | 44 | const date = new Date(isoDate); |
| 45 | return Number.isNaN(date.getTime()) ? null : date; | 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,6 +37,8 @@ export type ArchiveThreadInput = CommandInput<"thread.archive">; |
| 37 | export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; | 37 | export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; |
| 38 | export type SettleThreadInput = CommandInput<"thread.settle">; | 38 | export type SettleThreadInput = CommandInput<"thread.settle">; |
| 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; | 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; |
| 40 | export type SnoozeThreadInput = CommandInput<"thread.snooze">; | ||
| 41 | export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; | ||
| 40 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; | 42 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 41 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; | 43 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 42 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; | 44 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| ... | @@ -175,6 +177,26 @@ export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Eff | ... | @@ -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 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( | 200 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( |
| 179 | "EnvironmentCommands.updateThreadMetadata", | 201 | "EnvironmentCommands.updateThreadMetadata", |
| 180 | )(function* (input) { | 202 | )(function* (input) { |
packages/client-runtime/src/state/threadCommands.ts+18| ... | @@ -13,10 +13,12 @@ import { | ... | @@ -13,10 +13,12 @@ import { |
| 13 | type SetThreadInteractionModeInput, | 13 | type SetThreadInteractionModeInput, |
| 14 | type SetThreadRuntimeModeInput, | 14 | type SetThreadRuntimeModeInput, |
| 15 | type SettleThreadInput, | 15 | type SettleThreadInput, |
| 16 | type SnoozeThreadInput, | ||
| 16 | type StartThreadTurnInput, | 17 | type StartThreadTurnInput, |
| 17 | type StopThreadSessionInput, | 18 | type StopThreadSessionInput, |
| 18 | type UnarchiveThreadInput, | 19 | type UnarchiveThreadInput, |
| 19 | type UnsettleThreadInput, | 20 | type UnsettleThreadInput, |
| 21 | type UnsnoozeThreadInput, | ||
| 20 | type UpdateThreadMetadataInput, | 22 | type UpdateThreadMetadataInput, |
| 21 | archiveThread, | 23 | archiveThread, |
| 22 | createThread, | 24 | createThread, |
| ... | @@ -28,10 +30,12 @@ import { | ... | @@ -28,10 +30,12 @@ import { |
| 28 | setThreadInteractionMode, | 30 | setThreadInteractionMode, |
| 29 | setThreadRuntimeMode, | 31 | setThreadRuntimeMode, |
| 30 | settleThread, | 32 | settleThread, |
| 33 | snoozeThread, | ||
| 31 | startThreadTurn, | 34 | startThreadTurn, |
| 32 | stopThreadSession, | 35 | stopThreadSession, |
| 33 | unarchiveThread, | 36 | unarchiveThread, |
| 34 | unsettleThread, | 37 | unsettleThread, |
| 38 | unsnoozeThread, | ||
| 35 | updateThreadMetadata, | 39 | updateThreadMetadata, |
| 36 | } from "../operations/commands.ts"; | 40 | } from "../operations/commands.ts"; |
| 37 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 41 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| ... | @@ -47,10 +51,12 @@ export type { | ... | @@ -47,10 +51,12 @@ export type { |
| 47 | SetThreadInteractionModeInput, | 51 | SetThreadInteractionModeInput, |
| 48 | SetThreadRuntimeModeInput, | 52 | SetThreadRuntimeModeInput, |
| 49 | SettleThreadInput, | 53 | SettleThreadInput, |
| 54 | SnoozeThreadInput, | ||
| 50 | StartThreadTurnInput, | 55 | StartThreadTurnInput, |
| 51 | StopThreadSessionInput, | 56 | StopThreadSessionInput, |
| 52 | UnarchiveThreadInput, | 57 | UnarchiveThreadInput, |
| 53 | UnsettleThreadInput, | 58 | UnsettleThreadInput, |
| 59 | UnsnoozeThreadInput, | ||
| 54 | UpdateThreadMetadataInput, | 60 | UpdateThreadMetadataInput, |
| 55 | } from "../operations/commands.ts"; | 61 | } from "../operations/commands.ts"; |
| 56 | 62 | ||
| ... | @@ -100,6 +106,18 @@ export function createThreadEnvironmentAtoms<R, E>( | ... | @@ -100,6 +106,18 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 100 | scheduler, | 106 | scheduler, |
| 101 | concurrency, | 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 | updateMetadata: createEnvironmentCommand(runtime, { | 121 | updateMetadata: createEnvironmentCommand(runtime, { |
| 104 | label: "environment-data:commands:thread:update-metadata", | 122 | label: "environment-data:commands:thread:update-metadata", |
| 105 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), | 123 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), |
packages/client-runtime/src/state/threadDetail.ts+2| ... | @@ -59,6 +59,8 @@ export function mergeEnvironmentThread( | ... | @@ -59,6 +59,8 @@ export function mergeEnvironmentThread( |
| 59 | archivedAt: shell.archivedAt, | 59 | archivedAt: shell.archivedAt, |
| 60 | settledOverride: shell.settledOverride, | 60 | settledOverride: shell.settledOverride, |
| 61 | settledAt: shell.settledAt, | 61 | settledAt: shell.settledAt, |
| 62 | snoozedUntil: shell.snoozedUntil, | ||
| 63 | snoozedAt: shell.snoozedAt, | ||
| 62 | session: shell.session, | 64 | session: shell.session, |
| 63 | }; | 65 | }; |
| 64 | } | 66 | } |
packages/client-runtime/src/state/threadReducer.ts+24| ... | @@ -74,6 +74,8 @@ export function applyThreadDetailEvent( | ... | @@ -74,6 +74,8 @@ export function applyThreadDetailEvent( |
| 74 | archivedAt: null, | 74 | archivedAt: null, |
| 75 | settledOverride: null, | 75 | settledOverride: null, |
| 76 | settledAt: null, | 76 | settledAt: null, |
| 77 | snoozedUntil: null, | ||
| 78 | snoozedAt: null, | ||
| 77 | deletedAt: null, | 79 | deletedAt: null, |
| 78 | messages: [], | 80 | messages: [], |
| 79 | proposedPlans: [], | 81 | proposedPlans: [], |
| ... | @@ -124,6 +126,28 @@ export function applyThreadDetailEvent( | ... | @@ -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 | // ── Thread metadata ───────────────────────────────────────────── | 151 | // ── Thread metadata ───────────────────────────────────────────── |
| 128 | case "thread.meta-updated": | 152 | case "thread.meta-updated": |
| 129 | return { | 153 | return { |
packages/client-runtime/src/state/threadSettled.ts+124| ... | @@ -90,6 +90,130 @@ export function canSettle( | ... | @@ -90,6 +90,130 @@ export function canSettle( |
| 90 | return true; | 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 | * A merged/closed change request settles its thread only once the thread has | 218 | * A merged/closed change request settles its thread only once the thread has |
| 95 | * been idle this long. Without the idle guard the merge signal is permanent: | 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,6 +44,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 44 | pre-settlement servers, so clients treat missing as unsupported and | 44 | pre-settlement servers, so clients treat missing as unsupported and |
| 45 | never send the commands under version skew. */ | 45 | never send the commands under version skew. */ |
| 46 | threadSettlement: Schema.optionalKey(Schema.Boolean), | 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 | /** The update path clients should offer for this server. Absent on | 50 | /** The update path clients should offer for this server. Absent on |
| 48 | servers that must be relaunched manually (dev checkouts, Windows | 51 | servers that must be relaunched manually (dev checkouts, Windows |
| 49 | foreground runs, pre-update servers). */ | 52 | foreground runs, pre-update servers). */ |
packages/contracts/src/orchestration.ts+62| ... | @@ -361,6 +361,12 @@ export const OrchestrationThread = Schema.Struct({ | ... | @@ -361,6 +361,12 @@ export const OrchestrationThread = Schema.Struct({ |
| 361 | Schema.withDecodingDefault(Effect.succeed(null)), | 361 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 362 | ), | 362 | ), |
| 363 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | 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 | deletedAt: Schema.NullOr(IsoDateTime), | 370 | deletedAt: Schema.NullOr(IsoDateTime), |
| 365 | messages: Schema.Array(OrchestrationMessage), | 371 | messages: Schema.Array(OrchestrationMessage), |
| 366 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( | 372 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( |
| ... | @@ -411,6 +417,8 @@ export const OrchestrationThreadShell = Schema.Struct({ | ... | @@ -411,6 +417,8 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 411 | Schema.withDecodingDefault(Effect.succeed(null)), | 417 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 412 | ), | 418 | ), |
| 413 | settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | 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 | session: Schema.NullOr(OrchestrationSession), | 422 | session: Schema.NullOr(OrchestrationSession), |
| 415 | latestUserMessageAt: Schema.NullOr(IsoDateTime), | 423 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| 416 | hasPendingApprovals: Schema.Boolean, | 424 | hasPendingApprovals: Schema.Boolean, |
| ... | @@ -583,6 +591,27 @@ const ThreadUnsettleCommand = Schema.Struct({ | ... | @@ -583,6 +591,27 @@ const ThreadUnsettleCommand = Schema.Struct({ |
| 583 | reason: Schema.Literal("user"), | 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 | const ThreadMetaUpdateCommand = Schema.Struct({ | 615 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 587 | type: Schema.Literal("thread.meta.update"), | 616 | type: Schema.Literal("thread.meta.update"), |
| 588 | commandId: CommandId, | 617 | commandId: CommandId, |
| ... | @@ -727,6 +756,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ | ... | @@ -727,6 +756,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 727 | ThreadUnarchiveCommand, | 756 | ThreadUnarchiveCommand, |
| 728 | ThreadSettleCommand, | 757 | ThreadSettleCommand, |
| 729 | ThreadUnsettleCommand, | 758 | ThreadUnsettleCommand, |
| 759 | ThreadSnoozeCommand, | ||
| 760 | ThreadUnsnoozeCommand, | ||
| 730 | ThreadMetaUpdateCommand, | 761 | ThreadMetaUpdateCommand, |
| 731 | ThreadRuntimeModeSetCommand, | 762 | ThreadRuntimeModeSetCommand, |
| 732 | ThreadInteractionModeSetCommand, | 763 | ThreadInteractionModeSetCommand, |
| ... | @@ -750,6 +781,8 @@ export const ClientOrchestrationCommand = Schema.Union([ | ... | @@ -750,6 +781,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 750 | ThreadUnarchiveCommand, | 781 | ThreadUnarchiveCommand, |
| 751 | ThreadSettleCommand, | 782 | ThreadSettleCommand, |
| 752 | ThreadUnsettleCommand, | 783 | ThreadUnsettleCommand, |
| 784 | ThreadSnoozeCommand, | ||
| 785 | ThreadUnsnoozeCommand, | ||
| 753 | ThreadMetaUpdateCommand, | 786 | ThreadMetaUpdateCommand, |
| 754 | ThreadRuntimeModeSetCommand, | 787 | ThreadRuntimeModeSetCommand, |
| 755 | ThreadInteractionModeSetCommand, | 788 | ThreadInteractionModeSetCommand, |
| ... | @@ -854,6 +887,8 @@ export const OrchestrationEventType = Schema.Literals([ | ... | @@ -854,6 +887,8 @@ export const OrchestrationEventType = Schema.Literals([ |
| 854 | "thread.unarchived", | 887 | "thread.unarchived", |
| 855 | "thread.settled", | 888 | "thread.settled", |
| 856 | "thread.unsettled", | 889 | "thread.unsettled", |
| 890 | "thread.snoozed", | ||
| 891 | "thread.unsnoozed", | ||
| 857 | "thread.meta-updated", | 892 | "thread.meta-updated", |
| 858 | "thread.runtime-mode-set", | 893 | "thread.runtime-mode-set", |
| 859 | "thread.interaction-mode-set", | 894 | "thread.interaction-mode-set", |
| ... | @@ -945,6 +980,23 @@ export const ThreadUnsettledPayload = Schema.Struct({ | ... | @@ -945,6 +980,23 @@ export const ThreadUnsettledPayload = Schema.Struct({ |
| 945 | updatedAt: IsoDateTime, | 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 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | 1000 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 949 | threadId: ThreadId, | 1001 | threadId: ThreadId, |
| 950 | title: Schema.optional(TrimmedNonEmptyString), | 1002 | title: Schema.optional(TrimmedNonEmptyString), |
| ... | @@ -1122,6 +1174,16 @@ export const OrchestrationEvent = Schema.Union([ | ... | @@ -1122,6 +1174,16 @@ export const OrchestrationEvent = Schema.Union([ |
| 1122 | type: Schema.Literal("thread.unsettled"), | 1174 | type: Schema.Literal("thread.unsettled"), |
| 1123 | payload: ThreadUnsettledPayload, | 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 | Schema.Struct({ | 1187 | Schema.Struct({ |
| 1126 | ...EventBaseFields, | 1188 | ...EventBaseFields, |
| 1127 | type: Schema.Literal("thread.meta-updated"), | 1189 | type: Schema.Literal("thread.meta-updated"), |