From fa454dafdb6052804511e45e8dc5f4e9bbe011b1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 20 May 2026 16:53:59 -0500 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20snooze=20workspaces?= =?UTF-8?q?=20from=20sidebar,=20menu,=20and=20/snooze=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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`_ --- .../AgentListItem/AgentListItem.tsx | 12 + .../ProjectSidebar/ProjectSidebar.tsx | 81 +++++- .../WorkspaceActionsMenuContent.tsx | 30 ++- .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 26 ++ .../WorkspaceSnoozeModal.tsx | 236 ++++++++++++++++++ .../components/WorkspaceSnoozeModal/index.ts | 1 + src/browser/contexts/ThinkingContext.test.tsx | 1 + src/browser/contexts/WorkspaceContext.tsx | 37 +++ .../features/ChatInput/placeholderTips.ts | 1 + .../Settings/Sections/KeybindsSection.tsx | 2 + src/browser/utils/chatCommands.ts | 57 +++++ src/browser/utils/slashCommands/registry.ts | 47 ++++ src/browser/utils/slashCommands/types.ts | 1 + src/browser/utils/ui/keybinds.ts | 5 + .../utils/ui/workspaceFiltering.test.ts | 38 +++ src/browser/utils/ui/workspaceFiltering.ts | 75 ++++++ src/common/constants/slashCommandHints.ts | 1 + src/common/orpc/schemas/api.ts | 12 + src/common/orpc/schemas/telemetry.ts | 1 + src/common/orpc/schemas/workspace.ts | 4 + src/common/schemas/project.ts | 4 + src/common/telemetry/payload.ts | 3 +- src/common/utils/snooze.test.ts | 76 ++++++ src/common/utils/snooze.ts | 82 ++++++ src/constants/slashCommands.ts | 2 + src/node/config.ts | 4 + src/node/orpc/router.ts | 6 + .../services/workspaceService.snooze.test.ts | 134 ++++++++++ src/node/services/workspaceService.ts | 70 ++++++ 29 files changed, 1044 insertions(+), 5 deletions(-) create mode 100644 src/browser/components/WorkspaceSnoozeModal/WorkspaceSnoozeModal.tsx create mode 100644 src/browser/components/WorkspaceSnoozeModal/index.ts create mode 100644 src/common/utils/snooze.test.ts create mode 100644 src/common/utils/snooze.ts create mode 100644 src/node/services/workspaceService.snooze.test.ts diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 7139ff6a3e..6fed301fde 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -62,6 +62,7 @@ import { useLinkSharingEnabled } from "@/browser/contexts/TelemetryEnabledContex import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { ShareTranscriptDialog } from "../ShareTranscriptDialog/ShareTranscriptDialog"; import { WorkspaceHeartbeatModal } from "../WorkspaceHeartbeatModal"; +import { WorkspaceSnoozeModal } from "../WorkspaceSnoozeModal"; import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/WorkspaceActionsMenuContent"; import { useAPI } from "@/browser/contexts/API"; @@ -484,6 +485,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) { const linkSharingEnabled = useLinkSharingEnabled(); const [shareTranscriptOpen, setShareTranscriptOpen] = useState(false); const [heartbeatModalOpen, setHeartbeatModalOpen] = useState(false); + const [snoozeModalOpen, setSnoozeModalOpen] = useState(false); const overflowMenuButtonRef = useRef(null); const overflowMenuFrameRef = useRef(null); @@ -925,6 +927,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) { void onForkWorkspace(workspaceId, anchorEl); }} onShareTranscript={() => setShareTranscriptOpen(true)} + onSnoozeChat={() => setSnoozeModalOpen(true)} onArchiveChat={(anchorEl) => { void onArchiveWorkspace(workspaceId, anchorEl); }} @@ -984,6 +987,15 @@ function RegularAgentListItemInner(props: AgentListItemProps) { onOpenChange={setHeartbeatModalOpen} /> )} + {/* Lazy-mount so tests that render AgentListItem without the + full WorkspaceProvider tree don't pay the modal hook cost. */} + {snoozeModalOpen && ( + + )} {/* Share transcript dialog – rendered as a sibling to the overflow menu. Triggered by the menu item above or the Ctrl+Shift+L keybind. Uses a Dialog (modal) so it stays visible regardless of popover dismissal. */} diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 3c54580f63..188d23a660 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -47,6 +47,7 @@ import { PlatformPaths } from "@/common/utils/paths"; import { partitionWorkspacesByAge, partitionWorkspacesBySection, + partitionWorkspacesBySnooze, formatDaysThreshold, AGE_THRESHOLDS_DAYS, computeWorkspaceDepthMap, @@ -88,6 +89,10 @@ import { Folder, FolderOpen, KeyRound, + // Moon is the canonical SVG stand-in for the 💤 (Z) emoji per the + // sidebar styling rules — same icon EmojiIcon maps 💤 to, but used inline + // here since this is a fixed UI label, not tool-emitted output. + Moon, Palette, Pencil, Trash, @@ -841,6 +846,13 @@ const ProjectSidebarInner: React.FC = ({ Record >("expandedOldWorkspaces", {}); + // Track which projects/sections have their 💤 Snoozed section expanded. + // Keyed by the same `tierKeyPrefix` used for age tiers so each project and + // sub-project section gets its own collapsed state, persisted across reloads. + const [expandedSnoozeSections, setExpandedSnoozeSections] = usePersistedState< + Record + >("expandedSnoozeSections", {}); + // Track which sections are expanded const [expandedSections, setExpandedSections] = usePersistedState>( "expandedSections", @@ -2464,15 +2476,24 @@ const ProjectSidebarInner: React.FC = ({ ); }; - // Render age tiers for a list of workspaces + // Render age tiers for a list of workspaces. + // + // Snoozed workspaces are peeled off first so they don't muddle the + // age tiers. They render as a dedicated 💤 collapsible section + // appended after the last age tier, mirroring how the "Older than X" + // tiers work but with their own expansion state. const renderAgeTiers = ( workspaces: FrontendWorkspaceMetadata[], tierKeyPrefix: string, sectionId?: string, allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces ): React.ReactNode => { + const { active: activeWorkspaces, snoozed: snoozedWorkspaces } = + partitionWorkspacesBySnooze(workspaces); const { recent: topVisibleRows, buckets } = - partitionWorkspacesByAge(workspaces, workspaceRecency); + partitionWorkspacesByAge(activeWorkspaces, workspaceRecency); + const isSnoozeSectionExpanded = + expandedSnoozeSections[tierKeyPrefix] ?? false; const expandedTierVisibleIds = new Set(); const markExpandedTierRowsVisible = (tierIndex: number): void => { @@ -2506,10 +2527,14 @@ const ProjectSidebarInner: React.FC = ({ } // Connector geometry should match the rows users can currently see, - // not hidden siblings parked behind collapsed age tiers. + // not hidden siblings parked behind collapsed age tiers (or behind + // a collapsed snooze section). const visibleRowIds = new Set([ ...topVisibleRows.map((workspace) => workspace.id), ...expandedTierVisibleIds, + ...(isSnoozeSectionExpanded + ? snoozedWorkspaces.map((workspace) => workspace.id) + : []), ]); const visibleRows = workspaces.filter((workspace) => visibleRowIds.has(workspace.id) @@ -2695,6 +2720,55 @@ const ProjectSidebarInner: React.FC = ({ ); }; + const renderSnoozeSection = (): React.ReactNode => { + if (snoozedWorkspaces.length === 0) return null; + return ( + + + {isSnoozeSectionExpanded && + renderWorkspaceRowsWithTaskGroupCoalescing({ + rows: snoozedWorkspaces, + allRows: allRowsForTaskGroupCoalescing, + sectionId, + rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, + })} + + ); + }; + return ( <> {renderWorkspaceRowsWithTaskGroupCoalescing({ @@ -2704,6 +2778,7 @@ const ProjectSidebarInner: React.FC = ({ rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, })} {firstTier !== -1 && renderTier(firstTier)} + {renderSnoozeSection()} ); }; diff --git a/src/browser/components/WorkspaceActionsMenuContent/WorkspaceActionsMenuContent.tsx b/src/browser/components/WorkspaceActionsMenuContent/WorkspaceActionsMenuContent.tsx index 3bdb826099..eef59ba353 100644 --- a/src/browser/components/WorkspaceActionsMenuContent/WorkspaceActionsMenuContent.tsx +++ b/src/browser/components/WorkspaceActionsMenuContent/WorkspaceActionsMenuContent.tsx @@ -1,6 +1,15 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { ArchiveIcon } from "../icons/ArchiveIcon/ArchiveIcon"; -import { GitBranch, HeartPulse, Link2, Maximize2, Pencil, Server, Square } from "lucide-react"; +import { + GitBranch, + HeartPulse, + Link2, + Maximize2, + Moon, + Pencil, + Server, + Square, +} from "lucide-react"; import React from "react"; interface WorkspaceActionButtonProps { @@ -46,6 +55,12 @@ interface WorkspaceActionsMenuContentProps { onStopRuntime?: (() => void) | null; onForkChat?: ((anchorEl: HTMLElement) => void) | null; onShareTranscript?: (() => void) | null; + /** + * Open the workspace snooze modal. The same backing action is reachable via + * the `/snooze ` slash command and the SNOOZE_WORKSPACE keybind; + * this menu entry is the primary discovery surface. + */ + onSnoozeChat?: (() => void) | null; onArchiveChat?: ((anchorEl: HTMLElement) => void) | null; onCloseMenu: () => void; linkSharingEnabled: boolean; @@ -159,6 +174,19 @@ export const WorkspaceActionsMenuContent: React.FC )} + {props.onSnoozeChat && ( + } + onClick={(e) => { + e.stopPropagation(); + props.onCloseMenu(); + props.onSnoozeChat?.(); + }} + /> + )} {props.onArchiveChat && ( = ({ const [debugLlmRequestOpen, setDebugLlmRequestOpen] = useState(false); const [mcpModalOpen, setMcpModalOpen] = useState(false); const [heartbeatModalOpen, setHeartbeatModalOpen] = useState(false); + const [snoozeModalOpen, setSnoozeModalOpen] = useState(false); const [availableSkills, setAvailableSkills] = useState([]); const [invalidSkills, setInvalidSkills] = useState([]); const isSkillsMountedRef = useRef(true); @@ -438,6 +440,20 @@ export const WorkspaceMenuBar: React.FC = ({ return () => window.removeEventListener("keydown", handler); }, [workspaceHeartbeatsEnabled]); + // Keybind for opening the snooze modal. Lives here (not on AgentListItem) + // so the shortcut still resolves when the left sidebar is collapsed and + // workspace rows are unmounted — same pattern as SHARE_TRANSCRIPT. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (matchesKeybind(e, KEYBINDS.SNOOZE_WORKSPACE)) { + e.preventDefault(); + setSnoozeModalOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + // Keybind for sharing transcript — lives here (not AgentListItem) so it // works even when the left sidebar is collapsed and list items are unmounted. useEffect(() => { @@ -767,6 +783,7 @@ export const WorkspaceMenuBar: React.FC = ({ void handleForkChat(anchorEl); }} onShareTranscript={() => setShareTranscriptOpen(true)} + onSnoozeChat={() => setSnoozeModalOpen(true)} onArchiveChat={(anchorEl) => { // handleArchiveChat runs preflight and opens a confirmation dialog // when streaming or untracked files are detected. @@ -787,6 +804,15 @@ export const WorkspaceMenuBar: React.FC = ({ onOpenChange={setHeartbeatModalOpen} /> )} + {/* Lazy-mount so WorkspaceMenuBar tests that don't stub the full + WorkspaceProvider tree aren't forced to provide snooze context. */} + {snoozeModalOpen && ( + + )} ` slash command help so the modal and command surface + * stay in lockstep. Custom durations are supported via the input field. + */ +const SNOOZE_PRESETS: readonly SnoozePreset[] = [ + { label: "1 hour", durationToken: "1h" }, + { label: "4 hours", durationToken: "4h" }, + { label: "Tomorrow", durationToken: "1d" }, + { label: "3 days", durationToken: "3d" }, + { label: "1 week", durationToken: "1w" }, +]; + +interface WorkspaceSnoozeModalProps { + workspaceId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Modal companion to `/snooze `. Lets the user pick a preset + * duration or type a custom one (e.g. `15m`, `2h`, `1w`); shows the + * equivalent slash command live so the muscle-memory keyboard flow is + * discoverable from the menu/keybind entry point. + */ +export function WorkspaceSnoozeModal(props: WorkspaceSnoozeModalProps) { + const { api } = useAPI(); + const { snoozeWorkspace } = useWorkspaceActions(); + const { workspaceMetadata } = useWorkspaceMetadata(); + const metadata = workspaceMetadata.get(props.workspaceId); + const currentSnoozedUntil = metadata?.snoozedUntil; + const isCurrentlySnoozed = isWorkspaceSnoozed(currentSnoozedUntil); + + const [selectedDuration, setSelectedDuration] = useState(SNOOZE_PRESETS[2].durationToken); + const [customDuration, setCustomDuration] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + // Resets draft state whenever the modal reopens so a previously-typed + // custom value doesn't surprise the user on the next snooze gesture. + const previousOpenRef = useRef(props.open); + useEffect(() => { + if (props.open && !previousOpenRef.current) { + setSelectedDuration(SNOOZE_PRESETS[2].durationToken); + setCustomDuration(""); + setError(null); + setIsSaving(false); + } + previousOpenRef.current = props.open; + }, [props.open]); + + // Derive the active duration token (preset or custom). When the custom + // field has content we prefer it so the live `/snooze ` hint reflects + // what the user is actually about to submit. + const customDurationMs = customDuration.trim() ? parseHumanDurationMs(customDuration) : null; + const effectiveDurationToken = (() => { + if (customDuration.trim().length > 0) { + return customDurationMs != null ? formatDurationShort(customDurationMs) : null; + } + return selectedDuration; + })(); + const effectiveDurationMs = (() => { + if (customDuration.trim().length > 0) return customDurationMs; + return parseHumanDurationMs(selectedDuration); + })(); + const equivalentCommand = effectiveDurationToken + ? `/snooze ${effectiveDurationToken}` + : "/snooze "; + const hasInvalidCustom = customDuration.trim().length > 0 && customDurationMs == null; + const canSnooze = !isSaving && !hasInvalidCustom && effectiveDurationMs != null && api != null; + + const handleSnooze = async () => { + if (!effectiveDurationMs || !api) { + return; + } + setIsSaving(true); + setError(null); + const deadline = new Date(Date.now() + effectiveDurationMs).toISOString(); + const result = await snoozeWorkspace(props.workspaceId, deadline); + if (result.success) { + props.onOpenChange(false); + } else { + setError(result.error ?? "Failed to snooze workspace"); + } + setIsSaving(false); + }; + + const handleUnsnooze = async () => { + if (!api) return; + setIsSaving(true); + setError(null); + const result = await snoozeWorkspace(props.workspaceId, null); + if (result.success) { + props.onOpenChange(false); + } else { + setError(result.error ?? "Failed to clear snooze"); + } + setIsSaving(false); + }; + + return ( + + + + + + Snooze chat + + + +
+

+ Hide this chat from the main sidebar until the timer expires. Snoozed chats live under a + dedicated Snoozed section and return + automatically when their deadline passes. +

+ + {isCurrentlySnoozed && currentSnoozedUntil && ( +
+
Currently snoozed
+
+ Until {new Date(currentSnoozedUntil).toLocaleString()} +
+
+ )} + +
+
Choose a duration
+
+ {SNOOZE_PRESETS.map((preset) => { + const isSelected = + customDuration.trim().length === 0 && preset.durationToken === selectedDuration; + return ( + + ); + })} +
+
+ +
+ + ) => { + setCustomDuration(event.target.value); + }} + placeholder="e.g. 90m" + disabled={isSaving} + className="border-border-medium bg-background-secondary" + aria-label="Custom snooze duration" + /> + {hasInvalidCustom && ( +

+ Could not parse that duration — try a value like 15m, 2h,{" "} + 3d, or 1w. +

+ )} +
+ +
+ Equivalent command:{" "} + {equivalentCommand} +
+ + {error && ( +
{error}
+ )} + +
+ + {isCurrentlySnoozed && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/browser/components/WorkspaceSnoozeModal/index.ts b/src/browser/components/WorkspaceSnoozeModal/index.ts new file mode 100644 index 0000000000..f04227e674 --- /dev/null +++ b/src/browser/components/WorkspaceSnoozeModal/index.ts @@ -0,0 +1 @@ +export { WorkspaceSnoozeModal } from "./WorkspaceSnoozeModal"; diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 2dd4f247d6..3983c967ce 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -165,6 +165,7 @@ function createWorkspaceContextValue(): WorkspaceContextValue { preflightArchiveWorkspace: () => Promise.resolve({ success: true }), archiveWorkspace: () => Promise.resolve({ success: true }), unarchiveWorkspace: () => Promise.resolve({ success: true }), + snoozeWorkspace: () => Promise.resolve({ success: true }), refreshWorkspaceMetadata: () => Promise.resolve(), setWorkspaceMetadata: () => undefined, selectedWorkspace: null, diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index ac82319ce0..27b8b6447c 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -457,6 +457,17 @@ export interface WorkspaceContext extends WorkspaceMetadataContextValue { options?: { acknowledgedUntrackedPaths?: string[] } ) => Promise<{ success: boolean; error?: string; data?: ArchiveWorkspaceResult }>; unarchiveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; + /** + * Snooze (or unsnooze) a workspace. + * + * `snoozedUntil` is an ISO 8601 timestamp in the future. Passing `null` + * clears the snooze. The backend persists the absolute deadline so the + * sidebar's "Snoozed" section drains naturally without a backend timer. + */ + snoozeWorkspace: ( + workspaceId: string, + snoozedUntil: string | null + ) => Promise<{ success: boolean; error?: string }>; refreshWorkspaceMetadata: () => Promise; setWorkspaceMetadata: React.Dispatch< React.SetStateAction> @@ -1519,6 +1530,30 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [api] ); + const snoozeWorkspace = useCallback( + async ( + workspaceId: string, + snoozedUntil: string | null + ): Promise<{ success: boolean; error?: string }> => { + if (!api) return { success: false, error: "API not connected" }; + try { + // Rely on the backend metadata subscription to refresh the sidebar + // partition; this matches archive/unarchive's "fire-and-forget" style. + const result = await api.workspace.snooze({ workspaceId, snoozedUntil }); + if (result.success) { + return { success: true }; + } + console.error("Failed to snooze workspace:", result.error); + return { success: false, error: result.error }; + } catch (error) { + const errorMessage = getErrorMessage(error); + console.error("Failed to snooze workspace:", errorMessage); + return { success: false, error: errorMessage }; + } + }, + [api] + ); + const refreshWorkspaceMetadata = useCallback(async () => { await loadWorkspaceMetadata(); }, [loadWorkspaceMetadata]); @@ -1815,6 +1850,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { preflightArchiveWorkspace, archiveWorkspace, unarchiveWorkspace, + snoozeWorkspace, refreshWorkspaceMetadata, setWorkspaceMetadata, selectedWorkspace, @@ -1839,6 +1875,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { preflightArchiveWorkspace, archiveWorkspace, unarchiveWorkspace, + snoozeWorkspace, refreshWorkspaceMetadata, setWorkspaceMetadata, selectedWorkspace, diff --git a/src/browser/features/ChatInput/placeholderTips.ts b/src/browser/features/ChatInput/placeholderTips.ts index fb3e14a70c..0da5076943 100644 --- a/src/browser/features/ChatInput/placeholderTips.ts +++ b/src/browser/features/ChatInput/placeholderTips.ts @@ -48,6 +48,7 @@ export const PLACEHOLDER_TIPS: readonly string[] = [ "Try /fork to branch this chat into a new workspace", "Try /plan to view or edit the current plan inline", "Try /clear --soft to reset context while keeping the chat visible", + "Try /snooze 1d to hide this chat under the Snoozed section until tomorrow", "Try /new to start a fresh workspace from the trunk branch", "Try /vim to toggle vim keybindings in the chat input", ]; diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index 490d4485f3..2f50729dd2 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -23,6 +23,7 @@ const KEYBIND_LABELS: Record = { EDIT_WORKSPACE_TITLE: "Edit workspace title", GENERATE_WORKSPACE_TITLE: "Generate new title", ARCHIVE_WORKSPACE: "Archive workspace", + SNOOZE_WORKSPACE: "Snooze workspace", JUMP_TO_BOTTOM: "Jump to bottom", LOAD_OLDER_MESSAGES: "Load older messages", NEXT_WORKSPACE: "Next workspace", @@ -129,6 +130,7 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array "EDIT_WORKSPACE_TITLE", "GENERATE_WORKSPACE_TITLE", "ARCHIVE_WORKSPACE", + "SNOOZE_WORKSPACE", "NEXT_WORKSPACE", "PREV_WORKSPACE", "NAVIGATE_BACK", diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index ad93701f29..d42c1b932d 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -468,6 +468,63 @@ export async function processSlashCommand( } } + if (parsed.type === "snooze-set") { + const activeClient = requireClient(); + if (!activeClient) { + return { clearInput: false, toastShown: true }; + } + + if (!context.workspaceId) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "No workspace selected", + }); + return { clearInput: false, toastShown: true }; + } + + setInput(""); + + try { + // Convert duration→deadline at command time so the persisted ISO + // timestamp can drive the sidebar's snooze partition uniformly across + // restarts (server compute the same `Date.now() + duration` clock). + const snoozedUntil = + parsed.durationMs == null ? null : new Date(Date.now() + parsed.durationMs).toISOString(); + const result = await activeClient.workspace.snooze({ + workspaceId: context.workspaceId, + snoozedUntil, + }); + + if (!result.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: result.error ?? "Failed to update snooze", + }); + return { clearInput: false, toastShown: true }; + } + + trackCommandUsed("snooze"); + setToast({ + id: Date.now().toString(), + type: "success", + message: + parsed.durationMs == null + ? "Snooze cleared" + : "Workspace snoozed — find it under the Snoozed section", + }); + return { clearInput: true, toastShown: true }; + } catch (error) { + setToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to update snooze", + }); + return { clearInput: false, toastShown: true }; + } + } + if (parsed.type === "vim-toggle") { setInput(""); setVimEnabled((prev) => !prev); diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index f8b9b15d97..d61389b3a7 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -20,6 +20,7 @@ import { normalizeModelInput } from "@/browser/utils/models/normalizeModelInput" import { parseGoalBudgetInputCents } from "@/common/utils/goals/budgetParser"; import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; import { WORKSPACE_ONLY_COMMAND_KEYS } from "@/constants/slashCommands"; +import { parseHumanDurationMs } from "@/common/utils/snooze"; function tokenizeCommandLine(input: string): string[] { return (input.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((token) => @@ -432,6 +433,51 @@ const heartbeatCommandDefinition: SlashCommandDefinition = { }, }; +const SNOOZE_USAGE = `/snooze ${SLASH_COMMAND_HINTS.snooze}`; + +const snoozeCommandDefinition: SlashCommandDefinition = { + key: "snooze", + description: `Hide this chat under the Snoozed sidebar section for a duration. Usage: ${SNOOZE_USAGE}`, + inputHint: SLASH_COMMAND_HINTS.snooze, + appendSpace: false, + handler: ({ cleanRemainingTokens }): ParsedCommand => { + if (cleanRemainingTokens.length === 0) { + return { + type: "command-missing-args", + command: "snooze", + usage: SNOOZE_USAGE, + }; + } + + const input = cleanRemainingTokens.join(" "); + if (cleanRemainingTokens.length !== 1) { + return { + type: "command-invalid-args", + command: "snooze", + input, + usage: SNOOZE_USAGE, + }; + } + + const arg = cleanRemainingTokens[0].toLowerCase(); + if (arg === "off" || arg === "disable" || arg === "0") { + return { type: "snooze-set", durationMs: null }; + } + + const durationMs = parseHumanDurationMs(arg); + if (durationMs == null) { + return { + type: "command-invalid-args", + command: "snooze", + input, + usage: SNOOZE_USAGE, + }; + } + + return { type: "snooze-set", durationMs }; + }, +}; + /** * Slash-command-shaped wrapper around the canonical * `parseGoalBudgetInputCents` parser. Returns `null` for both "no budget" @@ -675,6 +721,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ vimCommandDefinition, idleCommandDefinition, heartbeatCommandDefinition, + snoozeCommandDefinition, goalCommandDefinition, btwCommandDefinition, debugLlmRequestCommandDefinition, diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index 0b38a4e0c0..6302b6ccdc 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -38,6 +38,7 @@ export type ParsedCommand = | { type: "command-invalid-args"; command: string; input: string; usage: string } | { type: "idle-compaction"; hours: number | null } | { type: "heartbeat-set"; minutes: number | null } + | { type: "snooze-set"; durationMs: number | null } | { type: "goal-show" } | { type: "goal-set"; objective: string; budgetCents?: number | null; turnCap?: number | null } | { type: "goal-budget"; budgetCents: number | null } diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index a59a2ddc6b..cd82b7aa2b 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -374,6 +374,11 @@ export const KEYBINDS = { // macOS: Cmd+Shift+H, Win/Linux: Ctrl+Shift+H CONFIGURE_HEARTBEAT: { key: "H", ctrl: true, shift: true }, + /** Open snooze modal for current workspace */ + // macOS: Cmd+Shift+Z, Win/Linux: Ctrl+Shift+Z + // "Z" mnemonic for Zzz / sleep — no existing collision. + SNOOZE_WORKSPACE: { key: "Z", ctrl: true, shift: true }, + /** Open Command Palette */ // VS Code-style palette // macOS: Cmd+Shift+P, Win/Linux: Ctrl+Shift+P diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 5983a49781..4cc7d1fa1e 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "@jest/globals"; import { partitionWorkspacesByAge, + partitionWorkspacesBySnooze, formatDaysThreshold, AGE_THRESHOLDS_DAYS, buildSortedWorkspacesByProject, @@ -958,3 +959,40 @@ describe("sub-agent row render metadata", () => { expect(expandedMeta.get("reported-2")?.connectorPosition).toBe("last"); }); }); + +describe("partitionWorkspacesBySnooze", () => { + const NOW = 1_700_000_000_000; + const future = (offsetMs: number): string => new Date(NOW + offsetMs).toISOString(); + const past = (offsetMs: number): string => new Date(NOW - offsetMs).toISOString(); + + it("separates currently-snoozed workspaces while leaving expired snoozes active", () => { + const active = createWorkspace("active"); + const snoozed: FrontendWorkspaceMetadata = { + ...createWorkspace("snoozed"), + snoozedUntil: future(60 * 60_000), + }; + const expired: FrontendWorkspaceMetadata = { + ...createWorkspace("expired"), + // A past deadline should auto-drain back into the active list without + // requiring a backend rewrite. + snoozedUntil: past(60 * 60_000), + }; + + const result = partitionWorkspacesBySnooze([active, snoozed, expired], NOW); + expect(result.snoozed.map((w) => w.id)).toEqual(["snoozed"]); + expect(result.active.map((w) => w.id)).toEqual(["active", "expired"]); + }); + + it("inherits the parent's snooze state so sub-agents follow their parent", () => { + const parent: FrontendWorkspaceMetadata = { + ...createWorkspace("parent"), + snoozedUntil: future(60 * 60_000), + }; + const child = createWorkspace("child", { parentWorkspaceId: "parent" }); + const standalone = createWorkspace("standalone"); + + const result = partitionWorkspacesBySnooze([parent, child, standalone], NOW); + expect(result.snoozed.map((w) => w.id).sort()).toEqual(["child", "parent"]); + expect(result.active.map((w) => w.id)).toEqual(["standalone"]); + }); +}); diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index c0c404db38..ba528374fe 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -2,6 +2,7 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/common/types/project"; import { hasCompletedAgentReport } from "@/common/utils/agentTaskCompletion"; import { assert } from "@/common/utils/assert"; +import { isWorkspaceSnoozed } from "@/common/utils/snooze"; interface WorkspaceGroupConfig { id: string; @@ -425,6 +426,80 @@ interface AgePartitionResult { buckets: FrontendWorkspaceMetadata[][]; } +/** + * Result of splitting workspaces into "active" (visible in age tiers) and + * "snoozed" (collected into the dedicated 💤 Snoozed sidebar section). + * + * Parent/child hierarchy is preserved: a sub-agent inherits the snooze state + * of its parent so descendants don't flicker into the active list while their + * parent is hidden. This keeps the existing parent-tier inheritance contract + * intact and avoids dangling orphans across the partition boundary. + */ +export interface SnoozePartitionResult { + active: FrontendWorkspaceMetadata[]; + snoozed: FrontendWorkspaceMetadata[]; +} + +/** + * Split workspaces into active vs snoozed buckets, with descendants inheriting + * their parent's snooze state. + * + * Defined as a separate partitioner (instead of folding into + * `partitionWorkspacesByAge`) so the existing age contract stays stable and + * tests around recency/age inheritance don't churn. Call site walks: + * 1. `partitionWorkspacesBySnooze(list)` → `{ active, snoozed }` + * 2. `partitionWorkspacesByAge(active, recency)` → existing age tiers + * 3. Render a sibling 💤 Snoozed section using `snoozed`. + */ +export function partitionWorkspacesBySnooze( + workspaces: FrontendWorkspaceMetadata[], + nowMs?: number +): SnoozePartitionResult { + if (workspaces.length === 0) { + return { active: [], snoozed: [] }; + } + + const byId = new Map(workspaces.map((workspace) => [workspace.id, workspace] as const)); + const snoozedById = new Map(); + const visiting = new Set(); + + const resolveSnoozed = (workspace: FrontendWorkspaceMetadata): boolean => { + const cached = snoozedById.get(workspace.id); + if (cached !== undefined) return cached; + + if (visiting.has(workspace.id)) { + // Defensive cycle handling: fall back to direct snooze check. + const fallback = isWorkspaceSnoozed(workspace.snoozedUntil, nowMs); + snoozedById.set(workspace.id, fallback); + return fallback; + } + visiting.add(workspace.id); + + let result = isWorkspaceSnoozed(workspace.snoozedUntil, nowMs); + if (!result && workspace.parentWorkspaceId) { + const parent = byId.get(workspace.parentWorkspaceId); + if (parent) { + result = resolveSnoozed(parent); + } + } + + visiting.delete(workspace.id); + snoozedById.set(workspace.id, result); + return result; + }; + + const active: FrontendWorkspaceMetadata[] = []; + const snoozed: FrontendWorkspaceMetadata[] = []; + for (const workspace of workspaces) { + if (resolveSnoozed(workspace)) { + snoozed.push(workspace); + } else { + active.push(workspace); + } + } + return { active, snoozed }; +} + /** * Build the storage key for a tier's expanded state. */ diff --git a/src/common/constants/slashCommandHints.ts b/src/common/constants/slashCommandHints.ts index 9c1643432a..d0da732913 100644 --- a/src/common/constants/slashCommandHints.ts +++ b/src/common/constants/slashCommandHints.ts @@ -10,6 +10,7 @@ export const SLASH_COMMAND_HINTS = { new: "[start message]", idle: "|off", heartbeat: "|off", + snooze: "|off", goal: "[-b ] [--turns ] |budget |clear", btw: "", } as const satisfies Readonly>; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 77068e7f87..4002bd5ba1 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1099,6 +1099,18 @@ export const workspace = { input: z.object({ workspaceId: z.string() }), output: ResultSchema(z.void(), z.string()), }, + /** + * Snooze (or unsnooze) a workspace. `snoozedUntil` is an ISO 8601 timestamp + * in the future; passing `null` clears the snooze. The frontend uses the + * single mutating route since the value carries the intent. + */ + snooze: { + input: z.object({ + workspaceId: z.string(), + snoozedUntil: z.string().nullable(), + }), + output: ResultSchema(z.void(), z.string()), + }, deleteWorktree: { input: z.object({ workspaceId: z.string() }), output: ResultSchema(z.void(), z.string()), diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index aeb0163a58..4d76da9de2 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -46,6 +46,7 @@ const TelemetryCommandTypeSchema = z.enum([ "providers", "goal", "btw", + "snooze", ]); // Individual event payload schemas diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 03e49a5da9..f066a68fba 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -186,6 +186,10 @@ export const WorkspaceMetadataSchema = z.object({ description: "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", }), + snoozedUntil: z.string().optional().meta({ + description: + "ISO 8601 timestamp until which this workspace is snoozed. Workspaces are considered snoozed (hidden under the sidebar Snooze section) while this timestamp lies in the future; once it passes the field is treated as cleared without requiring backend rewrite.", + }), projects: z .array(ProjectRefSchema) .optional() diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 3288e95986..8639e465eb 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -166,6 +166,10 @@ export const WorkspaceConfigSchema = z.object({ description: "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", }), + snoozedUntil: z.string().optional().meta({ + description: + "ISO 8601 timestamp until which this workspace is snoozed. Workspaces are considered snoozed (hidden under the sidebar Snooze section) while this timestamp lies in the future; once it passes the field is treated as cleared without requiring backend rewrite.", + }), worktreeArchiveSnapshot: WorktreeArchiveSnapshotSchema.optional().meta({ description: "Durable restore metadata captured before archive-time worktree deletion. Present only while an archived snapshot is awaiting restore.", diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index 7868afcb87..00313d9bca 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -300,7 +300,8 @@ export type TelemetryCommandType = | "plan" | "providers" | "goal" - | "btw"; + | "btw" + | "snooze"; /** * Command usage event - tracks slash command usage patterns diff --git a/src/common/utils/snooze.test.ts b/src/common/utils/snooze.test.ts new file mode 100644 index 0000000000..4463cf0f5c --- /dev/null +++ b/src/common/utils/snooze.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { + formatDurationShort, + isWorkspaceSnoozed, + MAX_SNOOZE_MS, + parseHumanDurationMs, +} from "./snooze"; + +describe("parseHumanDurationMs", () => { + it("parses minutes, hours, days, and weeks", () => { + expect(parseHumanDurationMs("15m")).toBe(15 * 60_000); + expect(parseHumanDurationMs("2h")).toBe(2 * 60 * 60_000); + expect(parseHumanDurationMs("3d")).toBe(3 * 24 * 60 * 60_000); + expect(parseHumanDurationMs("1w")).toBe(7 * 24 * 60 * 60_000); + }); + + it("is case-insensitive and tolerant of whitespace", () => { + expect(parseHumanDurationMs(" 2H ")).toBe(2 * 60 * 60_000); + expect(parseHumanDurationMs("2 h")).toBe(2 * 60 * 60_000); + }); + + it("rejects zero, negative, decimal, and unknown units", () => { + // Zero would mean "snooze for no time" — not useful and would round-trip to + // an unsnooze, so we force callers to use the explicit `off` keyword. + expect(parseHumanDurationMs("0h")).toBeNull(); + expect(parseHumanDurationMs("-2h")).toBeNull(); + expect(parseHumanDurationMs("1.5h")).toBeNull(); + expect(parseHumanDurationMs("3y")).toBeNull(); + expect(parseHumanDurationMs("")).toBeNull(); + expect(parseHumanDurationMs("abc")).toBeNull(); + }); +}); + +describe("formatDurationShort", () => { + it("picks the largest dividing unit", () => { + expect(formatDurationShort(15 * 60_000)).toBe("15m"); + expect(formatDurationShort(60 * 60_000)).toBe("1h"); + expect(formatDurationShort(2 * 24 * 60 * 60_000)).toBe("2d"); + expect(formatDurationShort(2 * 7 * 24 * 60 * 60_000)).toBe("2w"); + }); + + it("falls back to minutes for non-clean values", () => { + expect(formatDurationShort(90 * 60_000)).toBe("90m"); + }); + + it("returns 0m for nonpositive or non-finite input", () => { + expect(formatDurationShort(0)).toBe("0m"); + expect(formatDurationShort(-1)).toBe("0m"); + expect(formatDurationShort(Number.NaN)).toBe("0m"); + }); +}); + +describe("isWorkspaceSnoozed", () => { + const NOW = 1_700_000_000_000; + + it("returns true while the deadline is in the future", () => { + const future = new Date(NOW + 60 * 60_000).toISOString(); + expect(isWorkspaceSnoozed(future, NOW)).toBe(true); + }); + + it("returns false when the deadline has already passed (auto-drains the section)", () => { + const past = new Date(NOW - 1).toISOString(); + expect(isWorkspaceSnoozed(past, NOW)).toBe(false); + }); + + it("handles missing or malformed timestamps defensively", () => { + expect(isWorkspaceSnoozed(undefined, NOW)).toBe(false); + expect(isWorkspaceSnoozed("not-a-date", NOW)).toBe(false); + }); +}); + +describe("MAX_SNOOZE_MS", () => { + it("matches 52 weeks", () => { + expect(MAX_SNOOZE_MS).toBe(52 * 7 * 24 * 60 * 60_000); + }); +}); diff --git a/src/common/utils/snooze.ts b/src/common/utils/snooze.ts new file mode 100644 index 0000000000..92a9c02629 --- /dev/null +++ b/src/common/utils/snooze.ts @@ -0,0 +1,82 @@ +/** + * Snooze utilities. + * + * A workspace is "snoozed" while its persisted `snoozedUntil` ISO timestamp is + * still in the future. The sidebar hides snoozed workspaces under a dedicated + * 💤 Snoozed section until that timestamp passes — at which point the section + * drains naturally on the next render without requiring backend rewrites. + * + * Keeping the derivation here (not in the persisted field) mirrors how + * `isWorkspaceArchived` derives archived state from `archivedAt`/`unarchivedAt` + * and keeps schema migrations unnecessary for stale snoozes. + */ + +/** + * Parse a human duration like `15m`, `2h`, `3d`, or `1w` into milliseconds. + * + * Returns `null` for any unparseable input. Trims whitespace and is + * case-insensitive in the unit suffix so `/snooze 2H` and `/snooze 2h` both + * land on the same hour bucket. + * + * Restricting the supported units (no months/years, no fractional values) + * keeps the slash-command parser and modal preset list in lockstep. + */ +export function parseHumanDurationMs(input: string): number | null { + if (typeof input !== "string") return null; + const match = /^\s*(\d+)\s*([mhdw])\s*$/i.exec(input); + if (!match) return null; + const amount = Number(match[1]); + if (!Number.isSafeInteger(amount) || amount <= 0) return null; + const unit = match[2].toLowerCase(); + const MINUTE_MS = 60_000; + switch (unit) { + case "m": + return amount * MINUTE_MS; + case "h": + return amount * 60 * MINUTE_MS; + case "d": + return amount * 24 * 60 * MINUTE_MS; + case "w": + return amount * 7 * 24 * 60 * MINUTE_MS; + default: + return null; + } +} + +/** + * Format a millisecond delta as a short canonical duration suitable for the + * slash command (e.g. `15m`, `2h`, `1d`, `1w`). Picks the largest unit that + * divides cleanly so the modal can echo the equivalent `/snooze ` command. + */ +export function formatDurationShort(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return "0m"; + const MINUTE_MS = 60_000; + const minutes = Math.round(ms / MINUTE_MS); + if (minutes <= 0) return "0m"; + const WEEK_MIN = 7 * 24 * 60; + const DAY_MIN = 24 * 60; + const HOUR_MIN = 60; + if (minutes % WEEK_MIN === 0) return `${minutes / WEEK_MIN}w`; + if (minutes % DAY_MIN === 0) return `${minutes / DAY_MIN}d`; + if (minutes % HOUR_MIN === 0) return `${minutes / HOUR_MIN}h`; + return `${minutes}m`; +} + +/** + * Determine if a workspace is currently snoozed. Stale timestamps (already + * passed) intentionally return false so the sidebar auto-drains. + */ +export function isWorkspaceSnoozed(snoozedUntil: string | undefined, nowMs?: number): boolean { + if (!snoozedUntil) return false; + const deadlineMs = Date.parse(snoozedUntil); + if (!Number.isFinite(deadlineMs)) return false; + const now = nowMs ?? Date.now(); + return deadlineMs > now; +} + +/** + * Maximum supported snooze horizon. We refuse to set snoozes farther out than + * this so the section can't act as a soft-archive replacement (use `/archive` + * for permanent hiding). + */ +export const MAX_SNOOZE_MS = 52 * 7 * 24 * 60 * 60 * 1000; // 52 weeks diff --git a/src/constants/slashCommands.ts b/src/constants/slashCommands.ts index c21b8363e4..8825b53d46 100644 --- a/src/constants/slashCommands.ts +++ b/src/constants/slashCommands.ts @@ -13,6 +13,7 @@ export const WORKSPACE_ONLY_COMMAND_KEYS: ReadonlySet = new Set([ "new", "plan", "heartbeat", + "snooze", "btw", ]); @@ -27,6 +28,7 @@ export const WORKSPACE_ONLY_COMMAND_TYPES: ReadonlySet = new Set([ "plan-show", "plan-open", "heartbeat-set", + "snooze-set", "goal-show", "goal-set", "goal-budget", diff --git a/src/node/config.ts b/src/node/config.ts index 918c172bb6..5824560577 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1527,6 +1527,7 @@ export class Config { taskTrunkBranch: workspace.taskTrunkBranch, archivedAt: workspace.archivedAt, unarchivedAt: workspace.unarchivedAt, + snoozedUntil: workspace.snoozedUntil, projects: workspaceProjects, subProjectPath: workspace.subProjectPath, }; @@ -1626,6 +1627,7 @@ export class Config { // Preserve archived timestamps from config metadata.archivedAt ??= workspace.archivedAt; metadata.unarchivedAt ??= workspace.unarchivedAt; + metadata.snoozedUntil ??= workspace.snoozedUntil; // Preserve sub-project assignment from config. metadata.subProjectPath ??= workspace.subProjectPath; metadata.forkFamilyBaseName ??= workspace.forkFamilyBaseName; @@ -1694,6 +1696,7 @@ export class Config { taskTrunkBranch: workspace.taskTrunkBranch, archivedAt: workspace.archivedAt, unarchivedAt: workspace.unarchivedAt, + snoozedUntil: workspace.snoozedUntil, projects: workspaceProjects, subProjectPath: workspace.subProjectPath, }; @@ -1814,6 +1817,7 @@ export class Config { taskTrunkBranch: metadata.taskTrunkBranch, archivedAt: metadata.archivedAt, unarchivedAt: metadata.unarchivedAt, + snoozedUntil: metadata.snoozedUntil, projects: metadata.projects, subProjectPath: metadata.subProjectPath, }; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c32aed23ab..6977b373ba 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3343,6 +3343,12 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.workspaceService.unarchive(input.workspaceId); }), + snooze: t + .input(schemas.workspace.snooze.input) + .output(schemas.workspace.snooze.output) + .handler(async ({ context, input }) => { + return context.workspaceService.setSnooze(input.workspaceId, input.snoozedUntil); + }), deleteWorktree: t .input(schemas.workspace.deleteWorktree.input) .output(schemas.workspace.deleteWorktree.output) diff --git a/src/node/services/workspaceService.snooze.test.ts b/src/node/services/workspaceService.snooze.test.ts new file mode 100644 index 0000000000..3008d02313 --- /dev/null +++ b/src/node/services/workspaceService.snooze.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { EventEmitter } from "events"; +import type { ProjectConfig, ProjectsConfig, Workspace } from "@/common/types/project"; +import type { Config } from "@/node/config"; +import type { AIService } from "./aiService"; +import type { BackgroundProcessManager } from "./backgroundProcessManager"; +import type { ExtensionMetadataService } from "./ExtensionMetadataService"; +import type { HistoryService } from "./historyService"; +import type { InitStateManager } from "./initStateManager"; +import { WorkspaceService } from "./workspaceService"; + +const TEST_WORKSPACE_ID = "test-ws"; +const TEST_WORKSPACE_PATH = "/test/path"; +const TEST_PROJECT_PATH = "/test/project"; + +function createProjectsConfig(workspace: Workspace): ProjectsConfig { + const projectConfig: ProjectConfig = { + workspaces: [workspace], + }; + return { + projects: new Map([[TEST_PROJECT_PATH, projectConfig]]), + }; +} + +function createWorkspace(snoozedUntil?: string): Workspace { + return { + id: TEST_WORKSPACE_ID, + path: TEST_WORKSPACE_PATH, + name: "test", + snoozedUntil, + } as unknown as Workspace; +} + +describe("WorkspaceService.setSnooze", () => { + let currentProjectsConfig: ProjectsConfig; + let mockConfig: Config; + let service: WorkspaceService; + + beforeEach(() => { + currentProjectsConfig = createProjectsConfig(createWorkspace()); + + mockConfig = { + loadConfigOrDefault: mock(() => currentProjectsConfig), + findWorkspace: mock(() => ({ + workspacePath: TEST_WORKSPACE_PATH, + projectPath: TEST_PROJECT_PATH, + })), + // setSnooze uses editConfig (matches archive), so we mimic that hook. + editConfig: mock( + async (mutate: (config: ProjectsConfig) => ProjectsConfig | undefined): Promise => { + const next = mutate(currentProjectsConfig); + if (next) currentProjectsConfig = next; + } + ), + saveConfig: mock((nextConfig: ProjectsConfig) => { + currentProjectsConfig = nextConfig; + return Promise.resolve(); + }), + } as unknown as Config; + + service = new WorkspaceService( + mockConfig, + {} as HistoryService, + new EventEmitter() as unknown as AIService, + new EventEmitter() as unknown as InitStateManager, + { + updateRecency: mock(() => + Promise.resolve({ + recency: Date.now(), + streaming: false, + lastModel: null, + lastThinkingLevel: null, + agentStatus: null, + }) + ), + } as unknown as ExtensionMetadataService, + {} as BackgroundProcessManager + ); + ( + service as unknown as { emitCurrentWorkspaceMetadata: () => Promise } + ).emitCurrentWorkspaceMetadata = mock(() => Promise.resolve()); + }); + + afterEach(() => { + mock.restore(); + }); + + test("persists snoozedUntil when given a future ISO timestamp", async () => { + const future = new Date(Date.now() + 60 * 60_000).toISOString(); + const result = await service.setSnooze(TEST_WORKSPACE_ID, future); + + expect(result.success).toBe(true); + const persisted = currentProjectsConfig.projects.get(TEST_PROJECT_PATH)?.workspaces.at(0); + expect(persisted?.snoozedUntil).toBe(future); + }); + + test("clears snoozedUntil when called with null", async () => { + currentProjectsConfig = createProjectsConfig( + createWorkspace(new Date(Date.now() + 60 * 60_000).toISOString()) + ); + + const result = await service.setSnooze(TEST_WORKSPACE_ID, null); + + expect(result.success).toBe(true); + const persisted = currentProjectsConfig.projects.get(TEST_PROJECT_PATH)?.workspaces.at(0); + expect(persisted?.snoozedUntil).toBeUndefined(); + }); + + test("normalizes a past timestamp into an explicit unsnooze so the persisted state stays clean", async () => { + currentProjectsConfig = createProjectsConfig( + createWorkspace(new Date(Date.now() + 60 * 60_000).toISOString()) + ); + const past = new Date(Date.now() - 1000).toISOString(); + + const result = await service.setSnooze(TEST_WORKSPACE_ID, past); + + expect(result.success).toBe(true); + const persisted = currentProjectsConfig.projects.get(TEST_PROJECT_PATH)?.workspaces.at(0); + expect(persisted?.snoozedUntil).toBeUndefined(); + }); + + test("rejects malformed ISO timestamps", async () => { + const result = await service.setSnooze(TEST_WORKSPACE_ID, "not-a-date"); + expect(result.success).toBe(false); + }); + + test("rejects snooze deadlines beyond the maximum horizon", async () => { + // 53 weeks > MAX_SNOOZE_MS (52 weeks); should be refused so snooze can't + // act as a soft-archive replacement. + const tooFar = new Date(Date.now() + 53 * 7 * 24 * 60 * 60_000).toISOString(); + const result = await service.setSnooze(TEST_WORKSPACE_ID, tooFar); + expect(result.success).toBe(false); + }); +}); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5170a5001e..6bb06dd396 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -5,6 +5,7 @@ import assert from "@/common/utils/assert"; import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; import type { WorktreeArchiveSnapshot } from "@/common/schemas/project"; import { isWorkspaceArchived } from "@/common/utils/archive"; +import { MAX_SNOOZE_MS } from "@/common/utils/snooze"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { Config } from "@/node/config"; import type { Result } from "@/common/types/result"; @@ -4347,6 +4348,75 @@ export class WorkspaceService extends EventEmitter { } } + /** + * Snooze (or unsnooze) a workspace by setting its `snoozedUntil` ISO + * timestamp. Snoozed workspaces are hidden under a dedicated "Snoozed" + * section in the sidebar until the timestamp passes; passing `null` clears + * the field entirely. + * + * We persist the absolute deadline (not a duration) so the section drains + * naturally at the wall-clock expiry without needing a backend timer or + * scheduled job. The metadata-driven UI uses `isWorkspaceSnoozed` to derive + * the live state, mirroring how `isWorkspaceArchived` derives the archived + * state from `archivedAt`/`unarchivedAt`. + */ + async setSnooze(workspaceId: string, snoozedUntil: string | null): Promise> { + try { + const normalizedWorkspaceId = workspaceId.trim(); + assert(normalizedWorkspaceId.length > 0, "setSnooze requires a non-empty workspaceId"); + + let normalizedSnoozedUntil: string | undefined; + if (snoozedUntil != null) { + const parsed = Date.parse(snoozedUntil); + if (!Number.isFinite(parsed)) { + return Err("Snooze timestamp must be a valid ISO 8601 string"); + } + const now = Date.now(); + if (parsed <= now) { + // Setting a deadline in the past would render the workspace as + // not-snoozed anyway; treat that as an explicit unsnooze so the + // persisted state stays clean. + normalizedSnoozedUntil = undefined; + } else if (parsed - now > MAX_SNOOZE_MS) { + return Err("Snooze duration exceeds the maximum supported horizon (52 weeks)"); + } else { + normalizedSnoozedUntil = new Date(parsed).toISOString(); + } + } + + const found = this.config.findWorkspace(normalizedWorkspaceId); + if (!found) { + return Err("Workspace not found"); + } + + const { projectPath, workspacePath } = found; + + await this.config.editConfig((config) => { + const projectConfig = config.projects.get(projectPath); + if (!projectConfig) return config; + const workspaceEntry = + projectConfig.workspaces.find((w) => w.id === normalizedWorkspaceId) ?? + projectConfig.workspaces.find((w) => w.path === workspacePath); + if (!workspaceEntry) return config; + + if (normalizedSnoozedUntil) { + workspaceEntry.snoozedUntil = normalizedSnoozedUntil; + } else { + // Clearing via `delete` rather than writing `null` keeps the + // persisted config minimal and matches how archive/unarchive uses + // the absence of an `archivedAt` field. + delete workspaceEntry.snoozedUntil; + } + return config; + }); + + await this.emitCurrentWorkspaceMetadata(normalizedWorkspaceId); + return Ok(undefined); + } catch (error) { + return Err(`Failed to set snooze: ${getErrorMessage(error)}`); + } + } + /** * Archive a workspace. Archived workspaces are hidden from the main sidebar * but can be viewed on the project page. From 37063b74961bbcfe22c2124269f9f9eb7c01ffe7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 20 May 2026 16:57:30 -0500 Subject: [PATCH 2/8] tests: avoid async mock without await in setSnooze test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`_ --- src/node/services/workspaceService.snooze.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceService.snooze.test.ts b/src/node/services/workspaceService.snooze.test.ts index 3008d02313..692672c00d 100644 --- a/src/node/services/workspaceService.snooze.test.ts +++ b/src/node/services/workspaceService.snooze.test.ts @@ -46,10 +46,13 @@ describe("WorkspaceService.setSnooze", () => { projectPath: TEST_PROJECT_PATH, })), // setSnooze uses editConfig (matches archive), so we mimic that hook. + // Return Promise.resolve() explicitly instead of marking the mock + // `async` — lint flags async mocks without an `await` expression. editConfig: mock( - async (mutate: (config: ProjectsConfig) => ProjectsConfig | undefined): Promise => { + (mutate: (config: ProjectsConfig) => ProjectsConfig | undefined): Promise => { const next = mutate(currentProjectsConfig); if (next) currentProjectsConfig = next; + return Promise.resolve(); } ), saveConfig: mock((nextConfig: ProjectsConfig) => { From e865d3e6297d34c5f7ad66ac8b2587f6f4ea422a Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 20 May 2026 17:10:49 -0500 Subject: [PATCH 3/8] fix: avoid macOS Redo collision and clamp snooze durations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`_ --- .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 13 +++- .../WorkspaceSnoozeModal.tsx | 62 +++++++++++++++---- src/browser/utils/chatCommands.ts | 8 ++- src/browser/utils/slashCommands/registry.ts | 7 ++- src/browser/utils/ui/keybinds.ts | 8 ++- 5 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 15f13e61eb..c0e767a409 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -20,7 +20,12 @@ import { WorkspaceMCPModal } from "../WorkspaceMCPModal/WorkspaceMCPModal"; import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; import { Popover, PopoverTrigger, PopoverContent } from "../Popover/Popover"; import { Checkbox } from "../Checkbox/Checkbox"; -import { formatKeybind, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import { + formatKeybind, + isEditableElement, + KEYBINDS, + matchesKeybind, +} from "@/browser/utils/ui/keybinds"; import { getDevcontainerStatusChip } from "@/browser/utils/runtimeUi"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; import { useRuntimeStatus, useRuntimeStatusStoreRaw } from "@/browser/stores/RuntimeStatusStore"; @@ -443,9 +448,13 @@ export const WorkspaceMenuBar: React.FC = ({ // Keybind for opening the snooze modal. Lives here (not on AgentListItem) // so the shortcut still resolves when the left sidebar is collapsed and // workspace rows are unmounted — same pattern as SHARE_TRANSCRIPT. + // + // Skip when focus is in an editable element so we never preventDefault a + // legitimate editor shortcut (e.g. Cmd/Ctrl+Shift+X = cut in some editors) + // and the user can still type the key normally inside inputs. useEffect(() => { const handler = (e: KeyboardEvent) => { - if (matchesKeybind(e, KEYBINDS.SNOOZE_WORKSPACE)) { + if (matchesKeybind(e, KEYBINDS.SNOOZE_WORKSPACE) && !isEditableElement(e.target)) { e.preventDefault(); setSnoozeModalOpen(true); } diff --git a/src/browser/components/WorkspaceSnoozeModal/WorkspaceSnoozeModal.tsx b/src/browser/components/WorkspaceSnoozeModal/WorkspaceSnoozeModal.tsx index 8c0a842de1..6aebc8faee 100644 --- a/src/browser/components/WorkspaceSnoozeModal/WorkspaceSnoozeModal.tsx +++ b/src/browser/components/WorkspaceSnoozeModal/WorkspaceSnoozeModal.tsx @@ -14,6 +14,7 @@ import { cn } from "@/common/lib/utils"; import { formatDurationShort, isWorkspaceSnoozed, + MAX_SNOOZE_MS, parseHumanDurationMs, } from "@/common/utils/snooze"; @@ -75,7 +76,18 @@ export function WorkspaceSnoozeModal(props: WorkspaceSnoozeModalProps) { // Derive the active duration token (preset or custom). When the custom // field has content we prefer it so the live `/snooze ` hint reflects // what the user is actually about to submit. - const customDurationMs = customDuration.trim() ? parseHumanDurationMs(customDuration) : null; + // + // Durations above MAX_SNOOZE_MS are treated as invalid in-modal so they + // never reach `new Date(Date.now() + ms).toISOString()`. Very large inputs + // can produce an invalid Date and a RangeError, which would bypass the + // normal error UI and strand the modal in an `isSaving` state. The backend + // also enforces the same cap as a defense-in-depth check. + const rawCustomDurationMs = customDuration.trim() ? parseHumanDurationMs(customDuration) : null; + const customDurationMs = + rawCustomDurationMs != null && rawCustomDurationMs <= MAX_SNOOZE_MS + ? rawCustomDurationMs + : null; + const customExceedsMax = rawCustomDurationMs != null && rawCustomDurationMs > MAX_SNOOZE_MS; const effectiveDurationToken = (() => { if (customDuration.trim().length > 0) { return customDurationMs != null ? formatDurationShort(customDurationMs) : null; @@ -90,22 +102,42 @@ export function WorkspaceSnoozeModal(props: WorkspaceSnoozeModalProps) { ? `/snooze ${effectiveDurationToken}` : "/snooze "; const hasInvalidCustom = customDuration.trim().length > 0 && customDurationMs == null; - const canSnooze = !isSaving && !hasInvalidCustom && effectiveDurationMs != null && api != null; + const canSnooze = + !isSaving && + !hasInvalidCustom && + effectiveDurationMs != null && + Number.isFinite(effectiveDurationMs) && + effectiveDurationMs > 0 && + effectiveDurationMs <= MAX_SNOOZE_MS && + api != null; const handleSnooze = async () => { - if (!effectiveDurationMs || !api) { + if ( + effectiveDurationMs == null || + !Number.isFinite(effectiveDurationMs) || + effectiveDurationMs <= 0 || + effectiveDurationMs > MAX_SNOOZE_MS || + !api + ) { return; } setIsSaving(true); setError(null); - const deadline = new Date(Date.now() + effectiveDurationMs).toISOString(); - const result = await snoozeWorkspace(props.workspaceId, deadline); - if (result.success) { - props.onOpenChange(false); - } else { - setError(result.error ?? "Failed to snooze workspace"); + try { + const deadline = new Date(Date.now() + effectiveDurationMs).toISOString(); + const result = await snoozeWorkspace(props.workspaceId, deadline); + if (result.success) { + props.onOpenChange(false); + } else { + setError(result.error ?? "Failed to snooze workspace"); + } + } catch (err) { + // Defense-in-depth: even with the clamp above, surface any toISOString + // failure to the user rather than leaving `isSaving` stuck. + setError(err instanceof Error ? err.message : "Failed to snooze workspace"); + } finally { + setIsSaving(false); } - setIsSaving(false); }; const handleUnsnooze = async () => { @@ -196,8 +228,14 @@ export function WorkspaceSnoozeModal(props: WorkspaceSnoozeModalProps) { /> {hasInvalidCustom && (

- Could not parse that duration — try a value like 15m, 2h,{" "} - 3d, or 1w. + {customExceedsMax ? ( + <>Snooze durations cap out at 52 weeks. Use archive for longer hides. + ) : ( + <> + Could not parse that duration — try a value like 15m,{" "} + 2h, 3d, or 1w. + + )}

)} diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index d42c1b932d..77a848e325 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -489,8 +489,14 @@ export async function processSlashCommand( // Convert duration→deadline at command time so the persisted ISO // timestamp can drive the sidebar's snooze partition uniformly across // restarts (server compute the same `Date.now() + duration` clock). + // + // The slash registry already rejects unparseable + over-MAX_SNOOZE_MS + // values; this redundant guard prevents a RangeError from + // `toISOString()` if a future code path ever hands us a stale value. const snoozedUntil = - parsed.durationMs == null ? null : new Date(Date.now() + parsed.durationMs).toISOString(); + parsed.durationMs == null || !Number.isFinite(parsed.durationMs) + ? null + : new Date(Date.now() + parsed.durationMs).toISOString(); const result = await activeClient.workspace.snooze({ workspaceId: context.workspaceId, snoozedUntil, diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index d61389b3a7..5f836ae6f9 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -20,7 +20,7 @@ import { normalizeModelInput } from "@/browser/utils/models/normalizeModelInput" import { parseGoalBudgetInputCents } from "@/common/utils/goals/budgetParser"; import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; import { WORKSPACE_ONLY_COMMAND_KEYS } from "@/constants/slashCommands"; -import { parseHumanDurationMs } from "@/common/utils/snooze"; +import { MAX_SNOOZE_MS, parseHumanDurationMs } from "@/common/utils/snooze"; function tokenizeCommandLine(input: string): string[] { return (input.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((token) => @@ -465,7 +465,10 @@ const snoozeCommandDefinition: SlashCommandDefinition = { } const durationMs = parseHumanDurationMs(arg); - if (durationMs == null) { + // Reject unparseable and over-the-horizon durations here so callers don't + // hand them to `new Date(Date.now() + durationMs).toISOString()` — that + // can RangeError on extreme inputs and bypass our normal toast UI. + if (durationMs == null || durationMs > MAX_SNOOZE_MS) { return { type: "command-invalid-args", command: "snooze", diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index cd82b7aa2b..49a1b3bbe7 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -375,9 +375,11 @@ export const KEYBINDS = { CONFIGURE_HEARTBEAT: { key: "H", ctrl: true, shift: true }, /** Open snooze modal for current workspace */ - // macOS: Cmd+Shift+Z, Win/Linux: Ctrl+Shift+Z - // "Z" mnemonic for Zzz / sleep — no existing collision. - SNOOZE_WORKSPACE: { key: "Z", ctrl: true, shift: true }, + // macOS: Cmd+Shift+X, Win/Linux: Ctrl+Shift+X + // "X" as in "eXclude from the main sidebar until later". The natural "Z = + // Zzz" mnemonic collides with the platform-standard Redo shortcut on macOS + // (Cmd+Shift+Z), and a global preventDefault would hijack Redo in editors. + SNOOZE_WORKSPACE: { key: "X", ctrl: true, shift: true }, /** Open Command Palette */ // VS Code-style palette From 81c8f6603aea09ed3437817936cf545d6550e0f7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 20 May 2026 17:17:17 -0500 Subject: [PATCH 4/8] fix: walk project-wide parent lookup in snooze partition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`_ --- .../ProjectSidebar/ProjectSidebar.tsx | 13 ++++++++- .../utils/ui/workspaceFiltering.test.ts | 23 ++++++++++++++-- src/browser/utils/ui/workspaceFiltering.ts | 27 ++++++++++++++++--- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 188d23a660..16bf31832c 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -2482,6 +2482,15 @@ const ProjectSidebarInner: React.FC = ({ // age tiers. They render as a dedicated 💤 collapsible section // appended after the last age tier, mirroring how the "Older than X" // tiers work but with their own expansion state. + // Pre-build the project-wide id→metadata map once per project + // render so each section's `partitionWorkspacesBySnooze` call can + // walk parents that live in sibling sections / the unsectioned + // bucket — otherwise descendants would silently slip back into the + // active list when their snoozed parent is in a different slice. + const projectParentLookup = new Map( + projectWorkspaces.map((workspace) => [workspace.id, workspace]) + ); + const renderAgeTiers = ( workspaces: FrontendWorkspaceMetadata[], tierKeyPrefix: string, @@ -2489,7 +2498,9 @@ const ProjectSidebarInner: React.FC = ({ allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces ): React.ReactNode => { const { active: activeWorkspaces, snoozed: snoozedWorkspaces } = - partitionWorkspacesBySnooze(workspaces); + partitionWorkspacesBySnooze(workspaces, { + parentLookup: projectParentLookup, + }); const { recent: topVisibleRows, buckets } = partitionWorkspacesByAge(activeWorkspaces, workspaceRecency); const isSnoozeSectionExpanded = diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 4cc7d1fa1e..e7269bb3a4 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -978,7 +978,7 @@ describe("partitionWorkspacesBySnooze", () => { snoozedUntil: past(60 * 60_000), }; - const result = partitionWorkspacesBySnooze([active, snoozed, expired], NOW); + const result = partitionWorkspacesBySnooze([active, snoozed, expired], { nowMs: NOW }); expect(result.snoozed.map((w) => w.id)).toEqual(["snoozed"]); expect(result.active.map((w) => w.id)).toEqual(["active", "expired"]); }); @@ -991,8 +991,27 @@ describe("partitionWorkspacesBySnooze", () => { const child = createWorkspace("child", { parentWorkspaceId: "parent" }); const standalone = createWorkspace("standalone"); - const result = partitionWorkspacesBySnooze([parent, child, standalone], NOW); + const result = partitionWorkspacesBySnooze([parent, child, standalone], { nowMs: NOW }); expect(result.snoozed.map((w) => w.id).sort()).toEqual(["child", "parent"]); expect(result.active.map((w) => w.id)).toEqual(["standalone"]); }); + + it("walks the parentLookup when the parent lives outside the partition slice", () => { + // Reproduces the cross-section case: the section being rendered only + // contains the child, but the snoozed parent lives in another section + // (or the unsectioned bucket). + const parent: FrontendWorkspaceMetadata = { + ...createWorkspace("parent"), + snoozedUntil: future(60 * 60_000), + }; + const child = createWorkspace("child", { parentWorkspaceId: "parent" }); + const parentLookup = new Map([ + [parent.id, parent], + [child.id, child], + ]); + + const result = partitionWorkspacesBySnooze([child], { nowMs: NOW, parentLookup }); + expect(result.snoozed.map((w) => w.id)).toEqual(["child"]); + expect(result.active.map((w) => w.id)).toEqual([]); + }); }); diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index ba528374fe..0809966273 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -447,19 +447,38 @@ export interface SnoozePartitionResult { * Defined as a separate partitioner (instead of folding into * `partitionWorkspacesByAge`) so the existing age contract stays stable and * tests around recency/age inheritance don't churn. Call site walks: - * 1. `partitionWorkspacesBySnooze(list)` → `{ active, snoozed }` + * 1. `partitionWorkspacesBySnooze(list, { parentLookup })` → `{ active, snoozed }` * 2. `partitionWorkspacesByAge(active, recency)` → existing age tiers * 3. Render a sibling 💤 Snoozed section using `snoozed`. + * + * `parentLookup` is an optional project-wide id→metadata map used to walk + * the parent chain. When the caller renders a **subset** of the project's + * workspaces (e.g. a single sub-project section where a child's parent lives + * in a sibling section or in the unsectioned bucket), the section-local + * `byId` would miss that parent and the inheritance silently dropped — so a + * sub-agent could stay visible while its parent was snoozed. Passing the + * full project workspaces here keeps the "descendants follow parent snooze + * state" contract intact regardless of which slice is being rendered. */ export function partitionWorkspacesBySnooze( workspaces: FrontendWorkspaceMetadata[], - nowMs?: number + options?: { + nowMs?: number; + parentLookup?: ReadonlyMap; + } ): SnoozePartitionResult { if (workspaces.length === 0) { return { active: [], snoozed: [] }; } - const byId = new Map(workspaces.map((workspace) => [workspace.id, workspace] as const)); + const nowMs = options?.nowMs; + // Local lookup first (cheap + handles tests that only pass a single list), + // then fall back to the project-wide parentLookup so cross-section parents + // still resolve. + const localById = new Map(workspaces.map((workspace) => [workspace.id, workspace] as const)); + const parentLookup = options?.parentLookup; + const resolveById = (id: string): FrontendWorkspaceMetadata | undefined => + localById.get(id) ?? parentLookup?.get(id); const snoozedById = new Map(); const visiting = new Set(); @@ -477,7 +496,7 @@ export function partitionWorkspacesBySnooze( let result = isWorkspaceSnoozed(workspace.snoozedUntil, nowMs); if (!result && workspace.parentWorkspaceId) { - const parent = byId.get(workspace.parentWorkspaceId); + const parent = resolveById(workspace.parentWorkspaceId); if (parent) { result = resolveSnoozed(parent); } From e49e30395e1c945e3c62b67914fb8d77f0213428 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 21 May 2026 10:16:13 -0500 Subject: [PATCH 5/8] feat: auto-unsnooze workspace when user sends a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`_ --- .../services/workspaceService.snooze.test.ts | 100 ++++++++++++++++++ src/node/services/workspaceService.ts | 49 +++++++++ 2 files changed, 149 insertions(+) diff --git a/src/node/services/workspaceService.snooze.test.ts b/src/node/services/workspaceService.snooze.test.ts index 692672c00d..10f7a8a2d5 100644 --- a/src/node/services/workspaceService.snooze.test.ts +++ b/src/node/services/workspaceService.snooze.test.ts @@ -135,3 +135,103 @@ describe("WorkspaceService.setSnooze", () => { expect(result.success).toBe(false); }); }); + +describe("WorkspaceService.clearSnoozeOnUserMessage", () => { + let currentProjectsConfig: ProjectsConfig; + let mockConfig: Config; + let editConfigMock: ReturnType; + let service: WorkspaceService; + + // Access the private helper without exposing it on the public surface. + type PrivateClear = (workspaceId: string) => Promise; + const callPrivateHelper = (svc: WorkspaceService, workspaceId: string): Promise => { + return (svc as unknown as { clearSnoozeOnUserMessage: PrivateClear }).clearSnoozeOnUserMessage( + workspaceId + ); + }; + + beforeEach(() => { + currentProjectsConfig = createProjectsConfig(createWorkspace()); + + // Captured separately so tests can assert call counts without the + // `unbound-method` lint rule firing on `mockConfig.editConfig`. + editConfigMock = mock( + (mutate: (config: ProjectsConfig) => ProjectsConfig | undefined): Promise => { + const next = mutate(currentProjectsConfig); + if (next) currentProjectsConfig = next; + return Promise.resolve(); + } + ); + + mockConfig = { + loadConfigOrDefault: mock(() => currentProjectsConfig), + findWorkspace: mock(() => ({ + workspacePath: TEST_WORKSPACE_PATH, + projectPath: TEST_PROJECT_PATH, + })), + editConfig: editConfigMock, + saveConfig: mock((nextConfig: ProjectsConfig) => { + currentProjectsConfig = nextConfig; + return Promise.resolve(); + }), + } as unknown as Config; + + service = new WorkspaceService( + mockConfig, + {} as HistoryService, + new EventEmitter() as unknown as AIService, + new EventEmitter() as unknown as InitStateManager, + { + updateRecency: mock(() => + Promise.resolve({ + recency: Date.now(), + streaming: false, + lastModel: null, + lastThinkingLevel: null, + agentStatus: null, + }) + ), + } as unknown as ExtensionMetadataService, + {} as BackgroundProcessManager + ); + ( + service as unknown as { emitCurrentWorkspaceMetadata: () => Promise } + ).emitCurrentWorkspaceMetadata = mock(() => Promise.resolve()); + }); + + afterEach(() => { + mock.restore(); + }); + + test("clears an active snooze so a user message releases the chat from the Snoozed section", async () => { + currentProjectsConfig = createProjectsConfig( + createWorkspace(new Date(Date.now() + 60 * 60_000).toISOString()) + ); + + await callPrivateHelper(service, TEST_WORKSPACE_ID); + + const persisted = currentProjectsConfig.projects.get(TEST_PROJECT_PATH)?.workspaces.at(0); + expect(persisted?.snoozedUntil).toBeUndefined(); + }); + + test("fast-paths the not-snoozed case without calling editConfig", async () => { + // The not-snoozed branch must avoid a `setSnooze` round-trip so the + // per-message overhead stays at "single sync config read". + await callPrivateHelper(service, TEST_WORKSPACE_ID); + + expect(editConfigMock).not.toHaveBeenCalled(); + }); + + test("swallows errors so a transient config failure can't block the message send", async () => { + currentProjectsConfig = createProjectsConfig( + createWorkspace(new Date(Date.now() + 60 * 60_000).toISOString()) + ); + editConfigMock = mock(() => Promise.reject(new Error("disk full"))); + mockConfig.editConfig = editConfigMock as unknown as Config["editConfig"]; + + // Direct await keeps the assertion compatible with the lint rule that + // flags `expect(...).resolves` chains as non-thenable in some configs. + const result = await callPrivateHelper(service, TEST_WORKSPACE_ID); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 6bb06dd396..e69532690a 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4417,6 +4417,41 @@ export class WorkspaceService extends EventEmitter { } } + /** + * Best-effort: clear any active snooze on a workspace when the user sends a + * real (non-synthetic) message. Called fire-and-forget from `sendMessage`. + * + * Fast-paths the common "not snoozed" case with a cheap config read so we + * don't pay a `setSnooze` write on every single message send. Failures are + * logged at debug and swallowed so a transient config write error can never + * block the actual message — the user can re-snooze manually if needed. + */ + private async clearSnoozeOnUserMessage(workspaceId: string): Promise { + try { + const found = this.config.findWorkspace(workspaceId); + if (!found) return; + const config = this.config.loadConfigOrDefault(); + const projectConfig = config.projects.get(found.projectPath); + const entry = projectConfig?.workspaces.find((w) => w.id === workspaceId); + if (!entry?.snoozedUntil) { + // Fast path: most sends land here since most workspaces aren't snoozed. + return; + } + const result = await this.setSnooze(workspaceId, null); + if (!result.success) { + log.debug("Failed to auto-unsnooze workspace on user message", { + workspaceId, + error: result.error, + }); + } + } catch (error) { + log.debug("Auto-unsnooze threw on user message", { + workspaceId, + error: getErrorMessage(error), + }); + } + } + /** * Archive a workspace. Archived workspaces are hidden from the main sidebar * but can be viewed on the project page. @@ -5881,6 +5916,20 @@ export class WorkspaceService extends EventEmitter { void this.updateRecencyTimestamp(workspaceId, messageTimestamp); } + // Auto-unsnooze on real user sends — re-engaging with a chat by sending a + // message should release it from the Snoozed section the way Gmail/Slack + // snoozes clear on user interaction. Synthetic sends (heartbeats, idle + // compaction, goal continuations) are backend-initiated maintenance and + // must NOT count as re-engagement, otherwise the next scheduled + // heartbeat would drain the section right after the user snoozed. + // + // Fire-and-forget mirrors the recency update above; the persisted + // snooze state is best-effort and a transient write failure must never + // block the actual message send. + if (internal?.synthetic !== true) { + void this.clearSnoozeOnUserMessage(workspaceId); + } + const normalizedOptions = this.normalizeSendMessageAgentId(options); // Reject before any settings persistence so an unpriced model can never From 157f49a9f664bdbd4d00dbaeb803749d6bafbb95 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 21 May 2026 10:28:09 -0500 Subject: [PATCH 6/8] fix: include snoozedUntil in sidebar stable-reference key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/browser/utils/workspace.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/browser/utils/workspace.ts b/src/browser/utils/workspace.ts index 542a06d602..e3c72ef065 100644 --- a/src/browser/utils/workspace.ts +++ b/src/browser/utils/workspace.ts @@ -23,5 +23,11 @@ export function getWorkspaceSidebarKey(meta: FrontendWorkspaceMetadata): string meta.taskStatus ?? "", // Task lifecycle label/state for sub-agent rows meta.agentType ?? "", // Agent preset badge/label (future) meta.subProjectPath ?? "", // Sub-project grouping and cwd context for sidebar organization + // Snooze deadline — drives whether the row renders under the dedicated + // 💤 Snoozed section or in its normal age tier. Without this, the stable + // reference comparator short-circuits on snooze/unsnooze and the sidebar + // only updates after a reload (the metadata Map is fresh but the cached + // sorted Map is returned unchanged). + meta.snoozedUntil ?? "", ].join("|"); } From e97a47d42892df5a78efd668e2c35aabd642d8d6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 21 May 2026 10:32:14 -0500 Subject: [PATCH 7/8] fix: defer auto-unsnooze until message is actually accepted 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. --- src/node/services/workspaceService.ts | 31 +++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index e69532690a..8fdf03ccda 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -5916,20 +5916,6 @@ export class WorkspaceService extends EventEmitter { void this.updateRecencyTimestamp(workspaceId, messageTimestamp); } - // Auto-unsnooze on real user sends — re-engaging with a chat by sending a - // message should release it from the Snoozed section the way Gmail/Slack - // snoozes clear on user interaction. Synthetic sends (heartbeats, idle - // compaction, goal continuations) are backend-initiated maintenance and - // must NOT count as re-engagement, otherwise the next scheduled - // heartbeat would drain the section right after the user snoozed. - // - // Fire-and-forget mirrors the recency update above; the persisted - // snooze state is best-effort and a transient write failure must never - // block the actual message send. - if (internal?.synthetic !== true) { - void this.clearSnoozeOnUserMessage(workspaceId); - } - const normalizedOptions = this.normalizeSendMessageAgentId(options); // Reject before any settings persistence so an unpriced model can never @@ -6004,6 +5990,16 @@ export class WorkspaceService extends EventEmitter { this.taskService?.backgroundForegroundWaitsForWorkspace(workspaceId); } + // Auto-unsnooze only after the queue accepted the user's message — + // Codex P2 catch: doing this earlier (alongside the recency update) + // would drain the snooze even for sends that fail validation + // (pricing gate, queued-task block, requireIdle, etc.). Synthetic + // sends are excluded so heartbeats/idle compaction/goal continuations + // can't drain the section the moment the user snoozes. + if (internal?.synthetic !== true) { + void this.clearSnoozeOnUserMessage(workspaceId); + } + return Ok(undefined); } @@ -6084,6 +6080,13 @@ export class WorkspaceService extends EventEmitter { return result; } + // Auto-unsnooze only after the message was actually accepted by the + // session (see queue-path comment above for rationale). Same synthetic + // gate so backend-initiated maintenance sends never drain the section. + if (internal?.synthetic !== true) { + void this.clearSnoozeOnUserMessage(workspaceId); + } + if (claimedAutoTitle) { const autoTitlePromise = this.maybeRunPendingAutoTitleFromMessage(workspaceId, message); autoTitlePromise From 5ae1313f6e8714c56ce3ffc2e5ac8d2399794a6d Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 21 May 2026 10:39:57 -0500 Subject: [PATCH 8/8] fix: handle legacy path-only entries in clearSnoozeOnUserMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../services/workspaceService.snooze.test.ts | 20 +++++++++++++++++++ src/node/services/workspaceService.ts | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceService.snooze.test.ts b/src/node/services/workspaceService.snooze.test.ts index 10f7a8a2d5..5844d196ad 100644 --- a/src/node/services/workspaceService.snooze.test.ts +++ b/src/node/services/workspaceService.snooze.test.ts @@ -234,4 +234,24 @@ describe("WorkspaceService.clearSnoozeOnUserMessage", () => { const result = await callPrivateHelper(service, TEST_WORKSPACE_ID); expect(result).toBeUndefined(); }); + + test("clears legacy entries identified only by path (no id in config)", async () => { + // Reproduces Codex P2: legacy config rows that have a `path` but no `id` + // must still be picked up by the auto-unsnooze fast-path. Without the + // path fallback, `find((w) => w.id === workspaceId)` returns undefined, + // the fast-path treats it as "not snoozed", and the workspace stays + // stuck in the Snoozed section forever on re-engagement. + const legacyEntry: Workspace = { + path: TEST_WORKSPACE_PATH, + name: "test", + snoozedUntil: new Date(Date.now() + 60 * 60_000).toISOString(), + } as unknown as Workspace; + currentProjectsConfig = createProjectsConfig(legacyEntry); + + await callPrivateHelper(service, TEST_WORKSPACE_ID); + + expect(editConfigMock).toHaveBeenCalled(); + const persisted = currentProjectsConfig.projects.get(TEST_PROJECT_PATH)?.workspaces.at(0); + expect(persisted?.snoozedUntil).toBeUndefined(); + }); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 8fdf03ccda..238af71840 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4432,7 +4432,13 @@ export class WorkspaceService extends EventEmitter { if (!found) return; const config = this.config.loadConfigOrDefault(); const projectConfig = config.projects.get(found.projectPath); - const entry = projectConfig?.workspaces.find((w) => w.id === workspaceId); + // Mirror `setSnooze`'s id-or-path fallback so legacy config entries + // (those only identified by `path`, with no `id` yet) still match. + // Without the path fallback, the fast-path would silently treat legacy + // entries as "not snoozed" and leave the workspace stuck. + const entry = + projectConfig?.workspaces.find((w) => w.id === workspaceId) ?? + projectConfig?.workspaces.find((w) => w.path === found.workspacePath); if (!entry?.snoozedUntil) { // Fast path: most sends land here since most workspaces aren't snoozed. return;