Skip to content

🤖 feat: snooze workspaces from sidebar, menu, and /snooze command#3343

Open
ammar-agent wants to merge 8 commits into
mainfrom
chat-snooze-6rdj
Open

🤖 feat: snooze workspaces from sidebar, menu, and /snooze command#3343
ammar-agent wants to merge 8 commits into
mainfrom
chat-snooze-6rdj

Conversation

@ammar-agent
Copy link
Copy Markdown
Collaborator

@ammar-agent ammar-agent commented May 20, 2026

Summary

Adds a workspace "snooze" feature that hides a chat from the main sidebar list until its deadline passes, surfacing it instead under a dedicated 💤 Snoozed collapsible section (rendered like the existing "Older than N days" tiers, but with its own collapsed state). Snooze is reachable from the workspace dropdown menu, a keybind, and a /snooze <duration> slash command — and is discoverable via the chat-input tips carousel. Snoozes also auto-clear on user re-engagement (sending a message), matching Gmail/Slack semantics.

Background

Chats accumulate in the sidebar even when the user has consciously paused them for a few hours/days (e.g. waiting on something, or context-switching out for a sprint). Archiving is too permanent and triggers worktree cleanup; the user just wants them out of sight for a while. Snooze fills that gap with a timestamp-based hide that auto-expires.

Implementation

  • Schema. Adds an optional snoozedUntil ISO timestamp to WorkspaceConfigSchema, WorkspaceMetadataSchema, and the node config builder (NEW/LEGACY/fallback paths plus addWorkspace). The persisted form is the absolute deadline, mirroring how archive uses archivedAt/unarchivedAt — the live "is snoozed?" state is derived at render time.
  • Backend. WorkspaceService.setSnooze(workspaceId, snoozedUntil) validates the timestamp (rejects malformed input and snoozes >52 weeks out, treats past deadlines as explicit unsnooze), updates the entry, then emits metadata so the sidebar reacts via the normal subscription path. New api.workspace.snooze route with null semantics for "clear".
  • Auto-unsnooze on user re-engagement. WorkspaceService.sendMessage fires a best-effort clearSnoozeOnUserMessage for any non-synthetic send. Heartbeats, idle compaction, and goal continuations stay gated (they already pass synthetic: true) so they can never drain the Snoozed section by themselves. The helper fast-paths the common "not snoozed" case with a sync config read, so per-message overhead is negligible, and swallows errors so a transient config write failure can never block the actual message send.
  • Frontend partition. New partitionWorkspacesBySnooze (with optional parentLookup) cleanly peels snoozed workspaces out before age-tier partitioning, with parent inheritance that works across sub-project sections.
  • Sidebar render. New collapsible 💤 Snoozed (N) section appears below age tiers, styled identically to "Older than X days" with its own persisted expansion state per project/section.
  • Modal. WorkspaceSnoozeModal with preset durations (1h, 4h, 1d, 3d, 1w) + a custom input. Shows the equivalent /snooze <duration> command live. Clamps custom durations to MAX_SNOOZE_MS (52 weeks) to avoid Date RangeErrors. Includes an "Unsnooze" button when already snoozed.
  • Slash command. /snooze <duration>|off accepts 15m, 2h, 3d, 1w. Registered as workspace-only.
  • Keybind. Cmd/Ctrl+Shift+X ("eXclude from sidebar"). Handler skips editable-element targets so it never steals editor shortcuts.
  • Menu entry. "Snooze chat…" added to WorkspaceActionsMenuContent, wired through both AgentListItem and WorkspaceMenuBar.
  • Tip carousel. New tip after the pinned lead: "Try /snooze 1d to hide this chat under the Snoozed section until tomorrow".

Validation

  • make typecheck, make lint, make static-check — all green locally on the rebased branch.
  • Targeted bun tests:
    • src/common/utils/snooze.test.ts — duration parser, isWorkspaceSnoozed auto-drain, MAX_SNOOZE_MS.
    • src/browser/utils/ui/workspaceFiltering.test.tspartitionWorkspacesBySnooze (current/expired snooze, local parent inheritance, cross-section parent inheritance via parentLookup).
    • src/node/services/workspaceService.snooze.test.tssetSnooze (persist, clear-via-null, past-timestamp normalization, malformed input, max horizon) and clearSnoozeOnUserMessage (clears active snooze, fast-paths not-snoozed, swallows write errors).
    • Component suites (AgentListItem, WorkspaceMenuBar, ProjectSidebar, contexts/) — all still pass.

