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) {
472472 // boundary is actually crossed while the app stays open (mirrors web);
473473 // without a clock dependency the partition memoizes a frozen "now".
474474 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);
475479 useEffect(() => {
476480 if (!threadListV2Enabled) return;
477481 // Refresh immediately on enable: the mount-time value can be hours old
......@@ -493,8 +497,18 @@ export function HomeScreen(props: HomeScreenProps) {
493497 }
494498 return supported;
495499 }, [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]);
496509 const threadListV2Layout = useMemo(() => {
497 if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
510 if (!threadListV2Enabled)
511 return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null };
498512 // Settled threads are live shells; archived threads keep their original
499513 // "hidden from lists" meaning.
500514 return buildThreadListV2Items({
......@@ -504,20 +518,38 @@ export function HomeScreen(props: HomeScreenProps) {
504518 searchQuery: props.searchQuery,
505519 changeRequestStateByKey,
506520 settlementEnvironmentIds,
521 snoozeEnvironmentIds,
507522 settledLimit: settledVisibleCount,
508523 now: `${nowMinute}:00.000Z`,
524 snoozeNow: new Date().toISOString(),
509525 });
510526 }, [
511527 changeRequestStateByKey,
512528 nowMinute,
529 snoozeWakeTick,
513530 settledVisibleCount,
514531 settlementEnvironmentIds,
532 snoozeEnvironmentIds,
515533 props.searchQuery,
516534 props.selectedEnvironmentId,
517535 props.threads,
518536 threadListV2Enabled,
519537 v2ScopedProjectGroup,
520538 ]);
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]);
521553 const threadListV2Items = threadListV2Layout.items;
522554
523555 const renderV2Item = useCallback(
......@@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) {
813845 // Self-contained: v1's listEmpty keys off projectGroups, which ignores the
814846 // v2 project scope, so it can be null (results elsewhere) while this list
815847 // is empty. Search outranks the scope — "No results" names the actionable
816 // fact when a query is active. Pending tasks render in the header, so the
817 // list showing them isn't empty in the user's eyes.
848 // fact when a query is active. Snoozed threads outrank the rest: "No
849 // threads yet" over an inbox that is merely all-snoozed reads as data
850 // loss. Pending tasks render in the header, so the list showing them
851 // isn't empty in the user's eyes.
852 const v2SnoozedCount = threadListV2Layout.snoozedCount;
818853 const v2ListEmpty =
819854 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 />
821873 ) : v2ScopedProjectGroup !== null ? (
822874 <EmptyState
823875 title={`No threads in ${v2ScopedProjectGroup.title}`}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+50-5
......@@ -399,6 +399,10 @@ function ThreadNavigationSidebarPane(
399399 // crossed while the pane stays open; without a clock dependency the
400400 // partition memoizes a frozen "now".
401401 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);
402406 useEffect(() => {
403407 if (!threadListV2Enabled) return;
404408 // Refresh immediately on enable: the mount-time value can be hours old
......@@ -420,8 +424,18 @@ function ThreadNavigationSidebarPane(
420424 }
421425 return supported;
422426 }, [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]);
423436 const threadListV2Layout = useMemo(() => {
424 if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
437 if (!threadListV2Enabled)
438 return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null };
425439 return buildThreadListV2Items({
426440 threads: threads.filter((thread) => thread.archivedAt === null),
427441 environmentId: options.selectedEnvironmentId,
......@@ -429,20 +443,38 @@ function ThreadNavigationSidebarPane(
429443 searchQuery: props.searchQuery,
430444 changeRequestStateByKey,
431445 settlementEnvironmentIds,
446 snoozeEnvironmentIds,
432447 settledLimit: settledVisibleCount,
433448 now: `${nowMinute}:00.000Z`,
449 snoozeNow: new Date().toISOString(),
434450 });
435451 }, [
436452 changeRequestStateByKey,
437453 nowMinute,
454 snoozeWakeTick,
438455 options.selectedEnvironmentId,
439456 props.searchQuery,
440457 settledVisibleCount,
441458 settlementEnvironmentIds,
459 snoozeEnvironmentIds,
442460 threadListV2Enabled,
443461 threads,
444462 selectedProjectScope,
445463 ]);
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]);
446478 const listItems = useMemo<readonly SidebarListItem[]>(() => {
447479 if (!threadListV2Enabled) return listLayout.items;
448480 // Queued offline tasks render above the thread rows (mirrors the
......@@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane(
930962 }),
931963 [filterIcon, filterMenu, props.onOpenSettings],
932964 );
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;
933968 const listEmpty = (
934969 <Text className="px-2 py-4 text-sm text-foreground-muted">
935970 {catalogState.isLoadingConnections
936971 ? "Loading threads…"
937972 : props.searchQuery.trim().length > 0
938 ? "No matching threads"
939 : selectedProjectScope !== null
940 ? `No threads in ${selectedProjectScope.title}`
941 : "No threads yet"}
973 ? snoozedCount > 0
974 ? // Snoozed matches passed this same search filter — "No
975 // matching threads" would misreport them as nonexistent.
976 snoozedCount === 1
977 ? "1 matching thread snoozed"
978 : "All matching threads snoozed"
979 : "No matching threads"
980 : snoozedCount > 0
981 ? snoozedCount === 1
982 ? "1 thread snoozed"
983 : `${snoozedCount} threads snoozed`
984 : selectedProjectScope !== null
985 ? `No threads in ${selectedProjectScope.title}`
986 : "No threads yet"}
942987 </Text>
943988 );
944989
apps/mobile/src/features/threads/threadListV2.test.ts+79
......@@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => {
7777});
7878
7979describe("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
80159 it("partitions settled threads into a slim tail with one divider", () => {
81160 const { items } = buildThreadListV2Items({
82161 threads: [
apps/mobile/src/features/threads/threadListV2.ts+40-2
......@@ -1,4 +1,4 @@
1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
1import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
22import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
33import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
44
......@@ -84,6 +84,13 @@ export interface ThreadListV2Layout {
8484 readonly items: ThreadListV2Item[];
8585 /** Settled threads beyond the render limit (behind "Show more"). */
8686 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;
8794}
8895
8996/**
......@@ -106,13 +113,22 @@ export function buildThreadListV2Items(input: {
106113 other environments never classify as settled — the user could neither
107114 un-settle nor pin them. Absent = no gating (tests). */
108115 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
116 /** Environments whose server supports thread.snooze/unsnooze. Same
117 contract as settlementEnvironmentIds. */
118 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
109119 readonly autoSettleAfterDays?: number;
110120 /** Max settled rows to render; the rest are counted, not built. */
111121 readonly settledLimit?: number;
112122 /** Injectable for tests; defaults to now. */
113123 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;
114129}): ThreadListV2Layout {
115130 const now = input.now ?? new Date().toISOString();
131 const snoozeNow = input.snoozeNow ?? now;
116132 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
117133 const query = input.searchQuery.trim().toLocaleLowerCase();
118134 const projectKeys = input.projectRefs
......@@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: {
121137
122138 const active: EnvironmentThreadShell[] = [];
123139 const settled: EnvironmentThreadShell[] = [];
140 let snoozedCount = 0;
141 let nextSnoozeWakeAt: string | null = null;
124142 for (const thread of input.threads) {
125143 // Callers pass live (unarchived) shells; settled threads are among them
126144 // and partition into the tail via effectiveSettled.
......@@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: {
130148 }
131149 if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;
132150 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
151 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
133152 const changeRequestState =
134153 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 }
135168 if (
136169 supportsSettlement &&
137170 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
......@@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: {
168201 if (last) {
169202 items[items.length - 1] = { ...last, isLast: true };
170203 }
171 return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length };
204 return {
205 items,
206 hiddenSettledCount: orderedSettled.length - visibleSettled.length,
207 snoozedCount,
208 nextSnoozeWakeAt,
209 };
172210}
apps/mobile/src/state/use-thread-selection.ts+2
......@@ -60,6 +60,8 @@ function threadDetailToShell(
6060 archivedAt: thread.archivedAt,
6161 settledOverride: thread.settledOverride,
6262 settledAt: thread.settledAt,
63 snoozedUntil: thread.snoozedUntil ?? null,
64 snoozedAt: thread.snoozedAt ?? null,
6365 session: thread.session,
6466 latestUserMessageAt: latestUserMessageAt(thread),
6567 hasPendingApprovals: false,
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -141,6 +141,7 @@ export const make = Effect.gen(function* () {
141141 repositoryIdentity: true,
142142 connectionProbe: true,
143143 threadSettlement: true,
144 threadSnooze: true,
144145 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
145146 },
146147 };
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+34
......@@ -609,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
609609 archivedAt: null,
610610 settledOverride: null,
611611 settledAt: null,
612 snoozedUntil: null,
613 snoozedAt: null,
612614 latestUserMessageAt: null,
613615 pendingApprovalCount: 0,
614616 pendingUserInputCount: 0,
......@@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
679681 return;
680682 }
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
682716 case "thread.meta-updated": {
683717 const existingRow = yield* projectionThreadRepository.getById({
684718 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4
......@@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
310310 archivedAt: null,
311311 settledOverride: null,
312312 settledAt: null,
313 snoozedUntil: null,
314 snoozedAt: null,
313315 deletedAt: null,
314316 messages: [
315317 {
......@@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
422424 archivedAt: null,
423425 settledOverride: null,
424426 settledAt: null,
427 snoozedUntil: null,
428 snoozedAt: null,
425429 session: {
426430 threadId: ThreadId.make("thread-1"),
427431 status: "running",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+20
......@@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
336336 archived_at AS "archivedAt",
337337 settled_override AS "settledOverride",
338338 settled_at AS "settledAt",
339 snoozed_until AS "snoozedUntil",
340 snoozed_at AS "snoozedAt",
339341 latest_user_message_at AS "latestUserMessageAt",
340342 pending_approval_count AS "pendingApprovalCount",
341343 pending_user_input_count AS "pendingUserInputCount",
......@@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
366368 archived_at AS "archivedAt",
367369 settled_override AS "settledOverride",
368370 settled_at AS "settledAt",
371 snoozed_until AS "snoozedUntil",
372 snoozed_at AS "snoozedAt",
369373 latest_user_message_at AS "latestUserMessageAt",
370374 pending_approval_count AS "pendingApprovalCount",
371375 pending_user_input_count AS "pendingUserInputCount",
......@@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
398402 archived_at AS "archivedAt",
399403 settled_override AS "settledOverride",
400404 settled_at AS "settledAt",
405 snoozed_until AS "snoozedUntil",
406 snoozed_at AS "snoozedAt",
401407 latest_user_message_at AS "latestUserMessageAt",
402408 pending_approval_count AS "pendingApprovalCount",
403409 pending_user_input_count AS "pendingUserInputCount",
......@@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
762768 archived_at AS "archivedAt",
763769 settled_override AS "settledOverride",
764770 settled_at AS "settledAt",
771 snoozed_until AS "snoozedUntil",
772 snoozed_at AS "snoozedAt",
765773 latest_user_message_at AS "latestUserMessageAt",
766774 pending_approval_count AS "pendingApprovalCount",
767775 pending_user_input_count AS "pendingUserInputCount",
......@@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
11961204 archivedAt: row.archivedAt,
11971205 settledOverride: row.settledOverride,
11981206 settledAt: row.settledAt,
1207 snoozedUntil: row.snoozedUntil,
1208 snoozedAt: row.snoozedAt,
11991209 deletedAt: row.deletedAt,
12001210 messages: messagesByThread.get(row.threadId) ?? [],
12011211 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
......@@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
13961406 archivedAt: row.archivedAt,
13971407 settledOverride: row.settledOverride,
13981408 settledAt: row.settledAt,
1409 snoozedUntil: row.snoozedUntil,
1410 snoozedAt: row.snoozedAt,
13991411 deletedAt: row.deletedAt,
14001412 messages: [],
14011413 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
......@@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15271539 archivedAt: row.archivedAt,
15281540 settledOverride: row.settledOverride,
15291541 settledAt: row.settledAt,
1542 snoozedUntil: row.snoozedUntil,
1543 snoozedAt: row.snoozedAt,
15301544 session: sessionByThread.get(row.threadId) ?? null,
15311545 latestUserMessageAt: row.latestUserMessageAt,
15321546 hasPendingApprovals: row.pendingApprovalCount > 0,
......@@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16631677 archivedAt: row.archivedAt,
16641678 settledOverride: row.settledOverride,
16651679 settledAt: row.settledAt,
1680 snoozedUntil: row.snoozedUntil,
1681 snoozedAt: row.snoozedAt,
16661682 session: sessionByThread.get(row.threadId) ?? null,
16671683 latestUserMessageAt: row.latestUserMessageAt,
16681684 hasPendingApprovals: row.pendingApprovalCount > 0,
......@@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19051921 archivedAt: threadRow.value.archivedAt,
19061922 settledOverride: threadRow.value.settledOverride,
19071923 settledAt: threadRow.value.settledAt,
1924 snoozedUntil: threadRow.value.snoozedUntil,
1925 snoozedAt: threadRow.value.snoozedAt,
19081926 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
19091927 latestUserMessageAt: threadRow.value.latestUserMessageAt,
19101928 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
......@@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20012019 archivedAt: threadRow.value.archivedAt,
20022020 settledOverride: threadRow.value.settledOverride,
20032021 settledAt: threadRow.value.settledAt,
2022 snoozedUntil: threadRow.value.snoozedUntil,
2023 snoozedAt: threadRow.value.snoozedAt,
20042024 deletedAt: null,
20052025 messages: messageRows.map((row) => {
20062026 const message = {
apps/server/src/orchestration/Schemas.ts+4
......@@ -11,6 +11,8 @@ import {
1111 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
1212 ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
1313 ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema,
14 ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema,
15 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
1416 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
1517 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
1618 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet
3840export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
3941export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
4042export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;
43export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
44export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
4145
4246export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
4347export 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
5353 );
5454}
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.)
5663function hasOpenBlockingRequest(thread: {
5764 readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>;
5865}): boolean {
......@@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: {
7986 return openRequestIds.size > 0;
8087}
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
82145function withEventBase(
83146 input: Pick<OrchestrationCommand, "commandId"> & {
84147 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
......@@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
411474 );
412475 }
413476 const occurredAt = yield* nowIso;
414 // A queued turn start — a user message no turn has picked up yet — is
415 // work in flight even though session is still null (turn.start emits
416 // message-sent + turn-start-requested; the session arrives later).
417 // Settling in that window would hide just-requested work. Detection
418 // mirrors the client's hasQueuedTurnStart: the newest user message is
419 // strictly newer than every latestTurn timestamp (adoption stamps the
420 // new turn's requestedAt with the message time, clearing this), and
421 // only within the adoption grace window — historical threads whose
422 // last user message postdates their turn timestamps (older-server
423 // data, mid-turn messages) must stay settleable. A failed session
424 // start (status "error") clears the block immediately.
425 const latestUserMessageAtMs = thread.messages.reduce(
426 (latest, message) =>
427 message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest,
428 Number.NEGATIVE_INFINITY,
429 );
430 const latestTurnAtMs =
431 thread.latestTurn === null
432 ? Number.NEGATIVE_INFINITY
433 : Math.max(
434 ...[
435 thread.latestTurn.requestedAt,
436 thread.latestTurn.startedAt,
437 thread.latestTurn.completedAt,
438 ].map((candidate) =>
439 candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate),
440 ),
441 );
442 // The age check is bounded on BOTH sides: message timestamps are
443 // client-supplied, so a client clock ahead of the server yields a
444 // negative age. Without the lower bound that negative age satisfies
445 // `<= grace` for as long as the skew lasts, extending the settle
446 // block far past the intended two minutes.
447 const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs;
448 const hasQueuedTurnStart =
449 thread.session?.status !== "error" &&
450 Number.isFinite(latestUserMessageAtMs) &&
451 latestUserMessageAtMs > latestTurnAtMs &&
452 Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS;
453 if (hasQueuedTurnStart) {
477 // Settling inside the adoption window would hide just-requested work.
478 if (threadHasQueuedTurnStart(thread, occurredAt)) {
454479 return yield* Effect.fail(
455480 new OrchestrationCommandInvariantError({
456481 commandType: command.type,
......@@ -508,6 +533,103 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
508533 };
509534 }
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
511633 case "thread.meta.update": {
512634 const thread = yield* requireThread({
513635 readModel,
......@@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
663785 // Real activity resets ANY override: it wakes an explicitly settled
664786 // thread, and it clears a keep-active pin back to neutral so the
665787 // thread can auto-settle again after this burst of work goes stale.
666 if (targetThread.settledOverride === null) {
667 return [userMessageEvent, turnStartRequestedEvent];
788 // A snooze clears the same way — sending a message to a snoozed
789 // thread is the user re-engaging, so the return ticket is spent.
790 const lifecycleResetEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
791 if (targetThread.settledOverride !== null) {
792 lifecycleResetEvents.push({
793 ...(yield* withEventBase({
794 aggregateKind: "thread",
795 aggregateId: command.threadId,
796 occurredAt: command.createdAt,
797 commandId: command.commandId,
798 })),
799 type: "thread.unsettled",
800 payload: {
801 threadId: command.threadId,
802 reason: "activity",
803 updatedAt: command.createdAt,
804 },
805 });
668806 }
669 const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = {
670 ...(yield* withEventBase({
671 aggregateKind: "thread",
672 aggregateId: command.threadId,
673 occurredAt: command.createdAt,
674 commandId: command.commandId,
675 })),
676 type: "thread.unsettled",
677 payload: {
678 threadId: command.threadId,
679 reason: "activity",
680 updatedAt: command.createdAt,
681 },
682 };
683 return [unsettledEvent, userMessageEvent, turnStartRequestedEvent];
807 if (targetThread.snoozedUntil != null) {
808 lifecycleResetEvents.push({
809 ...(yield* withEventBase({
810 aggregateKind: "thread",
811 aggregateId: command.threadId,
812 occurredAt: command.createdAt,
813 commandId: command.commandId,
814 })),
815 type: "thread.unsnoozed",
816 payload: {
817 threadId: command.threadId,
818 reason: "activity",
819 updatedAt: command.createdAt,
820 },
821 });
822 }
823 return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent];
684824 }
685825
686826 case "thread.turn.interrupt": {
......@@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
822962 };
823963 // Only a session coming alive is activity worth waking a settled thread
824964 // 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.
826971 const isSessionActivity =
827972 command.session.status === "starting" || command.session.status === "running";
828973 // Real activity resets ANY override (settled wakes, active unpins).
apps/server/src/orchestration/projector.test.ts+2
......@@ -91,6 +91,8 @@ describe("orchestration projector", () => {
9191 archivedAt: null,
9292 settledOverride: null,
9393 settledAt: null,
94 snoozedUntil: null,
95 snoozedAt: null,
9496 deletedAt: null,
9597 messages: [],
9698 proposedPlans: [],
apps/server/src/orchestration/projector.ts+28
......@@ -23,8 +23,10 @@ import {
2323 ThreadProposedPlanUpsertedPayload,
2424 ThreadRuntimeModeSetPayload,
2525 ThreadSettledPayload,
26 ThreadSnoozedPayload,
2627 ThreadUnarchivedPayload,
2728 ThreadUnsettledPayload,
29 ThreadUnsnoozedPayload,
2830 ThreadRevertedPayload,
2931 ThreadSessionSetPayload,
3032 ThreadTurnDiffCompletedPayload,
......@@ -290,6 +292,8 @@ export function projectEvent(
290292 archivedAt: null,
291293 settledOverride: null,
292294 settledAt: null,
295 snoozedUntil: null,
296 snoozedAt: null,
293297 deletedAt: null,
294298 messages: [],
295299 activities: [],
......@@ -365,6 +369,30 @@ export function projectEvent(
365369 })),
366370 );
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
368396 case "thread.meta-updated":
369397 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
370398 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+12-1
......@@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
9393 archivedAt: null,
9494 settledOverride: null,
9595 settledAt: null,
96 snoozedUntil: null,
97 snoozedAt: null,
9698 latestUserMessageAt: null,
9799 pendingApprovalCount: 0,
98100 pendingUserInputCount: 0,
......@@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
153155 archivedAt: null,
154156 settledOverride: "settled",
155157 settledAt: "2026-03-25T00:00:00.000Z",
158 snoozedUntil: "2026-03-26T09:00:00.000Z",
159 snoozedAt: "2026-03-25T00:00:00.000Z",
156160 latestUserMessageAt: null,
157161 pendingApprovalCount: 0,
158162 pendingUserInputCount: 0,
......@@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
169173 }
170174 assert.strictEqual(row.settledOverride, "settled");
171175 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.
174181 yield* threads.upsert({
175182 ...row,
176183 settledOverride: "active",
177184 settledAt: null,
185 snoozedUntil: null,
186 snoozedAt: null,
178187 });
179188 const repersisted = yield* threads.getById({
180189 threadId: ThreadId.make("thread-settled"),
......@@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
182191 const updated = Option.getOrNull(repersisted);
183192 assert.strictEqual(updated?.settledOverride, "active");
184193 assert.strictEqual(updated?.settledAt, null);
194 assert.strictEqual(updated?.snoozedUntil, null);
195 assert.strictEqual(updated?.snoozedAt, null);
185196 }),
186197 );
187198});
apps/server/src/persistence/Layers/ProjectionThreads.ts+10
......@@ -45,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4545 archived_at,
4646 settled_override,
4747 settled_at,
48 snoozed_until,
49 snoozed_at,
4850 latest_user_message_at,
4951 pending_approval_count,
5052 pending_user_input_count,
......@@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6668 ${row.archivedAt},
6769 ${row.settledOverride},
6870 ${row.settledAt},
71 ${row.snoozedUntil},
72 ${row.snoozedAt},
6973 ${row.latestUserMessageAt},
7074 ${row.pendingApprovalCount},
7175 ${row.pendingUserInputCount},
......@@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8791 archived_at = excluded.archived_at,
8892 settled_override = excluded.settled_override,
8993 settled_at = excluded.settled_at,
94 snoozed_until = excluded.snoozed_until,
95 snoozed_at = excluded.snoozed_at,
9096 latest_user_message_at = excluded.latest_user_message_at,
9197 pending_approval_count = excluded.pending_approval_count,
9298 pending_user_input_count = excluded.pending_user_input_count,
......@@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
115121 archived_at AS "archivedAt",
116122 settled_override AS "settledOverride",
117123 settled_at AS "settledAt",
124 snoozed_until AS "snoozedUntil",
125 snoozed_at AS "snoozedAt",
118126 latest_user_message_at AS "latestUserMessageAt",
119127 pending_approval_count AS "pendingApprovalCount",
120128 pending_user_input_count AS "pendingUserInputCount",
......@@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
145153 archived_at AS "archivedAt",
146154 settled_override AS "settledOverride",
147155 settled_at AS "settledAt",
156 snoozed_until AS "snoozedUntil",
157 snoozed_at AS "snoozedAt",
148158 latest_user_message_at AS "latestUserMessageAt",
149159 pending_approval_count AS "pendingApprovalCount",
150160 pending_user_input_count AS "pendingUserInputCount",
apps/server/src/persistence/Migrations.ts+2
......@@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.
4646import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
4747import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4848import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
4950
5051/**
5152 * Migration loader with all migrations defined inline.
......@@ -91,6 +92,7 @@ export const migrationEntries = [
9192 [31, "AuthAuthorizationScopes", Migration0031],
9293 [32, "AuthPairingProofKeyThumbprint", Migration0032],
9394 [33, "ProjectionThreadsSettled", Migration0033],
95 [34, "ProjectionThreadsSnoozed", Migration0034],
9496] as const;
9597
9698export 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({
3838 archivedAt: Schema.NullOr(IsoDateTime),
3939 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])),
4040 settledAt: Schema.NullOr(IsoDateTime),
41 snoozedUntil: Schema.NullOr(IsoDateTime),
42 snoozedAt: Schema.NullOr(IsoDateTime),
4143 latestUserMessageAt: Schema.NullOr(IsoDateTime),
4244 pendingApprovalCount: NonNegativeInt,
4345 pendingUserInputCount: NonNegativeInt,
apps/web/src/components/ChatView.tsx+81-16
......@@ -25,7 +25,7 @@ import {
2525 connectionStatusTitle,
2626 type EnvironmentConnectionPresentation,
2727} from "@t3tools/client-runtime/connection";
28import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
28import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
2929import {
3030 parseScopedThreadKey,
3131 scopedThreadKey,
......@@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"
140140import PlanSidebar from "./PlanSidebar";
141141import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
142142import {
143 AlarmClockIcon,
143144 CheckCircle2Icon,
144145 ChevronDownIcon,
145146 GitBranchIcon,
......@@ -3844,7 +3845,24 @@ function ChatViewContent(props: ChatViewProps) {
38443845 hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null,
38453846 });
38463847 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
3848 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
38473849 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]);
38483866 const activeThreadSettled = useMemo(() => {
38493867 if (activeThreadShell === null || !supportsSettlement) return false;
38503868 return effectiveSettled(activeThreadShell, {
......@@ -3890,6 +3908,34 @@ function ChatViewContent(props: ChatViewProps) {
38903908 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
38913909 }
38923910 }, [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]);
38933939 const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
38943940 const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
38953941 // Once revealed for a given mismatch, the banner stays mounted until the
......@@ -3999,29 +4045,48 @@ function ChatViewContent(props: ChatViewProps) {
39994045 ]);
40004046 // The stack renders items[0] front-most and tucks the rest behind hover, so
40014047 // ordering is priority: system banners, then the branch-mismatch notice,
4002 // and the informational settled banner last — it must never cover another.
4003 const settledComposerBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4004 if (!activeThreadSettled) {
4048 // and the informational parked-thread banner last — it must never cover another.
4049 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4050 if (!activeThreadSnoozed && !activeThreadSettled) {
40054051 return null;
40064052 }
4053 const isSnoozed = activeThreadSnoozed;
40074054 return {
4008 id: `thread-settled:${activeThread?.id ?? "unknown"}`,
4055 id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`,
40094056 variant: "info",
4010 icon: <CheckCircle2Icon />,
4011 title: "This thread is settled",
4012 description: "Sending a message moves it back to Active in the sidebar.",
4057 icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />,
4058 title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`,
4059 description: isSnoozed
4060 ? "Sending a message wakes it and moves it back to Active in the sidebar."
4061 : "Sending a message moves it back to Active in the sidebar.",
40134062 actions: (
40144063 <Button
40154064 size="xs"
40164065 variant="outline"
4017 disabled={isUnsettling}
4018 onClick={() => void handleUnsettleActiveThread()}
4066 disabled={isSnoozed ? isUnsnoozing : isUnsettling}
4067 onClick={() =>
4068 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())
4069 }
40194070 >
4020 {isUnsettling ? "Un-settling..." : "Un-settle"}
4071 {isSnoozed
4072 ? isUnsnoozing
4073 ? "Waking..."
4074 : "Wake now"
4075 : isUnsettling
4076 ? "Un-settling..."
4077 : "Un-settle"}
40214078 </Button>
40224079 ),
40234080 };
4024 }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]);
4081 }, [
4082 activeThread?.id,
4083 activeThreadSettled,
4084 activeThreadSnoozed,
4085 handleUnsnoozeActiveThread,
4086 handleUnsettleActiveThread,
4087 isUnsnoozing,
4088 isUnsettling,
4089 ]);
40254090 const handleRestoreThreadBranch = useCallback(() => {
40264091 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
40274092 setBranchRestoreConfirmOpen(true);
......@@ -4030,9 +4095,9 @@ function ChatViewContent(props: ChatViewProps) {
40304095 void handleSwitchCheckoutToThread();
40314096 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
40324097 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
4033 const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem];
4098 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
40344099 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
4035 return [...systemComposerBannerItems, ...settledItems];
4100 return [...systemComposerBannerItems, ...parkedThreadItems];
40364101 }
40374102 return [
40384103 ...systemComposerBannerItems,
......@@ -4075,14 +4140,14 @@ function ChatViewContent(props: ChatViewProps) {
40754140 setBranchMismatchDismissTick((tick) => tick + 1);
40764141 },
40774142 },
4078 ...settledItems,
4143 ...parkedThreadItems,
40794144 ];
40804145 }, [
40814146 activeBranchMismatchKey,
40824147 handleRestoreThreadBranch,
40834148 isRestoringThreadBranch,
40844149 localCheckoutBranchMismatch,
4085 settledComposerBannerItem,
4150 parkedThreadBannerItem,
40864151 showBranchMismatchBanner,
40874152 systemComposerBannerItems,
40884153 ]);
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 @@
11import { autoAnimate } from "@formkit/auto-animate";
22import { 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";
49import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
510import {
611 scopeProjectRef,
......@@ -9,11 +14,15 @@ import {
914} from "@t3tools/client-runtime/environment";
1015import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
1116import {
17 AlarmClockIcon,
18 AlarmClockOffIcon,
1219 CheckIcon,
1320 ChevronDownIcon,
21 ChevronRightIcon,
1422 CircleAlertIcon,
1523 CircleCheckIcon,
1624 CircleDashedIcon,
25 ClockIcon,
1726 CopyIcon,
1827 FolderIcon,
1928 FolderPlusIcon,
......@@ -36,6 +45,7 @@ import {
3645 useState,
3746 type KeyboardEvent as ReactKeyboardEvent,
3847 type MouseEvent as ReactMouseEvent,
48 type ReactNode,
3949} from "react";
4050import { useParams, useRouter } from "@tanstack/react-router";
4151
......@@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects";
8898import { useEnvironmentQuery } from "../state/query";
8999import { useAtomCommand } from "../state/use-atom-command";
90100import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
91import { formatRelativeTimeLabel } from "../timestampFormat";
101import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
92102import type { SidebarThreadSummary } from "../types";
93103import { cn } from "~/lib/utils";
94104import {
95105 formatWorkingDurationLabel,
106 firstValidTimestampMs,
96107 hasUnseenCompletion,
97108 isTrailingDoubleClick,
98109 orderItemsByPreferredIds,
......@@ -107,6 +118,12 @@ import {
107118} from "./Sidebar.logic";
108119import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
109120import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
121import {
122 resolveSnoozePresets,
123 snoozeWakeDescription,
124 snoozeWakeLabel,
125 type SnoozePreset,
126} from "./Sidebar.snooze";
110127import { ProjectFavicon } from "./ProjectFavicon";
111128import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
112129import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
......@@ -130,6 +147,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u
130147import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
131148import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
132149import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
150import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
133151import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
134152import { useComposerDraftStore } from "../composerDraftStore";
135153
......@@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({
275293 );
276294}
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
278348const SidebarV2Row = memo(function SidebarV2Row(props: {
279349 thread: SidebarThreadSummary;
280350 variant: "card" | "slim";
281351 // Slim rows are either settled (action: un-settle) or merely quiet
282352 // (seen Ready threads — action: settle).
283 variantAction: "settle" | "unsettle";
353 variantAction: "settle" | "unsettle" | "unsnooze";
284354 // False on environments whose server predates thread.settle/unsettle:
285355 // the lifecycle affordances hide entirely rather than fail on click.
286356 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;
287364 isActive: boolean;
288365 jumpLabel: string | null;
289366 currentEnvironmentId: string | null;
......@@ -302,6 +379,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
302379 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
303380 onSettle: (threadRef: ScopedThreadRef) => void;
304381 onUnsettle: (threadRef: ScopedThreadRef) => void;
382 onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
383 onUnsnooze: (threadRef: ScopedThreadRef) => void;
305384 onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
306385}) {
307386 const {
......@@ -312,10 +391,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
312391 onContextMenu,
313392 onRenameTitleChange,
314393 onSettle,
394 onSnooze,
315395 onStartRename,
316396 onThreadActivate,
317397 onThreadClick,
318398 onUnsettle,
399 onUnsnooze,
319400 renamingTitle,
320401 thread,
321402 variant,
......@@ -334,15 +415,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
334415 // flag must not light up every historical thread as unread.
335416 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
336417 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);
337427 // In-flight rows (working, or waiting on approval/input) fade as a whole:
338428 // there is nothing for the user to do yet, so prominence is reserved for
339 // rows that need a human — done (unread), read-but-unsettled, and failed.
340 // The status label keeps its hue, so waiting rows stay findable. In-flight
341 // rows recede the same as read-ready ones (inbox-zero: working threads
342 // aren't your problem yet) — only the colored status label stands out.
429 // rows that need a human — done (unread), read-but-unsettled, failed, and
430 // freshly woken. The status label keeps its hue, so waiting rows stay
431 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:
432 // working threads aren't your problem yet) — only the colored status label
433 // stands out.
343434 const isInFlight = status === "working" || status === "approval" || status === "input";
344435 const shouldRecede =
345 (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected;
436 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
346437 // Status hues follow the system-wide convention set by sidebar v1 and the
347438 // mobile Live Activity/widgets (amber approval, indigo input, sky working)
348439 // so a thread reads the same color everywhere it surfaces.
......@@ -372,13 +463,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
372463 icon: null,
373464 className: "text-red-700 dark:text-red-300",
374465 }
375 : isUnread
466 : isWoke
376467 ? {
377 label: "Done",
378 icon: "done" as const,
379 className: "text-emerald-700 dark:text-emerald-300",
468 label: "Woke",
469 icon: "woke" as const,
470 className: "text-amber-700 dark:text-amber-300",
380471 }
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
383480 const gitCwd = thread.worktreePath ?? props.projectCwd;
384481 const gitStatus = useEnvironmentQuery(
......@@ -507,6 +604,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
507604 },
508605 [onUnsettle, threadRef],
509606 );
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]);
510637 const handlePrClick = useCallback(
511638 (event: ReactMouseEvent<HTMLElement>) => {
512639 if (pr?.url) openPrLink(event, pr.url);
......@@ -555,7 +682,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
555682 variant === "card"
556683 ? cn(
557684 "truncate",
558 isUnread
685 isUnread || isWoke
559686 ? "text-foreground"
560687 : shouldRecede
561688 ? "text-muted-foreground/80"
......@@ -565,7 +692,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
565692 )
566693 : cn(
567694 "truncate group-hover/v2-row:text-foreground",
568 props.isActive
695 props.isActive || isWoke
569696 ? "text-foreground"
570697 : isUnread
571698 ? "text-muted-foreground"
......@@ -640,13 +767,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
640767 {prBadge}
641768 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
642769 <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
643 <span className="text-xs">
644 {variantAction === "unsettle"
645 ? settledTimeLabel(thread)
646 : threadTimeLabel(thread)}
647 </span>
770 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
771 // Snoozed rows show when they come BACK, not when they were
772 // last touched — the return ticket is the row's whole story.
773 <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
774 {props.snoozeWakeLabelText}
775 </span>
776 ) : isWoke ? (
777 // A wake can land straight in the settled tail (e.g. PR
778 // merged while snoozed); the signal must survive the trip.
779 <span
780 role="status"
781 aria-label="Woke from snooze"
782 className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
783 >
784 <AlarmClockIcon aria-hidden className="size-3" />
785 Woke
786 </span>
787 ) : (
788 <span className="text-xs">
789 {variantAction === "unsettle"
790 ? settledTimeLabel(thread)
791 : threadTimeLabel(thread)}
792 </span>
793 )}
648794 </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" ? (
650807 <button
651808 type="button"
652809 aria-label="Un-settle thread"
......@@ -716,7 +873,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
716873 <span className="flex-1" />
717874 )}
718875 <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 >
720882 {topStatus ? (
721883 <span
722884 className={cn(
......@@ -728,6 +890,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
728890 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
729891 ) : topStatus.icon === "done" ? (
730892 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
893 ) : topStatus.icon === "woke" ? (
894 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
731895 ) : null}
732896 {/* The label alone is the live region: a role="status"
733897 wrapper around the ticking duration would make
......@@ -743,16 +907,32 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
743907 threadTimeLabel(thread)
744908 )}
745909 </span>
746 {props.settlementSupported ? (
747 <button
748 type="button"
749 aria-label="Settle thread"
750 onClick={handleSettleClick}
751 className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
910 {props.settlementSupported || showSnoozeButton ? (
911 <span
912 className={cn(
913 "absolute inset-y-0 right-0 flex items-stretch gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/v2-row:opacity-100",
914 snoozeMenuOpen && "opacity-100",
915 )}
752916 >
753 <CheckIcon className="size-3" />
754 Settle
755 </button>
917 {showSnoozeButton ? (
918 <SnoozePopoverButton
919 open={snoozeMenuOpen}
920 onOpenChange={setSnoozeMenuOpen}
921 onSnooze={handleSnoozePreset}
922 />
923 ) : null}
924 {props.settlementSupported ? (
925 <button
926 type="button"
927 aria-label="Settle thread"
928 onClick={handleSettleClick}
929 className="inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground hover:text-foreground"
930 >
931 <CheckIcon className="size-3" />
932 Settle
933 </button>
934 ) : null}
935 </span>
756936 ) : null}
757937 </span>
758938 </div>
......@@ -819,7 +999,8 @@ export default function SidebarV2() {
819999 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
8201000 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
8211001 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
822 const { settleThread, unsettleThread, deleteThread } = useThreadActions();
1002 const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } =
1003 useThreadActions();
8231004 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
8241005 reportFailure: false,
8251006 });
......@@ -954,6 +1135,12 @@ export default function SidebarV2() {
9541135 // now is quantized to the minute so effectiveSettled memoization doesn't
9551136 // churn on every render; auto-settle thresholds are day-granular anyway.
9561137 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
9581145 // PR states stream in per-row (rows own the VCS subscriptions); a merged or
9591146 // closed PR auto-settles its thread on the next partition.
......@@ -1165,8 +1352,14 @@ export default function SidebarV2() {
11651352 // merging, no optimistic holds. Archived threads remain hidden here —
11661353 // archive keeps its original "remove from sidebar" meaning.
11671354 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1168 const { activeThreads, settledThreads } = useMemo(() => {
1355 const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => {
11691356 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();
11701363 const visible = threads.filter(
11711364 (thread) =>
11721365 thread.archivedAt === null &&
......@@ -1174,6 +1367,7 @@ export default function SidebarV2() {
11741367 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
11751368 );
11761369 const active: EnvironmentThreadShell[] = [];
1370 const snoozed: EnvironmentThreadShell[] = [];
11771371 const settled: EnvironmentThreadShell[] = [];
11781372 for (const thread of visible) {
11791373 // Threads on servers without the settlement capability (old server,
......@@ -1182,9 +1376,16 @@ export default function SidebarV2() {
11821376 // strand rows in a tail with no working affordances.
11831377 const supportsSettlement =
11841378 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
1379 const supportsSnooze =
1380 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
11851381 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
11861382 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 (
11881389 supportsSettlement &&
11891390 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
11901391 ) {
......@@ -1195,7 +1396,14 @@ export default function SidebarV2() {
11951396 }
11961397 return {
11971398 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 ),
11981405 settledThreads: sortSettledThreadsForSidebarV2(settled),
1406 snoozeNow: preciseNow,
11991407 };
12001408 }, [
12011409 autoSettleAfterDays,
......@@ -1203,9 +1411,28 @@ export default function SidebarV2() {
12031411 nowMinute,
12041412 scopedProjectKeys,
12051413 serverConfigs,
1414 snoozeWakeTick,
12061415 threads,
12071416 ]);
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
12091436 // The settled tail renders in pages: history shouldn't dominate the
12101437 // sidebar, and the common lookups are recent. Expansion resets when the
12111438 // filter context changes so a scope/search flip never inherits a deep
......@@ -1239,10 +1466,40 @@ export default function SidebarV2() {
12391466 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),
12401467 [],
12411468 );
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
12431500 const orderedThreads = useMemo(
1244 () => [...activeThreads, ...visibleSettledThreads],
1245 [activeThreads, visibleSettledThreads],
1501 () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads],
1502 [activeThreads, visibleSnoozedThreads, renderedSettledThreads],
12461503 );
12471504 const orderedThreadKeys = useMemo(
12481505 () =>
......@@ -1287,6 +1544,17 @@ export default function SidebarV2() {
12871544 );
12881545 const settledThreadKeysRef = useRef(settledThreadKeys);
12891546 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
12911559 const jumpLabelByKey = useMemo(() => {
12921560 const mapping = new Map<string, string>();
......@@ -1382,6 +1650,36 @@ export default function SidebarV2() {
13821650 // A settle per thread at a time: double clicks and repeated menu picks
13831651 // must not dispatch a second settle that fails and toasts a false error.
13841652 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
13851683 const attemptSettle = useCallback(
13861684 (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => {
13871685 void (async () => {
......@@ -1389,34 +1687,7 @@ export default function SidebarV2() {
13891687 if (settlingThreadKeysRef.current.has(threadKey)) return;
13901688 settlingThreadKeysRef.current.add(threadKey);
13911689 try {
1392 // Settling the thread you're looking at moves you forward: the next
1393 // remaining card (never a settled row, never one settling in the
1394 // same batch), or a fresh draft in this project when it was the
1395 // last active one. Snapshot the target before the settle mutates
1396 // the partition. Background settles never navigate.
1397 const shell = threadByKeyRef.current.get(threadKey);
1398 let navigateAfterSettle: (() => void) | null = null;
1399 if (routeThreadKey === threadKey) {
1400 const orderedKeys = orderedThreadKeysRef.current;
1401 const settledKeys = settledThreadKeysRef.current;
1402 const currentIndex = orderedKeys.indexOf(threadKey);
1403 const nextCardKey =
1404 currentIndex === -1
1405 ? null
1406 : ([
1407 ...orderedKeys.slice(currentIndex + 1),
1408 ...orderedKeys.slice(0, currentIndex),
1409 ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null);
1410 const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null;
1411 navigateAfterSettle = nextThread
1412 ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id))
1413 : shell
1414 ? () =>
1415 void handleNewThreadRef.current(
1416 scopeProjectRef(shell.environmentId, shell.projectId),
1417 )
1418 : () => void router.navigate({ to: "/" });
1419 }
1690 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
14201691 const result = await settleThread(threadRef);
14211692 if (result._tag === "Failure") {
14221693 // Never navigate away from a thread that did not settle.
......@@ -1442,7 +1713,7 @@ export default function SidebarV2() {
14421713 }
14431714 })();
14441715 },
1445 [navigateToThread, routeThreadKey, router, settleThread],
1716 [planForwardNavigation, settleThread],
14461717 );
14471718 const attemptUnsettle = useCallback(
14481719 (threadRef: ScopedThreadRef) => {
......@@ -1462,6 +1733,80 @@ export default function SidebarV2() {
14621733 },
14631734 [unsettleThread],
14641735 );
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
14661811 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
14671812 const handleMultiSelectContextMenu = useCallback(
......@@ -1477,10 +1822,35 @@ export default function SidebarV2() {
14771822 );
14781823 if (threadKeys.length === 0) return;
14791824 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());
14801838 const clicked = await settlePromise(() =>
14811839 api.contextMenu.show(
14821840 [
14831841 { 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 : []),
14841854 { id: "mark-unread", label: `Mark unread (${count})` },
14851855 { id: "delete", label: `Delete (${count})`, destructive: true },
14861856 ],
......@@ -1488,6 +1858,23 @@ export default function SidebarV2() {
14881858 ),
14891859 );
14901860 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 }
14911878 if (clicked.value === "settle") {
14921879 // Post-settle navigation must skip threads settling in this same
14931880 // batch — they are all leaving the card block together. Rows that
......@@ -1552,11 +1939,13 @@ export default function SidebarV2() {
15521939 },
15531940 [
15541941 attemptSettle,
1942 attemptSnooze,
15551943 clearSelection,
15561944 confirmThreadDelete,
15571945 deleteThread,
15581946 markThreadUnread,
15591947 removeFromSelection,
1948 serverConfigs,
15601949 ],
15611950 );
15621951
......@@ -1580,7 +1969,12 @@ export default function SidebarV2() {
15801969 const supportsSettlement =
15811970 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
15821971 true;
1972 const supportsSnooze =
1973 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
15831974 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());
15841978 const clicked = await settlePromise(() =>
15851979 api.contextMenu.show(
15861980 [
......@@ -1599,6 +1993,21 @@ export default function SidebarV2() {
15991993 : { id: "settle", label: "Settle thread" },
16001994 ]
16011995 : []),
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 : []),
16022011 { id: "rename", label: "Rename thread" },
16032012 { id: "mark-unread", label: "Mark unread" },
16042013 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
......@@ -1607,6 +2016,13 @@ export default function SidebarV2() {
16072016 ),
16082017 );
16092018 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 }
16102026 switch (clicked.value) {
16112027 case "new-thread-on-branch": {
16122028 // Explicit branch carry-over: reuse the thread's worktree when it
......@@ -1637,6 +2053,9 @@ export default function SidebarV2() {
16372053 case "unsettle":
16382054 attemptUnsettle(threadRef);
16392055 return;
2056 case "unsnooze":
2057 attemptUnsnooze(threadRef);
2058 return;
16402059 case "rename":
16412060 startThreadRename(threadRef, thread.title);
16422061 return;
......@@ -1676,7 +2095,9 @@ export default function SidebarV2() {
16762095 },
16772096 [
16782097 attemptSettle,
2098 attemptSnooze,
16792099 attemptUnsettle,
2100 attemptUnsnooze,
16802101 confirmThreadDelete,
16812102 deleteThread,
16822103 handleMultiSelectContextMenu,
......@@ -1930,7 +2351,7 @@ export default function SidebarV2() {
19302351 </div>
19312352 </SidebarGroup>
19322353 ) : 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]">
19342355 <TooltipProvider
19352356 key="sidebar-thread-tooltips-150"
19362357 delay={150}
......@@ -1938,88 +2359,156 @@ export default function SidebarV2() {
19382359 timeout={400}
19392360 >
19402361 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
1941 {orderedThreads.flatMap((thread, threadIndex) => {
1942 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1943 const isSettledRow = settledThreadKeys.has(threadKey);
1944 // Settled is the ONLY thing that collapses a row: every
1945 // not-settled thread is a full card. Density comes from users
1946 // (or the auto rules) actually settling work, not from the
1947 // sidebar second-guessing what still matters.
1948 const isCard = !isSettledRow;
1949 const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null;
1950 const previousWasCard =
1951 previousThread != null &&
1952 !settledThreadKeys.has(
1953 scopedThreadKey(
1954 scopeThreadRef(previousThread.environmentId, previousThread.id),
1955 ),
2362 {(() => {
2363 const renderThreadRow = (
2364 thread: EnvironmentThreadShell,
2365 section: "active" | "snoozed" | "settled",
2366 ) => {
2367 const threadKey = scopedThreadKey(
2368 scopeThreadRef(thread.environmentId, thread.id),
19562369 );
1957 const showSettledGap = !isCard && previousWasCard;
1958 const row = (
1959 <SidebarV2Row
1960 // Keyed per variant on purpose: when a thread settles, the
1961 // card fades out in place and the slim row fades in at its
1962 // settled position instead of one element FLIP-sliding
1963 // through every row in between (rows here are translucent,
1964 // so a crossing row reads as text painted over text).
1965 key={`${threadKey}:${isCard ? "card" : "slim"}`}
1966 thread={thread}
1967 variant={isCard ? "card" : "slim"}
1968 // Every settled row can un-settle: explicit settles clear
1969 // the override, auto-settled rows get pinned active.
1970 variantAction={isSettledRow ? "unsettle" : "settle"}
1971 settlementSupported={
1972 serverConfigs.get(thread.environmentId)?.environment.capabilities
1973 .threadSettlement === true
1974 }
1975 isActive={routeThreadKey === threadKey}
1976 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
1977 currentEnvironmentId={primaryEnvironmentId}
1978 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
1979 projectCwd={
1980 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1981 }
1982 projectTitle={
1983 projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
1984 null
1985 }
1986 providerEntryByInstanceId={providerEntryByInstanceId}
1987 onThreadClick={handleThreadClick}
1988 onThreadActivate={navigateToThread}
1989 onStartRename={startThreadRename}
1990 onRenameTitleChange={setRenamingTitle}
1991 onCommitRename={commitThreadRename}
1992 onCancelRename={cancelThreadRename}
1993 isRenaming={renamingThreadKey === threadKey}
1994 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
1995 onContextMenu={handleThreadContextMenu}
1996 onSettle={attemptSettle}
1997 onUnsettle={attemptUnsettle}
1998 onChangeRequestState={handleChangeRequestState}
1999 />
2370 // Settled and snoozed are the ONLY things that collapse a
2371 // row: every other thread is a full card. Density comes
2372 // from users (or the auto rules) actually parking work,
2373 // not from the sidebar second-guessing what still matters.
2374 const isCard = section === "active";
2375 const rowVariant = isCard ? "card" : "slim";
2376 return (
2377 <SidebarV2Row
2378 // Keyed per variant on purpose: when a thread settles,
2379 // the card fades out in place and the slim row fades
2380 // in at its settled position instead of one element
2381 // FLIP-sliding through every row in between (rows here
2382 // are translucent, so a crossing row reads as text
2383 // painted over text).
2384 key={`${threadKey}:${rowVariant}`}
2385 thread={thread}
2386 variant={rowVariant}
2387 // Snoozed rows wake; settled rows un-settle (explicit
2388 // settles clear the override, auto-settled rows get
2389 // pinned active); cards settle.
2390 variantAction={
2391 section === "snoozed"
2392 ? "unsnooze"
2393 : section === "settled"
2394 ? "unsettle"
2395 : "settle"
2396 }
2397 settlementSupported={
2398 serverConfigs.get(thread.environmentId)?.environment.capabilities
2399 .threadSettlement === true
2400 }
2401 snoozeSupported={
2402 serverConfigs.get(thread.environmentId)?.environment.capabilities
2403 .threadSnooze === true
2404 }
2405 snoozeWakeLabelText={
2406 section === "snoozed" && thread.snoozedUntil != null
2407 ? snoozeWakeLabel(thread.snoozedUntil, new Date())
2408 : null
2409 }
2410 // All sections: a woken thread can classify straight
2411 // into the settled tail (PR merged while snoozed), and
2412 // the wake signal must survive the trip. Still-snoozed
2413 // rows resolve to null on their own.
2414 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
2415 isActive={routeThreadKey === threadKey}
2416 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
2417 currentEnvironmentId={primaryEnvironmentId}
2418 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
2419 projectCwd={
2420 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
2421 }
2422 projectTitle={
2423 projectDisplayNameByKey.get(
2424 `${thread.environmentId}:${thread.projectId}`,
2425 ) ?? null
2426 }
2427 providerEntryByInstanceId={providerEntryByInstanceId}
2428 onThreadClick={handleThreadClick}
2429 onThreadActivate={navigateToThread}
2430 onStartRename={startThreadRename}
2431 onRenameTitleChange={setRenamingTitle}
2432 onCommitRename={commitThreadRename}
2433 onCancelRename={cancelThreadRename}
2434 isRenaming={renamingThreadKey === threadKey}
2435 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
2436 onContextMenu={handleThreadContextMenu}
2437 onSettle={attemptSettle}
2438 onUnsettle={attemptUnsettle}
2439 onSnooze={attemptSnooze}
2440 onUnsnooze={attemptUnsnooze}
2441 onChangeRequestState={handleChangeRequestState}
2442 />
2443 );
2444 };
2445 const items: ReactNode[] = activeThreads.map((thread) =>
2446 renderThreadRow(thread, "active"),
20002447 );
2001 if (!showSettledGap) return [row];
2002 // The divider is its own keyed list item (not part of the first
2003 // settled row): it keeps one stable DOM node at the boundary,
2004 // so settling a thread slides it instead of teleporting it
2005 // along with whichever row happens to be first in the tail —
2006 // and row heights stay independent of neighbor classification.
2007 return [
2008 <li
2009 key="settled-divider"
2010 aria-hidden
2011 data-thread-selection-safe
2012 className="list-none"
2013 >
2014 <div className="mb-1 mt-3 flex items-center gap-2 px-2.5">
2015 <span className="text-xs font-medium text-muted-foreground/50">Settled</span>
2016 <span className="h-px flex-1 bg-sidebar-border/60" />
2017 </div>
2018 </li>,
2019 row,
2020 ];
2021 })}
2022 {hiddenSettledCount > 0 ? (
2448 // Snoozed shelf: between the inbox and Settled — out of the
2449 // way, never gone. The header always renders while anything
2450 // is snoozed (the count is the whole footprint when
2451 // collapsed); rows only when expanded. Vanishes entirely at
2452 // count 0.
2453 if (snoozedThreads.length > 0) {
2454 items.push(
2455 <li key="snoozed-shelf-header" data-thread-selection-safe className="list-none">
2456 <button
2457 type="button"
2458 onClick={toggleSnoozedShelf}
2459 aria-expanded={snoozedShelfExpanded}
2460 data-testid="sidebar-v2-snoozed-shelf-toggle"
2461 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2462 >
2463 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
2464 {snoozedShelfExpanded ? "Snoozed" : `Snoozed (${snoozedThreads.length})`}
2465 </span>
2466 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
2467 <ChevronDownIcon
2468 aria-hidden
2469 className={cn(
2470 "size-3 text-blue-600 transition-transform dark:text-blue-400",
2471 snoozedShelfExpanded && "rotate-180",
2472 )}
2473 />
2474 </button>
2475 </li>,
2476 );
2477 for (const thread of visibleSnoozedThreads) {
2478 items.push(renderThreadRow(thread, "snoozed"));
2479 }
2480 }
2481 if (settledThreads.length > 0) {
2482 items.push(
2483 <li key="settled-shelf-header" data-thread-selection-safe className="list-none">
2484 <button
2485 type="button"
2486 onClick={toggleSettledShelf}
2487 aria-expanded={settledShelfExpanded}
2488 data-testid="sidebar-v2-settled-shelf-toggle"
2489 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2490 >
2491 <span className="text-xs font-medium text-muted-foreground/50">
2492 {settledShelfExpanded ? "Settled" : `Settled (${settledThreads.length})`}
2493 </span>
2494 <span className="h-px flex-1 bg-sidebar-border/60" />
2495 <ChevronDownIcon
2496 aria-hidden
2497 className={cn(
2498 "size-3 text-muted-foreground/50 transition-transform",
2499 settledShelfExpanded && "rotate-180",
2500 )}
2501 />
2502 </button>
2503 </li>,
2504 );
2505 }
2506 for (const thread of renderedSettledThreads) {
2507 items.push(renderThreadRow(thread, "settled"));
2508 }
2509 return items;
2510 })()}
2511 {settledShelfExpanded && hiddenSettledCount > 0 ? (
20232512 <li className="list-none">
20242513 <button
20252514 type="button"
......@@ -2035,7 +2524,7 @@ export default function SidebarV2() {
20352524 ) : null}
20362525 </ul>
20372526 </TooltipProvider>
2038 {orderedThreads.length === 0 ? (
2527 {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
20392528 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
20402529 {projects.length === 0 ? (
20412530 <>
apps/web/src/hooks/useThreadActions.ts+91-1
......@@ -4,7 +4,7 @@ import {
44 scopeThreadRef,
55} from "@t3tools/client-runtime/environment";
66import { 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";
88import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
99import * as Cause from "effect/Cause";
1010import * as Schema from "effect/Schema";
......@@ -22,6 +22,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
2222import { readLocalApi } from "../localApi";
2323import {
2424 readEnvironmentSupportsSettlement,
25 readEnvironmentSupportsSnooze,
2526 readEnvironmentThreadRefs,
2627 readProject,
2728 readThreadShell,
......@@ -69,6 +70,30 @@ export class ThreadSettleBlockedError extends Schema.TaggedErrorClass<ThreadSett
6970 }
7071}
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
7297export function useThreadActions() {
7398 const closeTerminal = useAtomCommand(terminalEnvironment.close);
7499 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
......@@ -86,6 +111,12 @@ export function useThreadActions() {
86111 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
87112 reportFailure: false,
88113 });
114 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
115 reportFailure: false,
116 });
117 const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, {
118 reportFailure: false,
119 });
89120 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession);
90121 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {
91122 reportFailure: false,
......@@ -441,6 +472,61 @@ export function useThreadActions() {
441472 [unsettleThreadMutation],
442473 );
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
444530 const confirmAndDeleteThread = useCallback(
445531 async (target: ScopedThreadRef) => {
446532 const localApi = readLocalApi();
......@@ -477,14 +563,18 @@ export function useThreadActions() {
477563 confirmAndDeleteThread,
478564 settleThread,
479565 unsettleThread,
566 snoozeThread,
567 unsnoozeThread,
480568 }),
481569 [
482570 archiveThread,
483571 confirmAndDeleteThread,
484572 deleteThread,
485573 settleThread,
574 snoozeThread,
486575 unarchiveThread,
487576 unsettleThread,
577 unsnoozeThread,
488578 ],
489579 );
490580}
apps/web/src/state/entities.ts+9
......@@ -207,6 +207,15 @@ export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId):
207207 );
208208}
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
210219export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
211220 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
212221}
apps/web/src/timestampFormat.ts+1-1
......@@ -40,7 +40,7 @@ function getTimestampFormatter(
4040 return formatter;
4141}
4242
43function parseTimestampDate(isoDate: string): Date | null {
43export function parseTimestampDate(isoDate: string): Date | null {
4444 const date = new Date(isoDate);
4545 return Number.isNaN(date.getTime()) ? null : date;
4646}
packages/client-runtime/src/operations/commands.ts+22
......@@ -37,6 +37,8 @@ export type ArchiveThreadInput = CommandInput<"thread.archive">;
3737export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;
3838export type SettleThreadInput = CommandInput<"thread.settle">;
3939export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
40export type SnoozeThreadInput = CommandInput<"thread.snooze">;
41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
4042export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
4143export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4244export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
......@@ -175,6 +177,26 @@ export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Eff
175177 });
176178});
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
178200export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
179201 "EnvironmentCommands.updateThreadMetadata",
180202)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+18
......@@ -13,10 +13,12 @@ import {
1313 type SetThreadInteractionModeInput,
1414 type SetThreadRuntimeModeInput,
1515 type SettleThreadInput,
16 type SnoozeThreadInput,
1617 type StartThreadTurnInput,
1718 type StopThreadSessionInput,
1819 type UnarchiveThreadInput,
1920 type UnsettleThreadInput,
21 type UnsnoozeThreadInput,
2022 type UpdateThreadMetadataInput,
2123 archiveThread,
2224 createThread,
......@@ -28,10 +30,12 @@ import {
2830 setThreadInteractionMode,
2931 setThreadRuntimeMode,
3032 settleThread,
33 snoozeThread,
3134 startThreadTurn,
3235 stopThreadSession,
3336 unarchiveThread,
3437 unsettleThread,
38 unsnoozeThread,
3539 updateThreadMetadata,
3640} from "../operations/commands.ts";
3741import type { EnvironmentRegistry } from "../connection/registry.ts";
......@@ -47,10 +51,12 @@ export type {
4751 SetThreadInteractionModeInput,
4852 SetThreadRuntimeModeInput,
4953 SettleThreadInput,
54 SnoozeThreadInput,
5055 StartThreadTurnInput,
5156 StopThreadSessionInput,
5257 UnarchiveThreadInput,
5358 UnsettleThreadInput,
59 UnsnoozeThreadInput,
5460 UpdateThreadMetadataInput,
5561} from "../operations/commands.ts";
5662
......@@ -100,6 +106,18 @@ export function createThreadEnvironmentAtoms<R, E>(
100106 scheduler,
101107 concurrency,
102108 }),
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 }),
103121 updateMetadata: createEnvironmentCommand(runtime, {
104122 label: "environment-data:commands:thread:update-metadata",
105123 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+2
......@@ -59,6 +59,8 @@ export function mergeEnvironmentThread(
5959 archivedAt: shell.archivedAt,
6060 settledOverride: shell.settledOverride,
6161 settledAt: shell.settledAt,
62 snoozedUntil: shell.snoozedUntil,
63 snoozedAt: shell.snoozedAt,
6264 session: shell.session,
6365 };
6466}
packages/client-runtime/src/state/threadReducer.ts+24
......@@ -74,6 +74,8 @@ export function applyThreadDetailEvent(
7474 archivedAt: null,
7575 settledOverride: null,
7676 settledAt: null,
77 snoozedUntil: null,
78 snoozedAt: null,
7779 deletedAt: null,
7880 messages: [],
7981 proposedPlans: [],
......@@ -124,6 +126,28 @@ export function applyThreadDetailEvent(
124126 },
125127 };
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
127151 // ── Thread metadata ─────────────────────────────────────────────
128152 case "thread.meta-updated":
129153 return {
packages/client-runtime/src/state/threadSettled.ts+124
......@@ -90,6 +90,130 @@ export function canSettle(
9090 return true;
9191}
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
93217/**
94218 * A merged/closed change request settles its thread only once the thread has
95219 * 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({
4444 pre-settlement servers, so clients treat missing as unsupported and
4545 never send the commands under version skew. */
4646 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),
4750 /** The update path clients should offer for this server. Absent on
4851 servers that must be relaunched manually (dev checkouts, Windows
4952 foreground runs, pre-update servers). */
packages/contracts/src/orchestration.ts+62
......@@ -361,6 +361,12 @@ export const OrchestrationThread = Schema.Struct({
361361 Schema.withDecodingDefault(Effect.succeed(null)),
362362 ),
363363 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)),
364370 deletedAt: Schema.NullOr(IsoDateTime),
365371 messages: Schema.Array(OrchestrationMessage),
366372 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(
......@@ -411,6 +417,8 @@ export const OrchestrationThreadShell = Schema.Struct({
411417 Schema.withDecodingDefault(Effect.succeed(null)),
412418 ),
413419 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
420 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
421 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
414422 session: Schema.NullOr(OrchestrationSession),
415423 latestUserMessageAt: Schema.NullOr(IsoDateTime),
416424 hasPendingApprovals: Schema.Boolean,
......@@ -583,6 +591,27 @@ const ThreadUnsettleCommand = Schema.Struct({
583591 reason: Schema.Literal("user"),
584592});
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
586615const ThreadMetaUpdateCommand = Schema.Struct({
587616 type: Schema.Literal("thread.meta.update"),
588617 commandId: CommandId,
......@@ -727,6 +756,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
727756 ThreadUnarchiveCommand,
728757 ThreadSettleCommand,
729758 ThreadUnsettleCommand,
759 ThreadSnoozeCommand,
760 ThreadUnsnoozeCommand,
730761 ThreadMetaUpdateCommand,
731762 ThreadRuntimeModeSetCommand,
732763 ThreadInteractionModeSetCommand,
......@@ -750,6 +781,8 @@ export const ClientOrchestrationCommand = Schema.Union([
750781 ThreadUnarchiveCommand,
751782 ThreadSettleCommand,
752783 ThreadUnsettleCommand,
784 ThreadSnoozeCommand,
785 ThreadUnsnoozeCommand,
753786 ThreadMetaUpdateCommand,
754787 ThreadRuntimeModeSetCommand,
755788 ThreadInteractionModeSetCommand,
......@@ -854,6 +887,8 @@ export const OrchestrationEventType = Schema.Literals([
854887 "thread.unarchived",
855888 "thread.settled",
856889 "thread.unsettled",
890 "thread.snoozed",
891 "thread.unsnoozed",
857892 "thread.meta-updated",
858893 "thread.runtime-mode-set",
859894 "thread.interaction-mode-set",
......@@ -945,6 +980,23 @@ export const ThreadUnsettledPayload = Schema.Struct({
945980 updatedAt: IsoDateTime,
946981});
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
9481000export const ThreadMetaUpdatedPayload = Schema.Struct({
9491001 threadId: ThreadId,
9501002 title: Schema.optional(TrimmedNonEmptyString),
......@@ -1122,6 +1174,16 @@ export const OrchestrationEvent = Schema.Union([
11221174 type: Schema.Literal("thread.unsettled"),
11231175 payload: ThreadUnsettledPayload,
11241176 }),
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 }),
11251187 Schema.Struct({
11261188 ...EventBaseFields,
11271189 type: Schema.Literal("thread.meta-updated"),