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..16bf31832c 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,35 @@ 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. + // 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, sectionId?: string, allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces ): React.ReactNode => { + const { active: activeWorkspaces, snoozed: snoozedWorkspaces } = + partitionWorkspacesBySnooze(workspaces, { + parentLookup: projectParentLookup, + }); 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 +2538,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 +2731,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 +2789,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 +445,24 @@ 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. + // + // 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) && !isEditableElement(e.target)) { + 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 +792,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 +813,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. + // + // 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; + } + 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 && + Number.isFinite(effectiveDurationMs) && + effectiveDurationMs > 0 && + effectiveDurationMs <= MAX_SNOOZE_MS && + api != null; + + const handleSnooze = async () => { + if ( + effectiveDurationMs == null || + !Number.isFinite(effectiveDurationMs) || + effectiveDurationMs <= 0 || + effectiveDurationMs > MAX_SNOOZE_MS || + !api + ) { + return; + } + setIsSaving(true); + setError(null); + 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); + } + }; + + 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 && ( +

+ {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. + + )} +

+ )} +
+ +
+ 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..77a848e325 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -468,6 +468,69 @@ 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). + // + // 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 || !Number.isFinite(parsed.durationMs) + ? 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..5f836ae6f9 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 { MAX_SNOOZE_MS, parseHumanDurationMs } from "@/common/utils/snooze"; function tokenizeCommandLine(input: string): string[] { return (input.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((token) => @@ -432,6 +433,54 @@ 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); + // 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", + 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 +724,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..49a1b3bbe7 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -374,6 +374,13 @@ 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+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 // 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..e7269bb3a4 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,59 @@ 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], { nowMs: 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], { 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 c0c404db38..0809966273 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,99 @@ 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, { 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[], + options?: { + nowMs?: number; + parentLookup?: ReadonlyMap; + } +): SnoozePartitionResult { + if (workspaces.length === 0) { + return { active: [], snoozed: [] }; + } + + 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(); + + 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 = resolveById(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/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("|"); } 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..5844d196ad --- /dev/null +++ b/src/node/services/workspaceService.snooze.test.ts @@ -0,0 +1,257 @@ +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. + // Return Promise.resolve() explicitly instead of marking the mock + // `async` — lint flags async mocks without an `await` expression. + editConfig: mock( + (mutate: (config: ProjectsConfig) => ProjectsConfig | undefined): Promise => { + const next = mutate(currentProjectsConfig); + if (next) currentProjectsConfig = next; + return Promise.resolve(); + } + ), + 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); + }); +}); + +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(); + }); + + 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 5170a5001e..238af71840 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,116 @@ 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)}`); + } + } + + /** + * 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); + // 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; + } + 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. @@ -5885,6 +5996,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); } @@ -5965,6 +6086,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