Risks

  • Sidebar partition output type unchanged. Snooze is a new partitioner; partitionWorkspacesByAge keeps its existing return shape, so no callers were forced to change. Regression risk is contained to the sidebar render path inside renderAgeTiers.
  • Self-healing auto-drain. A past snoozedUntil is treated as unsnoozed by isWorkspaceSnoozed and normalized away on the next setSnooze write, so a stale persisted timestamp can never strand a workspace.
  • Auto-unsnooze gated by synthetic. All four backend-initiated synthetic sends (heartbeat, heartbeat-with-compaction, idle compaction, goal continuation) already pass synthetic: true, and the helper is fire-and-forget with error swallowing, so the new path can't regress message-send latency or reliability.
  • Schema change is additive + optional. Existing configs without snoozedUntil round-trip unchanged; downgrade is safe.

Generated with mux • Model: anthropic:claude-opus-4-7 • Thinking: xhigh

@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

1 similar comment
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d80069798

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/utils/ui/keybinds.ts Outdated
Comment thread src/browser/components/WorkspaceSnoozeModal/WorkspaceSnoozeModal.tsx Outdated
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed both findings:

  • P1 (Redo conflict): SNOOZE_WORKSPACE moved to Cmd/Ctrl+Shift+X ("eXclude from sidebar"); handler also now skips when focus is in an editable element so the chord can never preventDefault an editor shortcut.
  • P2 (Range bypass): Modal + slash command + dispatcher all clamp custom durations against MAX_SNOOZE_MS (52 weeks) and verify the resulting milliseconds are finite before calling new Date(...).toISOString(). Modal also surfaces a "cap at 52 weeks" helper when the user types over the maximum.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a785b0b47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/utils/ui/workspaceFiltering.ts
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed cross-section parent inheritance:

  • partitionWorkspacesBySnooze now accepts an optional parentLookup map and falls back to it when a parentWorkspaceId isn't present in the local slice.
  • ProjectSidebar.renderAgeTiers builds a project-wide id→metadata map once per project render and threads it through every section's partition call, so a child stays under the Snoozed section even if its parent lives in a sibling section / the unsectioned bucket.
  • Added a test covering the cross-section case.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3be3f18c82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/utils/ui/workspaceFiltering.ts
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

The previous P1 (cross-section parent inheritance) is already addressed by commit 3be3f18:

  • partitionWorkspacesBySnooze now accepts an optional parentLookup (project-wide id→metadata map) and falls back to it when a parentWorkspaceId isn't in the local slice.
  • ProjectSidebar.renderAgeTiers (the single production callsite) builds a project-wide lookup once per project render and threads it through every section's partition call.
  • New test walks the parentLookup when the parent lives outside the partition slice covers exactly this case.

The function's local-slice-only behavior only kicks in when parentLookup is omitted (e.g. in legacy tests); production always passes it. Please re-check the callsite at ProjectSidebar.tsx:2490-2503.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Adds a workspace "snooze" feature that hides a chat from the main sidebar
list until a deadline passes, surfacing it under a dedicated 💤 Snoozed
collapsible section similar to the existing "Older than X" tiers.

Reachable from:
- A new "Snooze chat…" item in the workspace dropdown menu
- The Cmd/Ctrl+Shift+Z keybind (opens a modal)
- The /snooze <duration> slash command (e.g. /snooze 1h, /snooze 2d,
  /snooze 1w, /snooze off)
- A discovery tip in the chat input placeholder carousel

The modal echoes the equivalent slash command live, so the menu/keybind
flow doubles as command discovery. Snoozed workspaces drain back into the
active list automatically once their deadline passes (no backend timer).

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-7 thinking=xhigh -->
Lint rule @typescript-eslint/require-await flags async functions without an
await expression. The mock returns Promise.resolve() directly instead.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-7 thinking=xhigh -->
Codex review feedback (P1 + P2):

- SNOOZE_WORKSPACE keybind moves from Cmd/Ctrl+Shift+Z to Cmd/Ctrl+Shift+X.
  Cmd+Shift+Z is the platform-standard Redo on macOS and our global
  preventDefault was hijacking it from text inputs / editors.
- The keybind handler now skips when focus is in an editable element so we
  never preventDefault a legitimate editor shortcut even with the new key.
- Modal + slash command + dispatch now clamp custom durations to
  MAX_SNOOZE_MS (52 weeks) before calling new Date(now + ms).toISOString().
  Without the clamp, very large inputs could produce an invalid Date and a
  RangeError that bypassed the toast UI and stranded the modal as isSaving.
- Modal renders a dedicated 'cap at 52 weeks' helper message when the user
  types a duration beyond the maximum.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-7 thinking=xhigh -->
Codex P2: partitionWorkspacesBySnooze only saw the local workspace slice,
so a sub-agent whose snoozed parent lived in a sibling section (or the
unsectioned bucket) silently slipped into the active list, breaking the
'descendants follow parent snooze state' contract.

- Add an optional parentLookup map to partitionWorkspacesBySnooze that
  the partitioner falls back to when a parentWorkspaceId isn't present in
  the local slice.
