authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 22:09:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 22:09:32-07:00
log202e5609ffb294bc0aa86c08ce1d3751de567226
tree9734b3e37b6a8b99a0843ddeb78ac531ffe7e4ff
parentbb38c3320d0a4eec4e9097fdc400970d881ff559
signature Signed by PGP key B5690EEEBB952194

feat(sidebar-v2): thread snoozing (#4311)

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 old481 // 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 original512 // 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;
522554
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 the845 // Self-contained: v1's listEmpty keys off projectGroups, which ignores the
814 // v2 project scope, so it can be null (results elsewhere) while this list846 // v2 project scope, so it can be null (results elsewhere) while this list
815 // is empty. Search outranks the scope — "No results" names the actionable847 // 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 the848 // 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 <EmptyState874 <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 the399 // 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 old408 // 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 the480 // 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.isLoadingConnections970 {catalogState.isLoadingConnections
936 ? "Loading threads…"971 ? "Loading threads…"
937 : props.searchQuery.trim().length > 0972 : props.searchQuery.trim().length > 0
938 ? "No matching threads"973 ? snoozedCount > 0
939 : selectedProjectScope !== null974 ? // 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 );
944989
apps/mobile/src/features/threads/threadListV2.test.ts+79
...@@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => {...@@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => {
77});77});
7878
79describe("buildThreadListV2Items", () => {79describe("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 @@
1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";1import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
44
...@@ -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}
8895
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 neither113 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.projectRefs134 const projectKeys = input.projectRefs
...@@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: {...@@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: {
121137
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 them143 // 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 }
681683
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
38export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;40export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
39export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;41export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
40export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;42export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;
43export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
44export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
4145
42export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;46export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
43export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;47export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/decider.snoozed.test.ts created+286
...@@ -0,0 +1,286 @@
1import {
2 CommandId,
3 EventId,
4 MessageId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationReadModel,
9 type OrchestrationThread,
10} from "@t3tools/contracts";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import { expect, it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14
15import { decideOrchestrationCommand } from "./decider.ts";
16
17const 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.
20const FUTURE_WAKE = "1970-01-02T09:00:00.000Z";
21const PAST_WAKE = "1969-12-31T09:00:00.000Z";
22const SNOOZED_AT = "1969-12-30T00:00:00.000Z";
23
24function 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
64it.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}
5555
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.)
56function hasOpenBlockingRequest(thread: {63function 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}
8188
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 */
107function 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
82function withEventBase(145function 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 — is477 // Settling inside the adoption window would hide just-requested work.
415 // work in flight even though session is still null (turn.start emits478 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 }
510535
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 settled785 // Real activity resets ANY override: it wakes an explicitly settled
664 // thread, and it clears a keep-active pin back to neutral so the786 // 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 }
685825
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 thread963 // Only a session coming alive is activity worth waking a settled thread
824 // for — status writes like ready/stopped/error arrive after the fact and964 // 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 );
367371
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");
172178
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.
46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
4950
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;
9597
96export const makeMigrationLoader = (throughId?: number) =>98export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts created+23
...@@ -0,0 +1,23 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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";
28import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";28import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
29import {29import {
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"
140import PlanSidebar from "./PlanSidebar";140import PlanSidebar from "./PlanSidebar";
141import ThreadTerminalDrawer from "./ThreadTerminalDrawer";141import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
142import {142import {
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 the3941 // 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, so4046 // 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 <Button4063 <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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze";
4
5// Local-time constructor so preset math is timezone-stable in tests.
6function 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
10describe("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
67describe("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
82describe("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 */
9import { parseTimestampDate } from "../timestampFormat";
10
11type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week";
12
13export 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
23function timeOfDayLabel(date: Date): string {
24 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
25}
26
27const EVENING_HOUR = 18;
28const MORNING_HOUR = 9;
29const HOUR_MS = 60 * 60 * 1_000;
30const DAY_MS = 24 * HOUR_MS;
31
32function 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).
41function 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 */
52export 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 */
100export 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 */
114export 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 @@
1import { autoAnimate } from "@formkit/auto-animate";1import { autoAnimate } from "@formkit/auto-animate";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";3import {
4 canSnooze,
5 effectiveSettled,
6 effectiveSnoozed,
7 threadWokeAt,
8} from "@t3tools/client-runtime/state/thread-settled";
4import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
5import {10import {
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";
10import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";15import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
11import {16import {
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";
40import { useParams, useRouter } from "@tanstack/react-router";50import { useParams, useRouter } from "@tanstack/react-router";
4151
...@@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects";...@@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects";
88import { useEnvironmentQuery } from "../state/query";98import { useEnvironmentQuery } from "../state/query";
89import { useAtomCommand } from "../state/use-atom-command";99import { useAtomCommand } from "../state/use-atom-command";
90import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";100import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
91import { formatRelativeTimeLabel } from "../timestampFormat";101import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
92import type { SidebarThreadSummary } from "../types";102import type { SidebarThreadSummary } from "../types";
93import { cn } from "~/lib/utils";103import { cn } from "~/lib/utils";
94import {104import {
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";
108import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";119import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
109import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";120import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
121import {
122 resolveSnoozePresets,
123 snoozeWakeDescription,
124 snoozeWakeLabel,
125 type SnoozePreset,
126} from "./Sidebar.snooze";
110import { ProjectFavicon } from "./ProjectFavicon";127import { ProjectFavicon } from "./ProjectFavicon";
111import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";128import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
112import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";129import { 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
130import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";147import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
131import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";148import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
132import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";149import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
150import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
133import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";151import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
134import { useComposerDraftStore } from "../composerDraftStore";152import { useComposerDraftStore } from "../composerDraftStore";
135153
...@@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({...@@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({
275 );293 );
276}294}
277295
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 */
301function 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
278const SidebarV2Row = memo(function SidebarV2Row(props: {348const 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 quiet351 // 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 for428 // 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-flight430 // freshly woken. The status label keeps its hue, so waiting rows stay
341 // rows recede the same as read-ready ones (inbox-zero: working threads431 // 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 the437 // 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 : isUnread466 : 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;
382479
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 isUnread685 isUnread || isWoke
559 ? "text-foreground"686 ? "text-foreground"
560 : shouldRecede687 : 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.isActive695 props.isActive || isWoke
569 ? "text-foreground"696 ? "text-foreground"
570 : isUnread697 : 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 <button807 <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 <span883 <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 make897 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 <button911 <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 Settle918 <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't1135 // 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);
9571144
958 // PR states stream in per-row (rows own the VCS subscriptions); a merged or1145 // 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 ]);
12081417
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 the1436 // The settled tail renders in pages: history shouldn't dominate the
1210 // sidebar, and the common lookups are recent. Expansion resets when the1437 // sidebar, and the common lookups are recent. Expansion resets when the
1211 // filter context changes so a scope/search flip never inherits a deep1438 // 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]);
12421499
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;
12901558
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 picks1650 // 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 next1690 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 );
14651810
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 same1879 // Post-settle navigation must skip threads settling in this same
1493 // batch — they are all leaving the card block together. Rows that1880 // 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 );
15621951
...@@ -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 it2028 // 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 <TooltipProvider2355 <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: every2365 section: "active" | "snoozed" | "settled",
1945 // not-settled thread is a full card. Density comes from users2366 ) => {
1946 // (or the auto rules) actually settling work, not from the2367 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 <SidebarV2Row2372 // from users (or the auto rules) actually parking work,
1960 // Keyed per variant on purpose: when a thread settles, the2373 // not from the sidebar second-guessing what still matters.
1961 // card fades out in place and the slim row fades in at its2374 const isCard = section === "active";
1962 // settled position instead of one element FLIP-sliding2375 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 clear2381 // 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.capabilities2385 thread={thread}
1973 .threadSettlement === true2386 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}`) ?? null2393 : section === "settled"
1981 }2394 ? "unsettle"
1982 projectTitle={2395 : "settle"
1983 projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ??2396 }
1984 null2397 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 first2449 // 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 it2451 // 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 <li2455 <li key="snoozed-shelf-header" data-thread-selection-safe className="list-none">
2009 key="settled-divider"2456 <button
2010 aria-hidden2457 type="button"
2011 data-thread-selection-safe2458 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 <button2513 <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";
6import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";6import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7import { canSettle } from "@t3tools/client-runtime/state/thread-settled";7import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled";
8import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";8import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
...@@ -22,6 +22,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat...@@ -22,6 +22,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
22import { readLocalApi } from "../localApi";22import { readLocalApi } from "../localApi";
23import {23import {
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}
7172
73export 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
85export 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
72export function useThreadActions() {97export 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 );
443474
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}
209209
210/** Whether the environment's server understands thread.snooze/unsnooze.
211 Same version-skew contract as settlement. */
212export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean {
213 return (
214 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
215 .threadSnooze === true
216 );
217}
218
210export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {219export 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}
4242
43function parseTimestampDate(isoDate: string): Date | null {43export 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">;
37export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;37export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;
38export type SettleThreadInput = CommandInput<"thread.settle">;38export type SettleThreadInput = CommandInput<"thread.settle">;
39export type UnsettleThreadInput = CommandInput<"thread.unsettle">;39export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
40export type SnoozeThreadInput = CommandInput<"thread.snooze">;
41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
40export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;42export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
41export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;43export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
42export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;44export 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});
177179
180export 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
190export 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
178export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(200export 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";
37import type { EnvironmentRegistry } from "../connection/registry.ts";41import 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";
5662
...@@ -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 };
126128
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}
9292
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 */
99export 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 */
118export 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 */
149export 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 */
168export 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 */
191export 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 has218 * 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 @@
1import { ThreadId } from "@t3tools/contracts";
2import { TurnId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 canSnooze,
7 effectiveSnoozed,
8 threadRaisedHandWhileSnoozed,
9 threadWokeAt,
10 type ThreadSnoozeShell,
11} from "./threadSettled.ts";
12
13const NOW = "2026-04-10T12:00:00.000Z";
14const SNOOZED_AT = "2026-04-10T09:00:00.000Z";
15const FUTURE_WAKE = "2026-04-11T09:00:00.000Z";
16const PAST_WAKE = "2026-04-10T10:00:00.000Z";
17
18function 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
57describe("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
137describe("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
157describe("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
198describe("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 and44 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 on50 /** The update path clients should offer for this server. Absent on
48 servers that must be relaunched manually (dev checkouts, Windows51 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});
585593
594const 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
604const 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
586const ThreadMetaUpdateCommand = Schema.Struct({615const 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});
947982
983export const ThreadSnoozedPayload = Schema.Struct({
984 threadId: ThreadId,
985 snoozedUntil: IsoDateTime,
986 snoozedAt: IsoDateTime,
987 updatedAt: IsoDateTime,
988});
989
990export 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
948export const ThreadMetaUpdatedPayload = Schema.Struct({1000export 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"),