diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 108a4b7056f..54e242c2166 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -472,6 +472,10 @@ export function HomeScreen(props: HomeScreenProps) { // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -493,8 +497,18 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -504,20 +518,38 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, + snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, + snoozeWakeTick, settledVisibleCount, settlementEnvironmentIds, + snoozeEnvironmentIds, props.searchQuery, props.selectedEnvironmentId, props.threads, threadListV2Enabled, v2ScopedProjectGroup, ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); const threadListV2Items = threadListV2Layout.items; const renderV2Item = useCallback( @@ -813,11 +845,31 @@ export function HomeScreen(props: HomeScreenProps) { // Self-contained: v1's listEmpty keys off projectGroups, which ignores the // v2 project scope, so it can be null (results elsewhere) while this list // is empty. Search outranks the scope — "No results" names the actionable - // fact when a query is active. Pending tasks render in the header, so the - // list showing them isn't empty in the user's eyes. + // fact when a query is active. Snoozed threads outrank the rest: "No + // threads yet" over an inbox that is merely all-snoozed reads as data + // loss. Pending tasks render in the header, so the list showing them + // isn't empty in the user's eyes. + const v2SnoozedCount = threadListV2Layout.snoozedCount; const v2ListEmpty = v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( - + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. + + ) : ( + + ) + ) : v2SnoozedCount > 0 ? ( + ) : v2ScopedProjectGroup !== null ? ( new Date().toISOString().slice(0, 16)); + // Snooze wake times are second-precise; a counter bumped exactly at the + // next wake boundary re-runs the partition with a fresh clock so a woken + // thread reappears immediately instead of on the next minute tick. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -420,8 +424,18 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const snoozeEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSnooze === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + if (!threadListV2Enabled) + return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -429,20 +443,38 @@ function ThreadNavigationSidebarPane( searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, + snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, + snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, + snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, settledVisibleCount, settlementEnvironmentIds, + snoozeEnvironmentIds, threadListV2Enabled, threads, selectedProjectScope, ]); + // Re-partition the moment the earliest snooze expires (clamped to the + // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). + const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; + useEffect(() => { + if (nextSnoozeWakeAt === null) return; + const wakeAtMs = Date.parse(nextSnoozeWakeAt); + if (Number.isNaN(wakeAtMs)) return; + const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is + // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout + // range) the boundary string is identical and the chain would die. + }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks render above the thread rows (mirrors the @@ -930,15 +962,28 @@ function ThreadNavigationSidebarPane( }), [filterIcon, filterMenu, props.onOpenSettings], ); + // "No threads yet" over an inbox that is merely all-snoozed reads as + // data loss; name the snoozed threads instead. + const snoozedCount = threadListV2Layout.snoozedCount; const listEmpty = ( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + ? snoozedCount > 0 + ? // Snoozed matches passed this same search filter — "No + // matching threads" would misreport them as nonexistent. + snoozedCount === 1 + ? "1 matching thread snoozed" + : "All matching threads snoozed" + : "No matching threads" + : snoozedCount > 0 + ? snoozedCount === 1 + ? "1 thread snoozed" + : `${snoozedCount} threads snoozed` + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index cdbf2e3c585..e62b9ceda34 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -77,6 +77,85 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("hides snoozed threads and counts them — visibility parity with web", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("woken"), + title: "Woken", + // Wake time already passed: back in the active list. + snoozedUntil: "2026-06-01T18:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + // Same createdAt → static sort tiebreaks by id; the point is the woken + // thread is BACK in the card block and the snoozed one is gone. + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "woken"]); + expect(layout.snoozedCount).toBe(1); + }); + + it("classifies snooze with the second-precise clock and reports the next wake", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("just-woke"), + title: "Just woke", + // Woke 30s ago: hidden under the minute-floored clock, visible + // under the precise one. + snoozedUntil: "2026-06-02T00:00:30.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("still-snoozed"), + title: "Still snoozed", + snoozedUntil: "2026-06-02T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + // Minute-floored partition clock vs precise snooze clock. + now: "2026-06-02T00:01:00.000Z", + snoozeNow: "2026-06-02T00:01:07.500Z", + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); + expect(layout.snoozedCount).toBe(1); + expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); + }); + + it("keeps snoozed threads visible on environments without the snooze capability", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + snoozeEnvironmentIds: new Set(), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["snoozed"]); + expect(layout.snoozedCount).toBe(0); + }); + it("partitions settled threads into a slim tail with one divider", () => { const { items } = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 071e1d93be0..efa68153a3f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,4 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -84,6 +84,13 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; + /** Snoozed threads hidden from the list (visibility parity with web's + collapsed Snoozed shelf; mobile has no shelf UI yet). */ + readonly snoozedCount: number; + /** Soonest wake time among hidden snoozed threads, or null. Callers arm + a timeout at this boundary so the list re-partitions the moment a + snooze expires instead of on the next minute tick. */ + readonly nextSnoozeWakeAt: string | null; } /** @@ -106,13 +113,22 @@ export function buildThreadListV2Items(input: { other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ readonly settlementEnvironmentIds?: ReadonlySet; + /** Environments whose server supports thread.snooze/unsnooze. Same + contract as settlementEnvironmentIds. */ + readonly snoozeEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ readonly now?: string; + /** Second-precise clock for snooze classification. Callers pass a + minute-quantized `now` for memoization; snooze wake times are + second-precise, so classifying with the floored minute would hold a + woken thread hidden for up to a minute. Defaults to `now`. */ + readonly snoozeNow?: string; }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); + const snoozeNow = input.snoozeNow ?? now; const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs @@ -121,6 +137,8 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + let snoozedCount = 0; + let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { // Callers pass live (unarchived) shells; settled threads are among them // and partition into the tail via effectiveSettled. @@ -130,8 +148,23 @@ export function buildThreadListV2Items(input: { } if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + // Visibility parity with web: a snoozed thread leaves the list until it + // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed + // work). Snooze outranks settled classification, same as web. + if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + snoozedCount += 1; + if ( + thread.snoozedUntil != null && + (nextSnoozeWakeAt === null || + parseTimestampMs(thread.snoozedUntil) < parseTimestampMs(nextSnoozeWakeAt)) + ) { + nextSnoozeWakeAt = thread.snoozedUntil; + } + continue; + } if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) @@ -168,5 +201,10 @@ export function buildThreadListV2Items(input: { if (last) { items[items.length - 1] = { ...last, isLast: true }; } - return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; + return { + items, + hiddenSettledCount: orderedSettled.length - visibleSettled.length, + snoozedCount, + nextSnoozeWakeAt, + }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 80bc1916b11..8e340ef33bd 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -60,6 +60,8 @@ function threadDetailToShell( archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + snoozedUntil: thread.snoozedUntil ?? null, + snoozedAt: thread.snoozedAt ?? null, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 181237d76b6..0eaf5a7c16a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -141,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + threadSnooze: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cfb88a06cd2..1f24a4a0200 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -609,6 +609,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -679,6 +681,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.snoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: event.payload.snoozedUntil, + snoozedAt: event.payload.snoozedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsnoozed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + snoozedUntil: null, + snoozedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 15ded458e22..d4a24a209ad 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -310,6 +310,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [ { @@ -422,6 +424,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 155e9ab0013..3d05bef4bdf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -336,6 +336,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -366,6 +368,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -398,6 +402,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -762,6 +768,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1196,6 +1204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1396,6 +1406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1527,6 +1539,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1663,6 +1677,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1905,6 +1921,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2001,6 +2019,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + snoozedUntil: threadRow.value.snoozedUntil, + snoozedAt: threadRow.value.snoozedAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 0d0d7bdd5e4..3b558d24739 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,8 @@ import { ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, + ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, + ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -38,6 +40,8 @@ export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSet export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; +export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; +export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts new file mode 100644 index 00000000000..1012240b18a --- /dev/null +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -0,0 +1,286 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The decider's clock is the Effect test clock, pinned to the epoch, so +// "future" wake times are relative to 1970-01-01T00:00:00.000Z. +const FUTURE_WAKE = "1970-01-02T09:00:00.000Z"; +const PAST_WAKE = "1969-12-31T09:00:00.000Z"; +const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; + readonly archivedAt?: string | null; + readonly activities?: OrchestrationThread["activities"]; + readonly messages?: OrchestrationThread["messages"]; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), + deletedAt: null, + messages: input.messages ?? [], + proposedPlans: [], + activities: input.activities ?? [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("snoozed thread decider", (it) => { + it.effect("snoozes a thread to a future wake time", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.snoozed"); + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE); + expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt); + } + }), + ); + + it.effect("rejects a wake time that is not in the future", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-past"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: PAST_WAKE, + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects an unparseable wake time", () => + Effect.gen(function* () { + // IsoDateTime is structurally a string, so garbage can reach the + // decider; a NaN wake time must never persist as snooze state. + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-garbage"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "not-a-date", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing blocked-on-you work", () => + Effect.gen(function* () { + const requestActivity = { + id: EventId.make("activity-req-1"), + tone: "approval" as const, + kind: "approval.requested", + summary: "approval.requested", + payload: { requestId: "req-1" }, + turnId: null, + createdAt: NOW, + } as OrchestrationThread["activities"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-blocked"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ activities: [requestActivity] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () => + Effect.gen(function* () { + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-again"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(events).toHaveLength(1); + if (events[0]?.type === "thread.snoozed") { + // Original snoozedAt preserved; updatedAt must not churn. + expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("re-snoozing to a DIFFERENT wake time stamps fresh", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-extend"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: "1970-01-03T09:00:00.000Z", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.snoozed") { + expect(events[0].payload.snoozedUntil).toBe("1970-01-03T09:00:00.000Z"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("unsnoozes with reason user and re-emits idempotently when awake", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unsnoozed"); + if (events[0]?.type === "thread.unsnoozed") { + expect(events[0].payload.reason).toBe("user"); + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + + const awake = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsnooze", + commandId: CommandId.make("cmd-unsnooze-awake"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel({}), + }); + const awakeEvents = Array.isArray(awake) ? awake : [awake]; + expect(awakeEvents[0]?.type).toBe("thread.unsnoozed"); + if (awakeEvents[0]?.type === "thread.unsnoozed") { + // No state change — keep the existing updatedAt. + expect(awakeEvents[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects snoozing a thread with a queued turn start", () => + Effect.gen(function* () { + // The decider clock is the Effect test clock pinned to the epoch: a + // user message 30s before it with no adopting turn is queued work. + const queuedMessage = { + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "1969-12-31T23:59:30.000Z", + updatedAt: "1969-12-31T23:59:30.000Z", + } as OrchestrationThread["messages"][number]; + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-queued"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ messages: [queuedMessage] }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects snoozing an archived thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.snooze", + commandId: CommandId.make("cmd-snooze-archived"), + threadId: ThreadId.make("thread-1"), + snoozedUntil: FUTURE_WAKE, + }, + readModel: makeReadModel({ archivedAt: NOW }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("a user message spends the snooze return ticket (activity wake)", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), + }); + const events = Array.isArray(result) ? result : [result]; + const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed"); + expect(unsnoozed).toBeDefined(); + if (unsnoozed?.type === "thread.unsnoozed") { + expect(unsnoozed.payload.reason).toBe("activity"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index cba967afc7c..100369ae6e3 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -53,6 +53,13 @@ function isStaleRequestFailureDetail(payload: Record | null): b ); } +// Scans the read model's activities, which the projector caps at the most +// recent 500. That bound is safe here: an OPEN approval/user-input request +// blocks its turn, so the thread cannot accumulate hundreds of later +// activities while one is outstanding — a request that has scrolled out of +// the window is one whose turn kept running, i.e. it was resolved or went +// stale. (The projection pipeline's pendingApprovalCount reads the same +// capped stream and stays consistent with this view.) function hasOpenBlockingRequest(thread: { readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; }): boolean { @@ -79,6 +86,62 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } +/** + * A queued turn start — a user message no turn has picked up yet — is work + * in flight even though session is still null (turn.start emits + * message-sent + turn-start-requested; the session arrives later). Detection + * mirrors the client's hasQueuedTurnStart: the newest user message is + * strictly newer than every latestTurn timestamp (adoption stamps the new + * turn's requestedAt with the message time, clearing this), and only within + * the adoption grace window — historical threads whose last user message + * postdates their turn timestamps (older-server data, mid-turn messages) + * must not be blocked forever. A failed session start (status "error") + * clears the block immediately. + * + * The age check is bounded on BOTH sides: message timestamps are + * client-supplied, so a client clock ahead of the server yields a negative + * age. Without the lower bound that negative age satisfies `<= grace` for + * as long as the skew lasts, extending the block far past the intended two + * minutes. + */ +function threadHasQueuedTurnStart( + thread: { + readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; + readonly latestTurn: { + readonly requestedAt: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + } | null; + readonly session: { readonly status: string } | null; + }, + occurredAt: string, +): boolean { + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + return ( + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + ); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -411,46 +474,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ); } const occurredAt = yield* nowIso; - // A queued turn start — a user message no turn has picked up yet — is - // work in flight even though session is still null (turn.start emits - // message-sent + turn-start-requested; the session arrives later). - // Settling in that window would hide just-requested work. Detection - // mirrors the client's hasQueuedTurnStart: the newest user message is - // strictly newer than every latestTurn timestamp (adoption stamps the - // new turn's requestedAt with the message time, clearing this), and - // only within the adoption grace window — historical threads whose - // last user message postdates their turn timestamps (older-server - // data, mid-turn messages) must stay settleable. A failed session - // start (status "error") clears the block immediately. - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - // The age check is bounded on BOTH sides: message timestamps are - // client-supplied, so a client clock ahead of the server yields a - // negative age. Without the lower bound that negative age satisfies - // `<= grace` for as long as the skew lasts, extending the settle - // block far past the intended two minutes. - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - const hasQueuedTurnStart = - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; - if (hasQueuedTurnStart) { + // Settling inside the adoption window would hide just-requested work. + if (threadHasQueuedTurnStart(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -508,6 +533,103 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.snooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // A wake time in the past would create a thread that is snoozed and + // woken at once — the row would never leave the inbox but still carry + // snooze state. Reject instead of silently normalizing. The negated + // comparison also catches unparseable wake times (IsoDateTime is + // structurally just a string): NaN fails every comparison, and an + // unparseable snoozedUntil must never persist. + if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }), + ); + } + // Blocked-on-you work must not be snoozed away: a pending approval or + // user-input request is the agent waiting on the user, and hiding it + // defeats the request. (A running session IS snoozable — snooze only + // affects visibility, never the agent.) + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }), + ); + } + // A queued turn start — a user message no turn has adopted yet — is + // invisible pending work: no session, no pending flags. Snoozing in + // that window would hide a just-requested turn exactly the way settle + // would. + if (threadHasQueuedTurnStart(thread, occurredAt)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }), + ); + } + // Re-snoozing an already-snoozed thread to the SAME wake time is a + // duplicate (double-click, raced clients): re-emit with the original + // timestamps so the projection is a no-op. A different wake time is a + // real change and stamps fresh. + const existingSnoozedAt = + thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null + ? thread.snoozedAt + : null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.snoozed", + payload: { + threadId: command.threadId, + snoozedUntil: command.snoozedUntil, + snoozedAt: existingSnoozedAt ?? occurredAt, + updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsnooze": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): waking a thread that + // is not snoozed lands on the same null state without churning + // updatedAt. + const alreadyAwake = thread.snoozedUntil == null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -663,24 +785,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // Real activity resets ANY override: it wakes an explicitly settled // thread, and it clears a keep-active pin back to neutral so the // thread can auto-settle again after this burst of work goes stale. - if (targetThread.settledOverride === null) { - return [userMessageEvent, turnStartRequestedEvent]; + // A snooze clears the same way — sending a message to a snoozed + // thread is the user re-engaging, so the return ticket is spent. + const lifecycleResetEvents: Array> = []; + if (targetThread.settledOverride !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); } - const unsettledEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "activity", - updatedAt: command.createdAt, - }, - }; - return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; + if (targetThread.snoozedUntil != null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -822,7 +962,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; // Only a session coming alive is activity worth waking a settled thread // for — status writes like ready/stopped/error arrive after the fact and - // must not fight a user's explicit settle. + // must not fight a user's explicit settle. Snooze is deliberately NOT + // cleared here: snooze never pauses the agent, so its session starting + // or erroring is not the user re-engaging. Blocked/failed work still + // surfaces immediately — effectiveSnoozed refuses to classify a thread + // with a raised hand (approval / input / failure / fresh completion) + // as snoozed, without spending the return ticket. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; // Real activity resets ANY override (settled wakes, active unpins). diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index e9a55c0796b..9c07a312023 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,8 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c8f47dcebbf..0504cb36f9a 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -23,8 +23,10 @@ import { ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadSettledPayload, + ThreadSnoozedPayload, ThreadUnarchivedPayload, ThreadUnsettledPayload, + ThreadUnsnoozedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -290,6 +292,8 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, deletedAt: null, messages: [], activities: [], @@ -365,6 +369,30 @@ export function projectEvent( })), ); + case "thread.snoozed": + return decodeForEvent(ThreadSnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: payload.snoozedUntil, + snoozedAt: payload.snoozedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsnoozed": + return decodeForEvent(ThreadUnsnoozedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + snoozedUntil: null, + snoozedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 497aa8b7d2c..4763f565653 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -93,6 +93,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -153,6 +155,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + snoozedUntil: "2026-03-26T09:00:00.000Z", + snoozedAt: "2026-03-25T00:00:00.000Z", latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -169,12 +173,17 @@ projectionRepositoriesLayer("Projection repositories", (it) => { } assert.strictEqual(row.settledOverride, "settled"); assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); + assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); - // Un-settle to the keep-active pin and confirm the flip persists. + // Un-settle to the keep-active pin and wake the snooze; confirm the + // flips persist. yield* threads.upsert({ ...row, settledOverride: "active", settledAt: null, + snoozedUntil: null, + snoozedAt: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -182,6 +191,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.snoozedUntil, null); + assert.strictEqual(updated?.snoozedAt, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index e0c85e91494..7e86d49eac3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -45,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at, settled_override, settled_at, + snoozed_until, + snoozed_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -66,6 +68,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.snoozedUntil}, + ${row.snoozedAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -87,6 +91,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + snoozed_until = excluded.snoozed_until, + snoozed_at = excluded.snoozed_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -145,6 +153,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cacb2c85b83..d25895671a9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; +import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; /** * Migration loader with all migrations defined inline. @@ -91,6 +92,7 @@ export const migrationEntries = [ [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], + [34, "ProjectionThreadsSnoozed", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts new file mode 100644 index 00000000000..5259ae44501 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "snoozed_until")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_until TEXT + `; + } + + if (!columns.some((column) => column.name === "snoozed_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN snoozed_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 8057d434950..056425ae886 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -38,6 +38,8 @@ export const ProjectionThread = Schema.Struct({ archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + snoozedUntil: Schema.NullOr(IsoDateTime), + snoozedAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63750e91041..ab1256cddb3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,7 +25,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { + AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, @@ -3844,7 +3845,24 @@ function ChatViewContent(props: ChatViewProps) { hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); + const activeThreadSnoozed = + activeThreadShell !== null && + supportsSnooze && + effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + void snoozeWakeTick; + if (!activeThreadSnoozed) return; + const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); + if (!Number.isFinite(wakeAtMs)) return; + const id = window.setTimeout( + () => bumpSnoozeWakeTick((tick) => tick + 1), + Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), + ); + return () => window.clearTimeout(id); + }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -3890,6 +3908,34 @@ function ChatViewContent(props: ChatViewProps) { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); + const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + reportFailure: false, + }); + const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); + const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; + const handleUnsnoozeActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsnoozingThreadKey(threadKey); + try { + const result = await unsnoozeThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsnoozeThreadMutation]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -3999,29 +4045,48 @@ function ChatViewContent(props: ChatViewProps) { ]); // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, - // and the informational settled banner last — it must never cover another. - const settledComposerBannerItem = useMemo(() => { - if (!activeThreadSettled) { + // and the informational parked-thread banner last — it must never cover another. + const parkedThreadBannerItem = useMemo(() => { + if (!activeThreadSnoozed && !activeThreadSettled) { return null; } + const isSnoozed = activeThreadSnoozed; return { - id: `thread-settled:${activeThread?.id ?? "unknown"}`, + id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, variant: "info", - icon: , - title: "This thread is settled", - description: "Sending a message moves it back to Active in the sidebar.", + icon: isSnoozed ? : , + title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, + description: isSnoozed + ? "Sending a message wakes it and moves it back to Active in the sidebar." + : "Sending a message moves it back to Active in the sidebar.", actions: ( ), }; - }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); + }, [ + activeThread?.id, + activeThreadSettled, + activeThreadSnoozed, + handleUnsnoozeActiveThread, + handleUnsettleActiveThread, + isUnsnoozing, + isUnsettling, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4030,9 +4095,9 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; + const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...settledItems]; + return [...systemComposerBannerItems, ...parkedThreadItems]; } return [ ...systemComposerBannerItems, @@ -4075,14 +4140,14 @@ function ChatViewContent(props: ChatViewProps) { setBranchMismatchDismissTick((tick) => tick + 1); }, }, - ...settledItems, + ...parkedThreadItems, ]; }, [ activeBranchMismatchKey, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, - settledComposerBannerItem, + parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, ]); diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts new file mode 100644 index 00000000000..4a4518a2591 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze"; + +// Local-time constructor so preset math is timezone-stable in tests. +function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + +describe("resolveSnoozePresets", () => { + it("offers hour, evening, tomorrow, next week in the morning", () => { + // Wednesday 2026-04-08 10:00 local. + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "evening", + "tomorrow", + "next-week", + ]); + const evening = presets.find((preset) => preset.id === "evening"); + expect(new Date(evening!.snoozedUntil).getHours()).toBe(18); + const tomorrow = presets.find((preset) => preset.id === "tomorrow"); + const tomorrowDate = new Date(tomorrow!.snoozedUntil); + expect(tomorrowDate.getDate()).toBe(9); + expect(tomorrowDate.getHours()).toBe(9); + const nextWeek = presets.find((preset) => preset.id === "next-week"); + const nextWeekDate = new Date(nextWeek!.snoozedUntil); + expect(nextWeekDate.getDay()).toBe(1); + expect(nextWeekDate.getDate()).toBe(13); + }); + + it("whenLabel complements the label instead of repeating it", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + for (const preset of presets) { + // Day words live in the label column; the time column is time-only + // (plus a weekday for next week, which names a different day). + expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow"); + } + const tomorrow = presets.find((preset) => preset.id === "tomorrow"); + expect(tomorrow!.whenLabel).toMatch(/9/); + const nextWeek = presets.find((preset) => preset.id === "next-week"); + expect(nextWeek!.whenLabel).toMatch(/Mon/); + }); + + it("drops the evening preset once evening is near or past", () => { + expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + expect(resolveSnoozePresets(localDate(2026, 4, 8, 21)).map((preset) => preset.id)).toEqual([ + "hour", + "tomorrow", + "next-week", + ]); + }); + + it("puts next week a full week out when today is Monday", () => { + // Monday 2026-04-06. + const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10)); + const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); + expect(nextWeek.getDay()).toBe(1); + expect(nextWeek.getDate()).toBe(13); + }); +}); + +describe("snoozeWakeLabel", () => { + const now = localDate(2026, 4, 8, 10); + + it("formats minutes, hours, and days, rounding up", () => { + expect(snoozeWakeLabel(new Date(now.getTime() + 30 * 60_000).toISOString(), now)).toBe("30m"); + expect(snoozeWakeLabel(new Date(now.getTime() + 90 * 60_000).toISOString(), now)).toBe("2h"); + expect(snoozeWakeLabel(new Date(now.getTime() + 26 * 3_600_000).toISOString(), now)).toBe("2d"); + }); + + it("reports now for past and malformed wake times", () => { + expect(snoozeWakeLabel(new Date(now.getTime() - 1000).toISOString(), now)).toBe("now"); + expect(snoozeWakeLabel("not-a-date", now)).toBe("now"); + }); +}); + +describe("snoozeWakeDescription", () => { + const now = localDate(2026, 4, 8, 10); + + it("uses bare time today, 'tomorrow' next day, weekday within the week", () => { + expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now)).not.toContain( + "tomorrow", + ); + expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now)).toContain( + "tomorrow", + ); + expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now)).toMatch(/Mon/); + }); +}); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts new file mode 100644 index 00000000000..dd56cec7290 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -0,0 +1,127 @@ +/** + * Snooze preset resolution for the sidebar snooze menu. Pure functions so + * the preset math (evening/tomorrow/next-week boundaries) is unit-testable + * without a DOM. + * + * Presets deliberately skew short: agent-thread rhythms are hours (a CI + * run, a teammate review, the next work session), not days. + */ +import { parseTimestampDate } from "../timestampFormat"; + +type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; + +export interface SnoozePreset { + readonly id: SnoozePresetId; + readonly label: string; + /** Menu-row time column. Complements the label instead of repeating it: + "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ + readonly whenLabel: string; + /** ISO wake time. */ + readonly snoozedUntil: string; +} + +function timeOfDayLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +const EVENING_HOUR = 18; +const MORNING_HOUR = 9; +const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; + +function atHour(base: Date, hour: number): Date { + const next = new Date(base); + next.setHours(hour, 0, 0, 0); + return next; +} + +// Calendar-day advance instead of adding DAY_MS: fixed millisecond offsets +// land on the wrong local day across DST transitions (a spring-forward day +// is 23 hours, so 23:30 + 24h skips the whole next day). +function addDays(base: Date, days: number): Date { + const next = new Date(base); + next.setDate(next.getDate() + days); + return next; +} + +/** + * Presets for "snooze until", computed against local time. "This evening" + * only appears while it is still meaningfully before evening; after that + * the list starts at "Tomorrow". + */ +export function resolveSnoozePresets(now: Date): ReadonlyArray { + const inAnHour = new Date(now.getTime() + HOUR_MS); + const presets: SnoozePreset[] = [ + { + id: "hour", + label: "In 1 hour", + whenLabel: timeOfDayLabel(inAnHour), + snoozedUntil: inAnHour.toISOString(), + }, + ]; + + const evening = atHour(now, EVENING_HOUR); + // Suppress the evening preset once it is within an hour (or past): it + // would duplicate "In 1 hour" or point at the past. + if (evening.getTime() - now.getTime() > HOUR_MS) { + presets.push({ + id: "evening", + label: "This evening", + whenLabel: timeOfDayLabel(evening), + snoozedUntil: evening.toISOString(), + }); + } + + const tomorrow = atHour(addDays(now, 1), MORNING_HOUR); + presets.push({ + id: "tomorrow", + label: "Tomorrow", + whenLabel: timeOfDayLabel(tomorrow), + snoozedUntil: tomorrow.toISOString(), + }); + + // Next Monday 9:00 (a week out when today is Monday). + const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; + const nextWeek = atHour(addDays(now, daysUntilMonday), MORNING_HOUR); + presets.push({ + id: "next-week", + label: "Next week", + whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${timeOfDayLabel(nextWeek)}`, + snoozedUntil: nextWeek.toISOString(), + }); + + return presets; +} + +/** + * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes + * round up so a snooze never reads "0m" while still hidden. + */ +export function snoozeWakeLabel(snoozedUntil: string, now: Date): string { + const wake = parseTimestampDate(snoozedUntil); + if (wake === null) return "now"; + const remainingMs = wake.getTime() - now.getTime(); + if (remainingMs <= 0) return "now"; + if (remainingMs < HOUR_MS) return `${Math.max(1, Math.ceil(remainingMs / 60_000))}m`; + if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; + return `${Math.ceil(remainingMs / DAY_MS)}d`; +} + +/** + * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", + * "17:30" (today). + */ +export function snoozeWakeDescription(snoozedUntil: string, now: Date): string { + const wake = parseTimestampDate(snoozedUntil); + if (wake === null) return ""; + const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); + if (dayDelta === 0) return time; + if (dayDelta === 1) return `tomorrow ${time}`; + const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); + if (dayDelta < 7) return `${weekday} ${time}`; + const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return `${date}, ${time}`; +} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c2995359034..e018491348e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,6 +1,11 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -9,11 +14,15 @@ import { } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { + AlarmClockIcon, + AlarmClockOffIcon, CheckIcon, ChevronDownIcon, + ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, + ClockIcon, CopyIcon, FolderIcon, FolderPlusIcon, @@ -36,6 +45,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -88,11 +98,12 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; -import { formatRelativeTimeLabel } from "../timestampFormat"; +import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, + firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -107,6 +118,12 @@ import { } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveSnoozePresets, + snoozeWakeDescription, + snoozeWakeLabel, + type SnoozePreset, +} from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -130,6 +147,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./u import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -275,15 +293,74 @@ function SidebarV2ThreadTooltip({ ); } +/** + * Hover entry point for snooze: a clock button opening the preset menu. + * Controlled by the row (which also uses the open state to pin its hover + * actions while the menu is up). + */ +function SnoozePopoverButton(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSnooze: (preset: SnoozePreset) => void; +}) { + const { open, onOpenChange, onSnooze } = props; + // Presets resolve at open time so "In 1 hour" is relative to the click, + // not to when the row mounted. + const presets = useMemo(() => (open ? resolveSnoozePresets(new Date()) : []), [open]); + return ( + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + 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" + /> + } + > + + + + {presets.map((preset) => ( + + ))} + + + ); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle"; + variantAction: "settle" | "unsettle" | "unsnooze"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; + // Same contract for thread.snooze/unsnooze. + snoozeSupported: boolean; + // Compact wake countdown ("2h") for rows in the snoozed shelf. + snoozeWakeLabelText: string | null; + // When a snooze ended (timer or early wake); drives the Woke pill until + // the user visits the thread. + wokeAt: string | null; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -302,6 +379,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; + onUnsnooze: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -312,10 +391,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu, onRenameTitleChange, onSettle, + onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, + onUnsnooze, renamingTitle, thread, variant, @@ -334,15 +415,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); + // A woken thread reappears at its original position (the sort is + // deliberately static), so the pill has to carry the weight. Snoozing is + // an explicit act, so unlike Done, a never-visited woke thread still + // shows the pill; visiting clears it. An unparseable visit timestamp + // counts as never-visited — corrupt local data must not eat the wake + // signal. + const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); + const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human — done (unread), read-but-unsettled, and failed. - // The status label keeps its hue, so waiting rows stay findable. In-flight - // rows recede the same as read-ready ones (inbox-zero: working threads - // aren't your problem yet) — only the colored status label stands out. + // rows that need a human — done (unread), read-but-unsettled, failed, and + // freshly woken. The status label keeps its hue, so waiting rows stay + // findable. In-flight rows recede the same as read-ready ones (inbox-zero: + // working threads aren't your problem yet) — only the colored status label + // stands out. const isInFlight = status === "working" || status === "approval" || status === "input"; const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; + (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. @@ -372,13 +463,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { icon: null, className: "text-red-700 dark:text-red-300", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -507,6 +604,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onUnsettle, threadRef], ); + const handleUnsnoozeClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsnooze(threadRef); + }, + [onUnsnooze, threadRef], + ); + const handleSnoozePreset = useCallback( + (preset: SnoozePreset) => { + onSnooze(threadRef, preset); + }, + [onSnooze, threadRef], + ); + // While the snooze popover is open the pointer leaves the row, which + // would fade the hover actions out from under the open menu; pin them. + const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); + // Snooze is offered only where it can succeed: capability-gated and never + // on blocked-on-you work or queued turns (the server rejects both). + const showSnoozeButton = + props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); + // If the thread becomes blocked while the popover is open, the button + // unmounts without firing onOpenChange(false). Deriving the flag keeps a + // stale true from permanently hiding the status label / pinning the + // hover actions, and the effect clears the raw state so the popover + // doesn't resurrect if the button later remounts. + const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; + useEffect(() => { + if (!showSnoozeButton) setSnoozeMenuOpen(false); + }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); @@ -555,7 +682,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant === "card" ? cn( "truncate", - isUnread + isUnread || isWoke ? "text-foreground" : shouldRecede ? "text-muted-foreground/80" @@ -565,7 +692,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : cn( "truncate group-hover/v2-row:text-foreground", - props.isActive + props.isActive || isWoke ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -640,13 +767,43 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {prBadge} - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. + + + Woke + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + ) : null} @@ -819,7 +999,8 @@ export default function SidebarV2() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = + useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -954,6 +1135,12 @@ export default function SidebarV2() { // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. const nowMinute = useNowMinute(); + // Snooze wake times are second-precise, so classifying with the quantized + // minute would hold a woken thread on the shelf for up to a minute. The + // tick is a plain counter bumped exactly at the next wake boundary (armed + // below, after the partition knows the boundary); the partition reads a + // fresh clock whenever it recomputes. + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition. @@ -1165,8 +1352,14 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { activeThreads, settledThreads } = useMemo(() => { + const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); const visible = threads.filter( (thread) => thread.archivedAt === null && @@ -1174,6 +1367,7 @@ export default function SidebarV2() { scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { // Threads on servers without the settlement capability (old server, @@ -1182,9 +1376,16 @@ export default function SidebarV2() { // strand rows in a tail with no working affordances. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - if ( + // Snooze outranks settled classification: an explicitly snoozed thread + // belongs to the shelf even if it would also auto-settle (the shelf's + // wake time is a stronger statement about when it matters again). + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + } else if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) ) { @@ -1195,7 +1396,14 @@ export default function SidebarV2() { } return { activeThreads: sortThreadsForSidebarV2(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), settledThreads: sortSettledThreadsForSidebarV2(settled), + snoozeNow: preciseNow, }; }, [ autoSettleAfterDays, @@ -1203,9 +1411,28 @@ export default function SidebarV2() { nowMinute, scopedProjectKeys, serverConfigs, + snoozeWakeTick, threads, ]); + // Arm a timeout for the earliest upcoming wake so the shelf empties the + // moment a snooze expires instead of on the next minute tick. Sorted + // soonest-first, so entry 0 is the boundary. + useEffect(() => { + const nextWakeAtMs = + snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null + ? Date.parse(snoozedThreads[0].snoozedUntil) + : Number.NaN; + if (Number.isNaN(nextWakeAtMs)) return; + // setTimeout delays are signed 32-bit: anything larger overflows and + // fires immediately, turning a far-future wake (event-condition snoozes + // synced from elsewhere) into a tight re-arm loop. Clamped, the timer + // just re-arms every ~24.8 days until the wake is in range. + const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); + const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); + return () => window.clearTimeout(id); + }, [snoozedThreads]); + // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the // filter context changes so a scope/search flip never inherits a deep @@ -1239,10 +1466,40 @@ export default function SidebarV2() { () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], ); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const renderedSettledThreads = useMemo(() => { + if (settledShelfExpanded) return visibleSettledThreads; + if (routeThreadKey === null) return []; + const routeThread = visibleSettledThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + + // The snoozed shelf is collapsed by default: out of the way, never gone. + // Collapsed threads don't render (and so don't participate in jump + // shortcuts or multi-select), matching the settled tail's paging model. + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const visibleSnoozedThreads = useMemo(() => { + if (snoozedShelfExpanded) return snoozedThreads; + // The open thread must never vanish behind the collapsed shelf: a + // snoozed thread reached by route (deep link, open before snoozing + // elsewhere) keeps its row — with highlight and wake affordance — same + // exception the settled tail's "Show more" makes. + if (routeThreadKey === null) return []; + const routeThread = snoozedThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSettledThreads], - [activeThreads, visibleSettledThreads], + () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -1287,6 +1544,17 @@ export default function SidebarV2() { ); const settledThreadKeysRef = useRef(settledThreadKeys); settledThreadKeysRef.current = settledThreadKeys; + const snoozedThreadKeys = useMemo( + () => + new Set( + snoozedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [snoozedThreads], + ); + const snoozedThreadKeysRef = useRef(snoozedThreadKeys); + snoozedThreadKeysRef.current = snoozedThreadKeys; const jumpLabelByKey = useMemo(() => { const mapping = new Map(); @@ -1382,6 +1650,36 @@ export default function SidebarV2() { // A settle per thread at a time: double clicks and repeated menu picks // must not dispatch a second settle that fails and toasts a false error. const settlingThreadKeysRef = useRef(new Set()); + // Parking the thread you're looking at (settle or snooze) moves you + // forward: the next remaining card (never a settled or snoozed row, never + // one leaving in the same batch), or a fresh draft in this project when it + // was the last active one. Callers snapshot the plan BEFORE the command + // mutates the partition; background parks never navigate (null plan). + const planForwardNavigation = useCallback( + (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { + if (routeThreadKeyRef.current !== threadKey) return null; + const shell = threadByKeyRef.current.get(threadKey); + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const snoozedKeys = snoozedThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( + (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), + ) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + return nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) + : () => void router.navigate({ to: "/" }); + }, + [navigateToThread, router], + ); + const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { void (async () => { @@ -1389,34 +1687,7 @@ export default function SidebarV2() { if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { - // Settling the thread you're looking at moves you forward: the next - // remaining card (never a settled row, never one settling in the - // same batch), or a fresh draft in this project when it was the - // last active one. Snapshot the target before the settle mutates - // the partition. Background settles never navigate. - const shell = threadByKeyRef.current.get(threadKey); - let navigateAfterSettle: (() => void) | null = null; - if (routeThreadKey === threadKey) { - const orderedKeys = orderedThreadKeysRef.current; - const settledKeys = settledThreadKeysRef.current; - const currentIndex = orderedKeys.indexOf(threadKey); - const nextCardKey = - currentIndex === -1 - ? null - : ([ - ...orderedKeys.slice(currentIndex + 1), - ...orderedKeys.slice(0, currentIndex), - ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); - const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; - navigateAfterSettle = nextThread - ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) - : shell - ? () => - void handleNewThreadRef.current( - scopeProjectRef(shell.environmentId, shell.projectId), - ) - : () => void router.navigate({ to: "/" }); - } + const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); const result = await settleThread(threadRef); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. @@ -1442,7 +1713,7 @@ export default function SidebarV2() { } })(); }, - [navigateToThread, routeThreadKey, router, settleThread], + [planForwardNavigation, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -1462,6 +1733,80 @@ export default function SidebarV2() { }, [unsettleThread], ); + const attemptUnsnooze = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsnoozeThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsnoozeThread], + ); + // One snooze per thread at a time — same double-dispatch guard as settle. + const snoozingThreadKeysRef = useRef(new Set()); + const attemptSnooze = useCallback( + ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) return; + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [attemptUnsnooze, planForwardNavigation, snoozeThread], + ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -1477,10 +1822,35 @@ export default function SidebarV2() { ); if (threadKeys.length === 0) return; const count = threadKeys.length; + // Snooze (N) is offered when every selected thread can actually take + // it — a mixed selection with blocked-on-you work would half-apply. + const selectionNow = new Date().toISOString(); + const snoozableThreads = threadKeys.flatMap((threadKey) => { + const thread = threadByKeyRef.current.get(threadKey); + return thread ? [thread] : []; + }); + const canSnoozeSelection = snoozableThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && + canSnooze(thread, { now: selectionNow }), + ); + const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ { id: "settle", label: `Settle (${count})` }, + ...(canSnoozeSelection + ? [ + { + id: "snooze", + label: `Snooze (${count})`, + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1488,6 +1858,23 @@ export default function SidebarV2() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) { + // Post-snooze navigation must skip threads snoozing in this same + // batch — they are all leaving the card block together. + const coSnoozingKeys = new Set(threadKeys); + for (const thread of snoozableThreads) { + attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { + coSnoozingKeys, + }); + } + clearSelection(); + } + return; + } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that @@ -1552,11 +1939,13 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, clearSelection, confirmThreadDelete, deleteThread, markThreadUnread, removeFromSelection, + serverConfigs, ], ); @@ -1580,7 +1969,12 @@ export default function SidebarV2() { const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); + const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + // Presets resolve at menu-open time (same as the popover). + const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -1599,6 +1993,21 @@ export default function SidebarV2() { : { id: "settle", label: "Settle thread" }, ] : []), + ...(supportsSnooze + ? [ + isSnoozed + ? { id: "unsnooze", label: "Wake thread" } + : { + id: "snooze", + label: "Snooze", + disabled: !canSnooze(thread, { now: new Date().toISOString() }), + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -1607,6 +2016,13 @@ export default function SidebarV2() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("snooze:")) { + const preset = snoozePresets.find( + (candidate) => `snooze:${candidate.id}` === clicked.value, + ); + if (preset) attemptSnooze(threadRef, preset); + return; + } switch (clicked.value) { case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it @@ -1637,6 +2053,9 @@ export default function SidebarV2() { case "unsettle": attemptUnsettle(threadRef); return; + case "unsnooze": + attemptUnsnooze(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -1676,7 +2095,9 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, attemptUnsettle, + attemptUnsnooze, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, @@ -1930,7 +2351,7 @@ export default function SidebarV2() { ) : null} - +
    - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey( - scopeThreadRef(previousThread.environmentId, previousThread.id), - ), + {(() => { + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "active" | "snoozed" | "settled", + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const items: ReactNode[] = activeThreads.map((thread) => + renderThreadRow(thread, "active"), ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail — - // and row heights stay independent of neighbor classification. - return [ -
  • -
    - Settled - -
    -
  • , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( + // Snoozed shelf: between the inbox and Settled — out of the + // way, never gone. The header always renders while anything + // is snoozed (the count is the whole footprint when + // collapsed); rows only when expanded. Vanishes entirely at + // count 0. + if (snoozedThreads.length > 0) { + items.push( +
  • + +
  • , + ); + for (const thread of visibleSnoozedThreads) { + items.push(renderThreadRow(thread, "snoozed")); + } + } + if (settledThreads.length > 0) { + items.push( +
  • + +
  • , + ); + } + for (const thread of renderedSettledThreads) { + items.push(renderThreadRow(thread, "settled")); + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? (