- ProjectSidebar's renderAgeTiers now builds a project-wide id→metadata
  lookup once per project render and threads it through every section's
  partition call.
- New test asserts cross-section inheritance via parentLookup.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-7 thinking=xhigh -->
Sending a real (non-synthetic) message into a snoozed chat now clears its
snoozedUntil so the workspace releases from the Snoozed section, matching
Gmail/Slack snooze semantics where any user re-engagement cancels the snooze.

- WorkspaceService.sendMessage fires a best-effort clearSnoozeOnUserMessage()
  for any send where internal.synthetic !== true. Heartbeats, idle compaction,
  and goal continuations stay gated so they never drain the section.
- clearSnoozeOnUserMessage fast-paths the not-snoozed case with a sync config
  read (no editConfig write) so per-message overhead stays minimal.
- Errors are swallowed and logged at debug so a transient config write failure
  can never block the actual message send.
- Three new tests cover: clears active snooze, fast-paths when not snoozed,
  swallows write errors.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-7 thinking=xhigh -->
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Two updates since the last review:

  1. Auto-unsnooze on user re-engagement. WorkspaceService.sendMessage now fires a best-effort clearSnoozeOnUserMessage for any non-synthetic send. Sending a real message into a snoozed chat releases it from the Snoozed section (Gmail/Slack semantics). Synthetic sends (heartbeat, idle compaction, goal continuation) stay gated by internal.synthetic === true, so backend maintenance turns can't drain the section by themselves. The helper fast-paths the not-snoozed case with a sync config read, and swallows errors so a transient config write failure can never block the actual message send. Three new tests cover all three branches.

  2. Rebased onto origin/main (6 commits ahead at the time of rebase). Clean rebase, no conflicts; make static-check + targeted bun tests still green.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e49e30395e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/workspaceService.ts Outdated
The App-level sortedWorkspacesByProject is wrapped in useStableReference
with a comparator that only treats a metadata change as 'visible' when
getWorkspaceSidebarKey() differs. snoozedUntil was missing from the key,
so when setSnooze or auto-unsnooze emitted a metadata event the Map
update was correctly merged into workspaceMetadata but the comparator
short-circuited and handed the sidebar back the previous sorted Map
reference. The 💤 partition then ran on stale data and the workspace
only moved between sections after a full reload (which rebuilt the
sorted Map from scratch).

Archive happens to work without being in the key because the metadata
subscription deletes the entry from the Map entirely, which trips the
comparator's length check. Snooze keeps the entry in place, so we have
to surface the field-level change explicitly here.
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Fixes the "must reload to see snooze move workspaces between sections" report by adding snoozedUntil to getWorkspaceSidebarKey — the App-level useStableReference comparator was short-circuiting on field-only snooze changes and returning the cached sorted Map, so the sidebar partition ran on stale data even though the metadata Map itself had updated correctly.

Codex P2 catch: the previous placement next to updateRecencyTimestamp
fired before downstream validation, so any failed manual send (blank
input, pricing gate rejection, queued-task block, requireIdle on busy
session, session.sendMessage preflight error) would still drain the
snooze even though no user turn was accepted. Move the call to fire on
both real success paths instead: right before the queue-path Ok return
and after session.sendMessage resolves with success. Same synthetic
gate as before so backend-initiated maintenance sends never drain the
Snoozed section.
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed P2: moved clearSnoozeOnUserMessage out of the early "right after recency" position and into both real success points — right before the queue-path Ok return and after session.sendMessage resolves with success. Failed sends (pricing gate, requireIdle, queued-task block, session preflight errors, etc.) no longer drain the snooze. Synthetic gate preserved so heartbeats/idle compaction/goal continuations can't re-engage on their own.

Also fixed the original "must reload to see workspace move between snooze states" report by adding snoozedUntil to getWorkspaceSidebarKey — the App-level useStableReference comparator was short-circuiting on field-only snooze changes and handing the sidebar back the cached sorted Map even though the metadata Map had updated.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e97a47d428

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/workspaceService.ts Outdated
Codex P2 catch: setSnooze already falls back to a path lookup when the
config entry has no `id` yet (legacy format), but
clearSnoozeOnUserMessage only searched by id. That meant a legacy
workspace could be snoozed successfully but never auto-unsnoozed — the
fast-path would treat it as "not snoozed" and skip the editConfig
write, leaving the workspace stuck in the Snoozed section after
re-engagement. Mirror setSnooze's id-or-path fallback so the fast-path
recognizes both modern and legacy entries. Test covers the legacy
path-only case.
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Addressed second P2: mirrored setSnooze's id-or-path fallback in clearSnoozeOnUserMessage's fast-path so legacy config entries (no id yet, identified only by path) are recognized. Added a regression test for the legacy path-only entry case.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant