From 7736061ed64321f6f8863ff53bb1cb976a7956c4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 15:12:11 -0700 Subject: [PATCH 1/2] feat(sidebar-v2): thread snooze with wake-loud return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an inbox-zero snooze lifecycle to Sidebar v2: hide a thread until a chosen time, then bring it back loudly. Contracts + server: - thread.snooze / thread.unsnooze commands and thread.snoozed / thread.unsnoozed events; snoozedUntil/snoozedAt on thread + shell (optional, so pre-snooze payloads still decode) - decider invariants: wake time must be in the future; blocked-on-you work (open approval/user-input) cannot be snoozed; duplicates re-emit idempotently; a user message on a snoozed thread spends the return ticket (thread.unsnoozed reason "activity") - migration 034 adds snoozed_until/snoozed_at to projection_threads; projector, projection pipeline, and all snapshot/shell queries thread the fields through - threadSnooze capability flag, version-skew gated like threadSettlement Shared logic (client-runtime): - effectiveSnoozed: hidden while the wake time is ahead AND the thread hasn't raised its hand; timer wakes are derived (no server event) - threadRaisedHandWhileSnoozed: pending approval/input, a failure newer than the snooze, or a run completed after the snooze wake early — classification only, without clearing the server-side snooze - threadWokeAt drives the woke indicator until the user visits Sidebar v2 UI: - hover clock button beside Settle opening a preset popover (In 1 hour / This evening / Tomorrow / Next week; event-based options teased as SOON) - collapsed "Snoozed · N" shelf between Active and Settled — never hides threads without a trace; expanded rows show wake countdowns and a wake-now button; shelf disappears at count 0 - amber "Woke" pill + unread-weight title when a snooze ends; the static sort is untouched, the pill carries the signal - context-menu Snooze submenu (single + multi-select), undo toast Co-Authored-By: Claude Fable 5 --- apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 34 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 20 + apps/server/src/orchestration/Schemas.ts | 4 + .../src/orchestration/decider.snoozed.test.ts | 242 +++++++ apps/server/src/orchestration/decider.ts | 139 +++- .../src/orchestration/projector.test.ts | 2 + apps/server/src/orchestration/projector.ts | 28 + .../Layers/ProjectionRepositories.test.ts | 13 +- .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../034_ProjectionThreadsSnoozed.ts | 23 + .../persistence/Services/ProjectionThreads.ts | 2 + .../web/src/components/Sidebar.snooze.test.ts | 81 +++ apps/web/src/components/Sidebar.snooze.ts | 91 +++ apps/web/src/components/SidebarV2.tsx | 646 ++++++++++++++---- apps/web/src/hooks/useThreadActions.ts | 91 ++- apps/web/src/state/entities.ts | 9 + .../client-runtime/src/operations/commands.ts | 22 + .../src/state/threadCommands.ts | 18 + .../client-runtime/src/state/threadDetail.ts | 2 + .../client-runtime/src/state/threadReducer.ts | 24 + .../client-runtime/src/state/threadSettled.ts | 114 ++++ .../src/state/threadSnoozed.test.ts | 195 ++++++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.ts | 62 ++ 28 files changed, 1748 insertions(+), 136 deletions(-) create mode 100644 apps/server/src/orchestration/decider.snoozed.test.ts create mode 100644 apps/server/src/persistence/Migrations/034_ProjectionThreadsSnoozed.ts create mode 100644 apps/web/src/components/Sidebar.snooze.test.ts create mode 100644 apps/web/src/components/Sidebar.snooze.ts create mode 100644 packages/client-runtime/src/state/threadSnoozed.test.ts 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 01567b98d32..d80bcbc7e9d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -137,6 +137,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + threadSnooze: true, }, }; 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..73191efaa47 --- /dev/null +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -0,0 +1,242 @@ +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"]; +}): 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: [], + 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 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 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..278d24a1c6e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -508,6 +508,86 @@ 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. + 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`, + }), + ); + } + // 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 alreadySnoozed = + thread.snoozedUntil === command.snoozedUntil && 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: alreadySnoozed && thread.snoozedAt != null ? thread.snoozedAt : occurredAt, + updatedAt: alreadySnoozed ? 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 +743,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 +920,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/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts new file mode 100644 index 00000000000..4c73e9bc76b --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -0,0 +1,81 @@ +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("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..eadcec4f410 --- /dev/null +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +export type SnoozePresetId = "hour" | "evening" | "tomorrow" | "next-week"; + +export interface SnoozePreset { + readonly id: SnoozePresetId; + readonly label: string; + /** ISO wake time. */ + readonly snoozedUntil: string; +} + +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; +} + +/** + * 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 presets: SnoozePreset[] = [ + { + id: "hour", + label: "In 1 hour", + snoozedUntil: new Date(now.getTime() + HOUR_MS).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", snoozedUntil: evening.toISOString() }); + } + + const tomorrow = atHour(new Date(now.getTime() + DAY_MS), MORNING_HOUR); + presets.push({ id: "tomorrow", label: "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(new Date(now.getTime() + daysUntilMonday * DAY_MS), MORNING_HOUR); + presets.push({ id: "next-week", label: "Next week", 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 remainingMs = Date.parse(snoozedUntil) - now.getTime(); + if (Number.isNaN(remainingMs) || 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 = new Date(snoozedUntil); + if (Number.isNaN(wake.getTime())) 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 32b3ae5e1ca..aaa9104de72 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,9 +14,13 @@ import { } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { + AlarmClockIcon, + AlarmClockOffIcon, CheckIcon, + ChevronRightIcon, CircleCheckIcon, CircleDashedIcon, + ClockIcon, CloudIcon, FolderPlusIcon, MessageSquareIcon, @@ -29,6 +38,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -80,6 +90,12 @@ import { sortThreadsForSidebarV2, } from "./Sidebar.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 { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; @@ -97,6 +113,7 @@ import { useSidebar, } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; // Settled-tail paging: recent history is the common lookup; the deep tail @@ -114,15 +131,99 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } +/** + * Hover entry point for snooze: a clock button opening the preset menu. + * Kept as its own component so each row owns its popover state without + * widening the memoized row's prop surface. + */ +function SnoozePopoverButton(props: { + onSnooze: (preset: SnoozePreset) => void; + onOpenChange: (open: boolean) => void; +}) { + const { onSnooze, onOpenChange } = props; + const [open, setOpen] = useState(false); + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + onOpenChange(nextOpen); + }, + [onOpenChange], + ); + // 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 items-center gap-0.5 rounded-md border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground" + /> + } + > + + + +
+ Snooze until +
+ {presets.map((preset) => ( + + ))} +
+ {/* Roadmap teasers: event-based wake conditions. Visible-but-disabled + on purpose — they teach the mental model before they exist. */} +
+ Until PR merges + + SOON + +
+
+ Until next review + + SOON + +
+ + + ); +} + 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"; + // Slim rows are settled (action: un-settle) or snoozed (action: wake); + // card rows offer settle. + 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; @@ -141,6 +242,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 { @@ -151,10 +254,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu, onRenameTitleChange, onSettle, + onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, + onUnsnooze, renamingTitle, thread, variant, @@ -173,7 +278,14 @@ 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); - const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + // 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. + const isWoke = + props.wokeAt !== null && + (lastVisitedAt === undefined || Date.parse(lastVisitedAt) < Date.parse(props.wokeAt)); + const shouldRecede = status === "ready" && !isUnread && !isWoke && !props.isActive && !isSelected; const topStatus = status === "working" ? { @@ -200,13 +312,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { icon: null, className: "font-semibold text-red-700 dark:text-red-300", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "font-semibold text-emerald-700 dark:text-emerald-300", + label: "Woke", + icon: "woke" as const, + className: "font-semibold text-amber-700 dark:text-amber-300", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "font-semibold text-emerald-700 dark:text-emerald-300", + } + : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -305,6 +423,26 @@ 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 [snoozeMenuOpen, setSnoozeMenuOpen] = useState(false); + // Snooze is offered only where it can succeed: capability-gated and never + // on blocked-on-you work (the server would reject it anyway). + const showSnoozeButton = props.snoozeSupported && canSnooze(thread); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); @@ -341,7 +479,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant === "card" ? cn( "line-clamp-2 break-words", - isUnread + isUnread || isWoke ? "font-semibold text-foreground" : status !== "ready" ? "font-semibold text-foreground/95" @@ -421,14 +559,34 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {prBadge} - - {props.jumpLabel ?? - compactSidebarTimeLabel( - formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), - )} - + {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} + + ) : ( + + {props.jumpLabel ?? + compactSidebarTimeLabel( + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), + )} + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + ) : null}
@@ -619,7 +799,8 @@ export default function SidebarV2() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); - const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = + useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -743,7 +924,7 @@ 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 } = useMemo(() => { const now = `${nowMinute}:00.000Z`; const visible = threads.filter( (thread) => @@ -753,6 +934,7 @@ export default function SidebarV2() { thread.projectId === scopedProject.id)), ); const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { // Threads on servers without the settlement capability (old server, @@ -761,9 +943,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 })) { + snoozed.push(thread); + } else if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) ) { @@ -774,6 +963,12 @@ 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: settled.toSorted( (left, right) => firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - @@ -810,9 +1005,16 @@ export default function SidebarV2() { [], ); + // 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 = snoozedShelfExpanded ? snoozedThreads : []; + const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSettledThreads], - [activeThreads, visibleSettledThreads], + () => [...activeThreads, ...visibleSnoozedThreads, ...visibleSettledThreads], + [activeThreads, visibleSnoozedThreads, visibleSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -857,6 +1059,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(); @@ -960,15 +1173,16 @@ export default function SidebarV2() { 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. + // remaining card (never a settled or snoozed 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 snoozedKeys = snoozedThreadKeysRef.current; const currentIndex = orderedKeys.indexOf(threadKey); const nextCardKey = currentIndex === -1 @@ -976,7 +1190,12 @@ export default function SidebarV2() { : ([ ...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex), - ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); + ].find( + (key) => + !settledKeys.has(key) && + !snoozedKeys.has(key) && + !opts.coSettlingKeys?.has(key), + ) ?? null); const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; navigateAfterSettle = nextThread ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) @@ -1032,6 +1251,67 @@ 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) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) return; + snoozingThreadKeysRef.current.add(threadKey); + try { + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + 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), + }, + }), + ); + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [attemptUnsnooze, snoozeThread], + ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -1047,10 +1327,36 @@ 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 snoozableThreads = threadKeys.flatMap((threadKey) => { + const thread = threadByKeyRef.current.get(threadKey); + return thread ? [thread] : []; + }); + const canSnoozeSelection = + snoozableThreads.length === count && + snoozableThreads.every( + (thread) => + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === + true && canSnooze(thread), + ); + 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} (${snoozeWakeDescription(preset.snoozedUntil, new Date())})`, + })), + }, + ] + : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -1058,6 +1364,18 @@ 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) { + for (const thread of snoozableThreads) { + attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset); + } + 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 @@ -1122,11 +1440,13 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, clearSelection, confirmThreadDelete, deleteThread, markThreadUnread, removeFromSelection, + serverConfigs, ], ); @@ -1150,7 +1470,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( [ @@ -1161,6 +1486,21 @@ export default function SidebarV2() { : { id: "settle", label: "Settle thread" }, ] : []), + ...(supportsSnooze + ? [ + isSnoozed + ? { id: "unsnooze", label: "Wake thread" } + : { + id: "snooze", + label: "Snooze", + disabled: !canSnooze(thread), + children: snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + label: `${preset.label} (${snoozeWakeDescription(preset.snoozedUntil, new Date())})`, + })), + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -1169,6 +1509,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 "settle": attemptSettle(threadRef); @@ -1176,6 +1523,9 @@ export default function SidebarV2() { case "unsettle": attemptUnsettle(threadRef); return; + case "unsnooze": + attemptUnsnooze(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -1215,7 +1565,9 @@ export default function SidebarV2() { }, [ attemptSettle, + attemptSnooze, attemptUnsettle, + attemptUnsnooze, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, @@ -1464,86 +1816,148 @@ 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)); + // 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 showSettledGap = !isCard && previousWasCard; - const row = ( - + }; + 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, - ]; - })} + // Snoozed shelf: between the inbox and Settled — out of the + // way, never gone. The header is always rendered 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")); + } + } + // 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. Matching the pre-shelf behavior, it only shows + // when something sits above it. + if ( + visibleSettledThreads.length > 0 && + (activeThreads.length > 0 || snoozedThreads.length > 0) + ) { + items.push( +
  • +
    + + Settled + + +
    +
  • , + ); + } + for (const thread of visibleSettledThreads) { + items.push(renderThreadRow(thread, "settled")); + } + return items; + })()} {hiddenSettledCount > 0 ? (